From b634f41a1c818fb0cb4809c868059ae9db721492 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 29 Sep 2026 14:59:08 -0400 Subject: [PATCH] Complete paid-file caching and deliver LND waiting UI to existing nodes --- CHANGELOG.md | 1 + core/archipelago/src/api/rpc/content.rs | 189 ++++++++++++------ core/archipelago/src/api/rpc/content_tests.rs | 164 +++++++++++++++ core/archipelago/src/container/companion.rs | 37 +++- docker/lnd-ui/index.html | 24 ++- docs/repair-release-20260929.md | 46 +++++ .../src/views/settings/AccountInfoSection.vue | 1 + tests/regression/lnd-ui-readiness.cjs | 15 ++ 8 files changed, 401 insertions(+), 76 deletions(-) create mode 100644 core/archipelago/src/api/rpc/content_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fcd434e2..061f0b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change. - Payment failures now report whether a refund actually succeeded; missing files and unsupported payment methods are rejected before charging. +- Improved saving paid files into Files and reopening purchases without paying again. - Bitcoin Core and Knots installation offers optional pruning on larger disks, using the same settings as automatic pruning. - Fixed false missing-port checks that unnecessarily restarted Bitcoin and LND; recovery now respects managed shutdown timeouts. - LND explains when it is waiting for Bitcoin installation, startup, or sync, without treating normal synchronization as a restart-worthy failure. diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index be4c2445..dc6cde36 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -43,6 +43,90 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: & } } +/// Keep first purchases and cached repeats compatible with both existing clients. +fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json::Value { + use base64::Engine; + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + serde_json::json!({ + "data": data, "data_base64": data, + "size": bytes.len(), "size_bytes": bytes.len(), + "mime_type": mime, "paid_sats": paid_sats, "owned": true, + }) +} + +/// FileBrowser owns its files through a rootless UID mapping. Use its authenticated +/// API rather than writing host paths with the backend's unrelated UID. Its +/// override=false upload atomically refuses existing names, including races. +async fn file_purchase_in_files( + client: &reqwest::Client, + base_url: &str, + token: &str, + filename: &str, + mime: &str, + bytes: &[u8], +) -> Result { + let folder = if mime.starts_with("image/") || mime.starts_with("video/") { + "Photos" + } else if mime.starts_with("audio/") { + "Music" + } else { + "Documents" + }; + let mut folder_url = reqwest::Url::parse(base_url)?; + folder_url + .path_segments_mut() + .map_err(|_| anyhow::anyhow!("Invalid Files URL"))? + .extend(["api", "resources", folder, ""]); + let response = client + .get(folder_url.clone()) + .header("X-Auth", token) + .send() + .await?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + let response = client + .post(folder_url.clone()) + .header("X-Auth", token) + .send() + .await?; + if response.status() != reqwest::StatusCode::CONFLICT { + response.error_for_status()?; + } + } else { + response.error_for_status()?; + } + let base = std::path::Path::new(filename) + .file_name() + .and_then(|n| n.to_str()) + .filter(|n| !n.is_empty()) + .unwrap_or("download"); + let (stem, extension) = match base.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() => (stem, format!(".{ext}")), + _ => (base, String::new()), + }; + for attempt in 1..=100 { + let name = if attempt == 1 { + base.to_string() + } else { + format!("{stem} ({attempt}){extension}") + }; + let mut url = folder_url.clone(); + url.path_segments_mut().unwrap().pop_if_empty().push(&name); + url.query_pairs_mut().append_pair("override", "false"); + let response = client + .post(url) + .header("X-Auth", token) + .body(bytes.to_vec()) + .send() + .await?; + if response.status() == reqwest::StatusCode::CONFLICT { + continue; + } + response.error_for_status()?; + return Ok(format!("{folder}/{name}")); + } + anyhow::bail!("Too many existing copies; purchased file remains in the purchase cache") +} + impl RpcHandler { /// List content I'm sharing. pub(super) async fn handle_content_list_mine(&self) -> Result { @@ -464,17 +548,10 @@ impl RpcHandler { crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id) .await { - use base64::Engine; - return Ok(serde_json::json!({ - "owned": true, - "already_owned": true, - "filename": o.filename, - "mime_type": mime, - "size_bytes": bytes.len(), - "paid_sats": 0, - "data_base64": - base64::engine::general_purpose::STANDARD.encode(&bytes), - })); + let mut result = paid_content_response(&bytes, &mime, 0); + result["already_owned"] = serde_json::json!(true); + result["filename"] = serde_json::json!(o.filename); + return Ok(result); } // Cache record exists but bytes are gone — fall through and // repurchase rather than stranding the user. @@ -649,63 +726,41 @@ impl RpcHandler { tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}"); } - // Auto-file the purchase into the user's Files area (2026-07-22): - // Photos for images/video, Music for audio, Documents otherwise — - // same buckets the Cloud view uses. The in-app viewer still plays - // from the purchase cache; this makes the file ALSO show up where - // files live, on every device, without relying on a browser - // download. Best-effort: never fail a paid download over it. - { - let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") { - "Photos" - } else if mime_type.starts_with("audio/") { - "Music" - } else { - "Documents" - }; - let base = std::path::Path::new(&filename) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("download") - .to_string(); - let dir = self.config.data_dir.join("filebrowser").join(folder); - if let Err(e) = tokio::fs::create_dir_all(&dir).await { - tracing::warn!("paid download: cannot create {}: {e}", dir.display()); - } else { - // Don't clobber an existing file of the same name: "x.jpg" - // → "x (2).jpg" etc. - let mut target = dir.join(&base); - let (stem, ext) = match base.rsplit_once('.') { - Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")), - _ => (base.clone(), String::new()), - }; - let mut n = 2; - while target.exists() { - target = dir.join(format!("{stem} ({n}){ext}")); - n += 1; - } - match tokio::fs::write(&target, &bytes).await { - Ok(()) => tracing::info!("paid download: filed into {}", target.display()), - Err(e) => tracing::warn!( - "paid download: filing into {} failed (non-fatal): {e}", - target.display() - ), - } - } + // The durable purchased-content cache above is primary. A Files copy + // remains optional: a stopped FileBrowser must not undo a paid download. + let filed = async { + let auth = self.handle_filebrowser_token().await?; + let token = auth + .get("token") + .and_then(|v| v.as_str()) + .context("FileBrowser omitted its authentication token")?; + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(30)) + .build()?; + file_purchase_in_files( + &client, + "http://127.0.0.1:8083", + token, + &filename, + &mime_type, + &bytes, + ) + .await + } + .await; + match filed { + Ok(path) => tracing::info!("paid download: filed into Files/{path}"), + Err(error) => tracing::warn!( + "paid download: optional Files copy failed; purchase cache retained: {error}" + ), } - use base64::Engine; - let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); - tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len()); - Ok(serde_json::json!({ - "data": encoded, - "size": bytes.len(), - "paid_sats": price_sats, - "ecash_backend": used_backend, - "mime_type": mime_type, - "owned": true, - })) + let mut result = paid_content_response(&bytes, &mime_type, price_sats); + result["ecash_backend"] = serde_json::json!(used_backend); + Ok(result) } /// Buyer side (#46): ask the selling node to mint a Lightning invoice for a @@ -1378,3 +1433,7 @@ impl RpcHandler { } } } + +#[cfg(test)] +#[path = "content_tests.rs"] +mod tests; diff --git a/core/archipelago/src/api/rpc/content_tests.rs b/core/archipelago/src/api/rpc/content_tests.rs new file mode 100644 index 00000000..78fa9d51 --- /dev/null +++ b/core/archipelago/src/api/rpc/content_tests.rs @@ -0,0 +1,164 @@ +use super::*; +use hyper::{ + service::{make_service_fn, service_fn}, + Body, Response, Server, +}; +use std::{ + collections::VecDeque, + convert::Infallible, + sync::{Arc, Mutex}, +}; + +struct FilesApi { + url: String, + seen: Arc)>>>, + task: tokio::task::JoinHandle<()>, +} +impl Drop for FilesApi { + fn drop(&mut self) { + self.task.abort(); + } +} +fn files_api(statuses: Vec) -> FilesApi { + let statuses = Arc::new(Mutex::new(VecDeque::from(statuses))); + let seen = Arc::new(Mutex::new(Vec::new())); + let history = seen.clone(); + let server = Server::bind(&([127, 0, 0, 1], 0).into()); + let address = server.local_addr(); + let service = make_service_fn(move |_| { + let statuses = statuses.clone(); + let seen = history.clone(); + async move { + Ok::<_, Infallible>(service_fn(move |request: hyper::Request| { + let statuses = statuses.clone(); + let seen = seen.clone(); + async move { + assert_eq!(request.headers().get("X-Auth").unwrap(), "test-session"); + let method = request.method().to_string(); + let uri = request.uri().to_string(); + let body = hyper::body::to_bytes(request.into_body()) + .await + .unwrap() + .to_vec(); + seen.lock().unwrap().push((method, uri, body)); + let status = statuses + .lock() + .unwrap() + .pop_front() + .expect("unexpected extra Files request"); + Ok::<_, Infallible>( + Response::builder() + .status(status) + .body(Body::empty()) + .unwrap(), + ) + } + })) + } + }); + FilesApi { + url: format!("http://{address}"), + seen, + task: tokio::spawn(async move { + server.serve(service).await.unwrap(); + }), + } +} + +#[test] +fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() { + use base64::Engine; + for paid in [0, 1] { + let response = paid_content_response(&[0, 255, 123], "application/octet-stream", paid); + assert_eq!(response["data"], response["data_base64"]); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(response["data"].as_str().unwrap()) + .unwrap(), + [0, 255, 123] + ); + assert_eq!(response["size"], 3); + assert_eq!(response["size_bytes"], 3); + assert_eq!(response["paid_sats"], paid); + assert_eq!(response["owned"], true); + } +} + +#[tokio::test] +async fn files_copy_uses_authenticated_api_and_preserves_existing_names() { + let api = files_api(vec![200, 409, 200]); + let client = reqwest::Client::new(); + let path = file_purchase_in_files( + &client, + &api.url, + "test-session", + "../my #file?.txt", + "text/plain", + b"paid bytes", + ) + .await + .unwrap(); + assert_eq!(path, "Documents/my #file? (2).txt"); + let seen = api.seen.lock().unwrap(); + assert_eq!(seen[0].0, "GET"); + assert_eq!(seen[0].1, "/api/resources/Documents/"); + assert_eq!(seen.len(), 3); + for (_, uri, body) in &seen[1..] { + assert!(uri.contains("override=false")); + assert!(uri.contains("%23file%3F")); + assert!(!uri.contains("../")); + assert_eq!(body, b"paid bytes"); + } +} + +#[tokio::test] +async fn files_copy_creates_missing_media_folder() { + for (mime, folder) in [ + ("image/png", "Photos"), + ("video/mp4", "Photos"), + ("audio/ogg", "Music"), + ] { + let api = files_api(vec![404, 200, 200]); + let path = file_purchase_in_files( + &reqwest::Client::new(), + &api.url, + "test-session", + "file", + mime, + b"bytes", + ) + .await + .unwrap(); + assert_eq!(path, format!("{folder}/file")); + let seen = api.seen.lock().unwrap(); + assert_eq!(seen[1].0, "POST"); + assert!(seen[1].1.ends_with('/')); + assert!(seen[1].2.is_empty()); + assert_eq!(seen[2].2, b"bytes"); + } +} + +#[tokio::test] +async fn files_copy_fails_without_overwriting_or_claiming_success_on_errors() { + for statuses in [ + vec![401], + vec![503], + vec![404, 500], + vec![200, 507], + vec![200, 403], + ] { + let expected = statuses.len(); + let api = files_api(statuses); + assert!(file_purchase_in_files( + &reqwest::Client::new(), + &api.url, + "test-session", + "file.txt", + "text/plain", + b"bytes" + ) + .await + .is_err()); + assert_eq!(api.seen.lock().unwrap().len(), expected); + } +} diff --git a/core/archipelago/src/container/companion.rs b/core/archipelago/src/container/companion.rs index 9752454f..42f1b8da 100644 --- a/core/archipelago/src/container/companion.rs +++ b/core/archipelago/src/container/companion.rs @@ -313,7 +313,7 @@ async fn image_id(image_ref: &str) -> Option { /// should reference (`localhost/:latest` for build, registry /// URL for pull). async fn ensure_image_present(spec: &CompanionSpec) -> Result { - let local_image = format!("localhost/{}:latest", spec.image_base); + let mut local_image = format!("localhost/{}:latest", spec.image_base); let local_image_compat = format!("localhost/{}:local", spec.image_base); let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base); @@ -322,11 +322,13 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result { for dir in spec.build_dir_candidates { let dockerfile = PathBuf::from(dir).join("Dockerfile"); if fs::try_exists(&dockerfile).await.unwrap_or(false) { - // `:local` is a deliberate manual override — never auto-rebuild it. + // Older installers and self-update create :local themselves. It + // must receive source updates too; treating it as a permanent + // manual override silently kept the old LND UI after an OTA. if image_exists(&local_image_compat).await { - return Ok(local_image_compat); + local_image = local_image_compat.clone(); } - // Reuse the auto-built `:latest` only when the build context has NOT + // Reuse either local tag only when the build context has NOT // changed since it was built. Without this staleness check an // already-present image is reused forever, so edits to the baked-in // context (Dockerfile, nginx.conf, …) never reach the node — this is @@ -849,20 +851,43 @@ async fn needs_repair(spec: &CompanionSpec) -> Result { if !matches_known_shape { return Ok(true); } - if on_disk.contains(&local_image) && !on_disk.contains(&local_image_compat) { + if let Some(image) = managed_local_image(spec, &on_disk) { for dir in spec.build_dir_candidates { let dockerfile = PathBuf::from(dir).join("Dockerfile"); if fs::try_exists(&dockerfile).await.unwrap_or(false) { // Conservative on any timeout/error inside: reuse the cache. - return Ok(context_is_newer_than_image(dir, &local_image).await); + return Ok(context_is_newer_than_image(dir, &image).await); } } } Ok(false) } +fn managed_local_image(spec: &CompanionSpec, unit: &str) -> Option { + ["latest", "local"] + .iter() + .map(|tag| format!("localhost/{}:{tag}", spec.image_base)) + .find(|image| build_unit(spec, image).render() == unit) +} + #[cfg(test)] mod tests { + #[test] + fn legacy_installer_local_tag_is_checked_for_source_updates_like_latest() { + for spec in ALL_COMPANIONS.iter().flat_map(|group| group.iter()) { + for tag in ["local", "latest"] { + let image = format!("localhost/{}:{tag}", spec.image_base); + let unit = build_unit(spec, &image).render(); + assert_eq!(managed_local_image(spec, &unit), Some(image)); + } + let registry = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base); + assert_eq!( + managed_local_image(spec, &build_unit(spec, ®istry).render()), + None + ); + } + } + use super::*; fn names(specs: &[&'static CompanionSpec]) -> Vec<&'static str> { diff --git a/docker/lnd-ui/index.html b/docker/lnd-ui/index.html index 2e1edf11..64dd2ad9 100644 --- a/docker/lnd-ui/index.html +++ b/docker/lnd-ui/index.html @@ -1290,13 +1290,14 @@ setText('balOnchainSub', !haveOnchain ? 'waiting for LND' : state.onchainStale ? 'last known balance' : onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed'); - setText('liqLocal', fmtAmount(lnLocal)); - setText('liqRemote', fmtAmount(lnRemote)); + const liquidityReady = haveChan && !state.chanbalStale && !!state.info; + setText('liqLocal', liquidityReady ? fmtAmount(lnLocal) : '—'); + setText('liqRemote', liquidityReady ? fmtAmount(lnRemote) : '—'); const total = lnLocal + lnRemote; const localPct = total > 0 ? (lnLocal / total) * 100 : 50; - document.getElementById('liqBarLocal').style.width = localPct + '%'; - document.getElementById('liqBarRemote').style.width = (100 - localPct) + '%'; - setText('liqHint', total > 0 + document.getElementById('liqBarLocal').style.width = (liquidityReady ? localPct : 0) + '%'; + document.getElementById('liqBarRemote').style.width = (liquidityReady ? 100 - localPct : 0) + '%'; + setText('liqHint', !liquidityReady ? 'Channel capacity is unavailable while waiting for LND.' : total > 0 ? Math.round(localPct) + '% of your channel capacity is outbound (sendable).' : 'Open a channel to start sending and receiving over Lightning.'); } @@ -1312,6 +1313,15 @@ function renderSummary() { const g = state.info; + if (!g) { + for (const id of ['statPeers', 'statActiveChannels', 'statCapacity', 'statRoutingMonth', 'healthHeight', 'healthPending', 'chActive', 'chInactive', 'chPending', 'chCapacity']) setText(id, '—'); + for (const id of ['statChannelsSub', 'channelsLinkSub']) setText(id, 'Waiting for LND'); + for (const id of ['healthChain', 'healthGraph']) { + const pill = document.getElementById(id); + pill.textContent = '—'; pill.className = 'pill warn'; + } + return; + } const chans = state.channels; const active = chans.filter(c => c.active).length; const inactive = chans.length - active; @@ -1349,6 +1359,10 @@ function renderChannels() { const el = document.getElementById('channelList'); if (!el) return; + if (!state.info) { + el.innerHTML = '
Waiting for LND. Existing channels will appear when it is ready.
'; + return; + } const q = (document.getElementById('channelFilter').value || '').toLowerCase(); let list = state.channels.slice(); if (q) list = list.filter(c => String(c.remote_pubkey || '').toLowerCase().includes(q) || String(c.chan_id || '').includes(q)); diff --git a/docs/repair-release-20260929.md b/docs/repair-release-20260929.md index 0c577a80..cd605f5b 100644 --- a/docs/repair-release-20260929.md +++ b/docs/repair-release-20260929.md @@ -110,3 +110,49 @@ Bitcoin/LND fallback receives 600/330-second grace respectively. Regression uses read-only checks of the actual Bitcoin listener pass. Script deployed to dev and Shorty with root-only rollback copies. OTA runtime payload includes scripts/. This evidence supersedes the earlier unknown-caller/unknown-exit attribution. + +### Initial candidate live validation — 18:48 UTC + +Source 0f85f588, optimized backend SHA256 +84434c495c5f8472cf6bfcb6c65e762502c74718ad88271619373335c0054bb6, +deployed to dev and Shorty with matching hashes and rollback copies. Both +management services restarted; wallets/channels were not reset. Old embedded +runtime assets restored the old doctor on backend startup; updated the live +script AND embedded runtime copy on both nodes. Final OTA will contain the new +script directly. + +Authenticated dev readiness transitioned from waiting_start to waiting_sync. +Real Chromium at 1440px and 390px showed Waiting for Bitcoin to sync, an unknown +balance, and no blocked native LND calls. Screenshot review also caught invented +zero capacity/channel counts during waiting: corrected them and the empty-channel +recommendation; five UI regression tests now pass. + +Real Minibits Cashu purchase from dev to Shorty succeeded for one sat and returned +the expected 44 bytes. A rejected one-sat underpayment was refunded exactly, and +two cached downloads charged zero. Temporary seller files/catalog entries removed. +The first test runner expected data_base64 while the first-purchase API returns +data; cached responses use data_base64. Existing purchase clients only consume +data, so a follow-up normalizes both response variants to both fields. + +The optional Files copy failed because FileBrowser owns host paths as mapped UID +100000. Follow-up uses its authenticated API with override=false and collision +suffixes. A live API probe succeeded, refused overwrite with HTTP409, preserved +original bytes, and cleaned up. New protocol tests cover folder creation, escaped +names, collisions, authentication failure, disk-full, and unavailable service. +Full backend suite for these follow-ups is running; do not package the earlier +backend as final. + +### Follow-up validation and OTA delivery check + +Paid-response and Files API regressions passed in the full backend run: 1,552 +passed, zero failed, four existing ignored tests. Live browser waiting checks +passed again after removing invented zero capacity and channel counts. + +OTA inspection found that companion image :local (created by old installers and +used on dev) bypassed both source-staleness detection and rebuilding. The earlier +assumption that build-context detection covered these nodes was incorrect. +Follow-up applies the existing source-mtime/stamp checks to both :local and +:latest, preserving the existing tag and rebuilding only stale source. Existing +image-ID comparison then restarts the UI companion onto the new image. This does +not restart LND itself. Regression covers every companion's two local tags; final +backend suite is running. Verify the resulting live rebuilt image before release. diff --git a/neode-ui/src/views/settings/AccountInfoSection.vue b/neode-ui/src/views/settings/AccountInfoSection.vue index 9a886e51..d5bd3ff6 100644 --- a/neode-ui/src/views/settings/AccountInfoSection.vue +++ b/neode-ui/src/views/settings/AccountInfoSection.vue @@ -371,6 +371,7 @@ init()

Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.

Payment failures now report whether a refund actually succeeded; missing files and unsupported payment methods are rejected before charging.

+

Improved saving paid files into Files and reopening purchases without paying again.

Bitcoin Core and Knots installation offers optional pruning on larger disks, using the same settings as automatic pruning.

Fixed false missing-port checks that unnecessarily restarted Bitcoin and LND; recovery now respects managed shutdown timeouts.

LND explains when it is waiting for Bitcoin installation, startup, or sync, without treating normal synchronization as a restart-worthy failure.

diff --git a/tests/regression/lnd-ui-readiness.cjs b/tests/regression/lnd-ui-readiness.cjs index bd366d1f..25fcee47 100644 --- a/tests/regression/lnd-ui-readiness.cjs +++ b/tests/regression/lnd-ui-readiness.cjs @@ -92,3 +92,18 @@ test('waiting renders promptly without querying unavailable LND endpoints, then assert.ok(calls.includes('/v1/getinfo')); assert.ok(calls.includes('/v1/balance/blockchain')); }); + +test('cold waiting never invents zero channel capacity or an empty wallet recommendation',()=>{ + const {ctx,el}=fixture(); + Object.assign(ctx,{num:v=>Number(v)||0,fmtAmount:String,fmtAmountShort:String,setBalance:(id,v)=>el(id).value=v}); + vm.runInContext(extract('renderBalances')+'\n'+extract('renderSummary')+'\n'+extract('renderChannels'),ctx); + ctx.state.channels=[]; + ctx.renderBalances();ctx.renderSummary();ctx.renderChannels(); + for(const id of ['liqLocal','liqRemote','statActiveChannels','healthPending','chActive']) assert.equal(el(id).textContent,'—'); + assert.equal(el('balTotal').value,null); + assert.match(el('liqHint').textContent,/waiting for LND/); + assert.doesNotMatch(el('channelList').innerHTML,/No payment channels yet/); + ctx.state.info={};ctx.state.chanbal={local_balance:{sat:'0'}}; + ctx.renderBalances(); + assert.equal(el('liqLocal').textContent,'0'); +});