Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a78aa02890 | ||
|
|
0365cc0f9d | ||
|
|
0880824fb3 | ||
|
|
c0d34bd836 | ||
|
|
c8d0dda656 | ||
|
|
57c6a4d512 | ||
|
|
a32e40da31 | ||
|
|
449ed7c1f7 | ||
|
|
729cee573a | ||
|
|
5b7ebd85b6 | ||
|
|
c6f11f8ddb | ||
|
|
a26090e561 | ||
|
|
2609f60e6c | ||
|
|
c4c558954b | ||
|
|
d4ea4ed636 | ||
|
|
0fadbb1d0f | ||
|
|
0e5cd24e18 | ||
|
|
1d8e57d564 | ||
|
|
00b7e1798f | ||
|
|
bca1698682 | ||
|
|
0641ee85ef | ||
|
|
901cf5713a | ||
|
|
875da6c630 | ||
|
|
4589e88442 | ||
|
|
5862689949 | ||
|
|
3995ab5cf5 | ||
|
|
9d83ee3770 | ||
|
|
7e3d01f633 | ||
|
|
385c950f00 | ||
|
|
98e15b3af0 |
@@ -0,0 +1,62 @@
|
||||
name: Build Archipelago release ISO (gated)
|
||||
|
||||
# Resurrected from image-recipe/_archived/.gitea-workflows/build-iso-dev.yml.
|
||||
# Dispatch-only on purpose: the ISO is cut per release, not per push, and
|
||||
# the iso-builder runner is a live node — builds are deliberate events.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-iso:
|
||||
runs-on: iso-builder
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Sync source to workspace
|
||||
run: |
|
||||
# Direct fetch + sync (actions/checkout token is broken on this Gitea)
|
||||
REPO_DIR="$HOME/Projects/archy"
|
||||
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
|
||||
cd "$REPO_DIR" && git fetch origin main && git reset --hard origin/main
|
||||
echo "=== Source at commit: $(git log --oneline -1) ==="
|
||||
|
||||
- name: Install ISO build dependencies
|
||||
run: |
|
||||
if dpkg -s debootstrap squashfs-tools xorriso isolinux syslinux-common mtools \
|
||||
grub-efi-amd64-bin grub-pc-bin grub-common >/dev/null 2>&1; then
|
||||
echo "ISO build deps already installed, skipping apt"
|
||||
else
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq \
|
||||
debootstrap squashfs-tools xorriso \
|
||||
isolinux syslinux-common mtools \
|
||||
grub-efi-amd64-bin grub-pc-bin grub-common
|
||||
fi
|
||||
|
||||
- name: Build backend + frontend if stale
|
||||
run: |
|
||||
REPO_DIR="$HOME/Projects/archy"
|
||||
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
|
||||
cd "$REPO_DIR"
|
||||
. "$HOME/.cargo/env" 2>/dev/null || true
|
||||
VERSION=$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
|
||||
if ! strings core/target/release/archipelago 2>/dev/null | grep -qF "$VERSION"; then
|
||||
cargo build --release --manifest-path core/Cargo.toml -p archipelago
|
||||
fi
|
||||
if ! grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js 2>/dev/null; then
|
||||
(cd neode-ui && npm ci && npm run build)
|
||||
fi
|
||||
|
||||
- name: Gated ISO build (gates + build + smoke + qemu)
|
||||
run: |
|
||||
REPO_DIR="$HOME/Projects/archy"
|
||||
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
|
||||
cd "$REPO_DIR"
|
||||
. "$HOME/.cargo/env" 2>/dev/null || true
|
||||
bash scripts/build-iso-release.sh
|
||||
|
||||
- name: Report artifacts
|
||||
if: always()
|
||||
run: |
|
||||
REPO_DIR="$HOME/Projects/archy"
|
||||
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
|
||||
ls -lh "$REPO_DIR"/image-recipe/results/*.iso 2>/dev/null | tail -3 || echo "no ISO produced"
|
||||
@@ -23,6 +23,10 @@ used by the `.githooks/pre-push` hook), which:
|
||||
**aborts** if any is missing.
|
||||
5. Stages the signed APK at `neode-ui/public/packages/archipelago-companion.apk`,
|
||||
commits, and pushes with `SHIP_COMPANION=1` (the sanctioned pre-push bypass).
|
||||
6. The first-launch companion modal and Android "Share this app" QR point at
|
||||
`http://146.59.87.168:2100/packages/archipelago-companion.apk`. After the
|
||||
repo artifact is built, mirror that exact APK to the VPS2-served path before
|
||||
calling the release done.
|
||||
|
||||
**Never** hand-roll `gradlew assembleDebug` + `cp` to the served path. That path
|
||||
skips the clean build and the signature enforcement and is exactly how a broken
|
||||
@@ -82,13 +86,16 @@ home-screen app layouts wiped by an over-broad action.
|
||||
|
||||
## Verify the published download after shipping
|
||||
|
||||
The download served to nodes is Gitea raw-on-main. Confirm the live bytes match
|
||||
what you built and signed:
|
||||
The checked-in artifact is Gitea raw-on-main. The QR/App Store download served
|
||||
to users is the VPS2 `:2100` URL. Confirm both live byte streams match what you
|
||||
built and signed:
|
||||
|
||||
```bash
|
||||
SERVED=neode-ui/public/packages/archipelago-companion.apk
|
||||
URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
|
||||
curl -sS -o /tmp/live.apk "$URL"
|
||||
shasum -a 256 "$SERVED" /tmp/live.apk # must match
|
||||
apksigner verify -v --min-sdk-version 21 /tmp/live.apk | grep -i "scheme" # v1/v2/v3 = true
|
||||
GITEA_URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
|
||||
QR_URL=http://146.59.87.168:2100/packages/archipelago-companion.apk
|
||||
curl -sS -o /tmp/live-gitea.apk "$GITEA_URL"
|
||||
curl -sS -o /tmp/live-qr.apk "$QR_URL"
|
||||
shasum -a 256 "$SERVED" /tmp/live-gitea.apk /tmp/live-qr.apk # all must match
|
||||
apksigner verify -v --min-sdk-version 21 /tmp/live-qr.apk | grep -i "scheme" # v1/v2/v3 = true
|
||||
```
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 31
|
||||
versionName = "0.5.11"
|
||||
versionCode = 38
|
||||
versionName = "0.5.18"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -88,8 +88,18 @@ object ServerQrParser {
|
||||
|
||||
private fun parseFips(uri: Uri): FipsPairInfo? {
|
||||
val npub = uri.getQueryParameter("fnpub")?.trim()
|
||||
val host = uri.getQueryParameter("fhost")?.trim()
|
||||
var host = uri.getQueryParameter("fhost")?.trim()
|
||||
if (npub.isNullOrBlank() || host.isNullOrBlank()) return null
|
||||
// A .fips fhost is a dead dial hint: Android's system DNS can't
|
||||
// resolve .fips, so every handshake send fails and first connect
|
||||
// falls back to slow anchor discovery. If the QR's `url` host is a
|
||||
// real address, dial that instead (QRs minted while the node UI was
|
||||
// browsed over the mesh carry npub….fips here).
|
||||
if (host.endsWith(".fips")) {
|
||||
val urlHost = uri.getQueryParameter("url")
|
||||
?.let { runCatching { Uri.parse(it).host }.getOrNull() }
|
||||
if (!urlHost.isNullOrBlank() && !urlHost.endsWith(".fips")) host = urlHost
|
||||
}
|
||||
return FipsPairInfo(
|
||||
npub = npub,
|
||||
ula = uri.getQueryParameter("fip")?.trim().orEmpty(),
|
||||
|
||||
@@ -25,6 +25,26 @@ internal const val ARCHY_ANCHOR_NPUB =
|
||||
internal const val ARCHY_ANCHOR_ADDR = "146.59.87.168:8444"
|
||||
internal const val ARCHY_ANCHOR_TRANSPORT = "tcp"
|
||||
|
||||
/**
|
||||
* Public FIPS network anchors (join.fips.network — the dual-transport TCP
|
||||
* pair; keep in lockstep with core/archipelago/src/fips/anchors.rs
|
||||
* fips_network_anchors()). Baked into every pairing at trailing priority so
|
||||
* a degraded/unreachable vps2 anchor can never strand the phone: the mesh
|
||||
* still joins the public tree and routes to the node through it.
|
||||
*/
|
||||
internal val PUBLIC_FIPS_ANCHORS = listOf(
|
||||
Triple(
|
||||
"npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u",
|
||||
"23.182.128.74:443",
|
||||
"tcp",
|
||||
),
|
||||
Triple(
|
||||
"npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98",
|
||||
"217.77.8.91:443",
|
||||
"tcp",
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Mesh identity + known node peers. Follows the same plaintext-DataStore
|
||||
* storage model as ServerPreferences (the server password lives there the
|
||||
@@ -126,7 +146,7 @@ class FipsPreferences(private val context: Context) {
|
||||
if (peer.ip.isBlank() || peer.port <= 0) continue
|
||||
merged.put(JSONObject().apply {
|
||||
put("npub", peer.npub)
|
||||
put("alias", peer.name.ifBlank { "Party phone" })
|
||||
put("alias", hostSafeAlias(peer.name.ifBlank { "party-phone" }))
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", "udp")
|
||||
put("addr", "${peer.ip}:${peer.port}")
|
||||
@@ -145,7 +165,7 @@ class FipsPreferences(private val context: Context) {
|
||||
) {
|
||||
merged.put(JSONObject().apply {
|
||||
put("npub", ARCHY_ANCHOR_NPUB)
|
||||
put("alias", "Archipelago anchor")
|
||||
put("alias", "archipelago-anchor")
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", ARCHY_ANCHOR_TRANSPORT)
|
||||
put("addr", ARCHY_ANCHOR_ADDR)
|
||||
@@ -153,9 +173,40 @@ class FipsPreferences(private val context: Context) {
|
||||
}))
|
||||
})
|
||||
}
|
||||
// Backfill anchor peers for entries paired before newer QR/app
|
||||
// releases added them. `upsertNodePeer` persists these on re-scan, but
|
||||
// startup must also self-heal old DataStore state so updating the APK is
|
||||
// enough to get off-LAN redundancy.
|
||||
if (merged.length() > 0) {
|
||||
addAnchorIfMissing(merged, ARCHY_ANCHOR_NPUB, "archipelago-anchor", ARCHY_ANCHOR_ADDR, ARCHY_ANCHOR_TRANSPORT, 40)
|
||||
for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) {
|
||||
val (npub, addr, transport) = anchor
|
||||
addAnchorIfMissing(merged, npub, "fips-network-anchor-${i + 1}", addr, transport, 50 + i * 10)
|
||||
}
|
||||
}
|
||||
return merged.toString()
|
||||
}
|
||||
|
||||
private fun addAnchorIfMissing(
|
||||
peers: JSONArray,
|
||||
npub: String,
|
||||
alias: String,
|
||||
addr: String,
|
||||
transport: String,
|
||||
priority: Int,
|
||||
) {
|
||||
if ((0 until peers.length()).any { peers.optJSONObject(it)?.optString("npub") == npub }) return
|
||||
peers.put(JSONObject().apply {
|
||||
put("npub", npub)
|
||||
put("alias", alias)
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", transport)
|
||||
put("addr", addr)
|
||||
put("priority", priority)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
private fun parsePartyPeers(json: String): List<PartyPeer> = try {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
@@ -200,16 +251,19 @@ class FipsPreferences(private val context: Context) {
|
||||
val incoming = mutableListOf<JSONObject>()
|
||||
incoming += JSONObject().apply {
|
||||
put("npub", info.npub)
|
||||
put("alias", alias.ifBlank { "Archipelago" })
|
||||
put("alias", hostSafeAlias(alias.ifBlank { "Archipelago" }))
|
||||
val addresses = JSONArray()
|
||||
if (info.udpPort > 0) {
|
||||
// .fips hosts are unresolvable on Android (no system .fips DNS):
|
||||
// storing one gives the mesh a dial target that fails every
|
||||
// handshake and stalls first connect on anchor discovery.
|
||||
if (info.udpPort > 0 && !info.host.endsWith(".fips")) {
|
||||
addresses.put(JSONObject().apply {
|
||||
put("transport", "udp")
|
||||
put("addr", "${info.host}:${info.udpPort}")
|
||||
put("priority", 10)
|
||||
})
|
||||
}
|
||||
if (info.tcpPort > 0) {
|
||||
if (info.tcpPort > 0 && !info.host.endsWith(".fips")) {
|
||||
addresses.put(JSONObject().apply {
|
||||
put("transport", "tcp")
|
||||
put("addr", "${info.host}:${info.tcpPort}")
|
||||
@@ -220,9 +274,10 @@ class FipsPreferences(private val context: Context) {
|
||||
}
|
||||
for (anchor in info.anchors) {
|
||||
if (anchor.npub == info.npub) continue
|
||||
if (anchor.addr.substringBeforeLast(":").endsWith(".fips")) continue
|
||||
incoming += JSONObject().apply {
|
||||
put("npub", anchor.npub)
|
||||
put("alias", "Mesh anchor")
|
||||
put("alias", "mesh-anchor")
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", anchor.transport)
|
||||
put("addr", anchor.addr)
|
||||
@@ -237,7 +292,7 @@ class FipsPreferences(private val context: Context) {
|
||||
) {
|
||||
incoming += JSONObject().apply {
|
||||
put("npub", ARCHY_ANCHOR_NPUB)
|
||||
put("alias", "Archipelago anchor")
|
||||
put("alias", "archipelago-anchor")
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", ARCHY_ANCHOR_TRANSPORT)
|
||||
put("addr", ARCHY_ANCHOR_ADDR)
|
||||
@@ -245,6 +300,21 @@ class FipsPreferences(private val context: Context) {
|
||||
}))
|
||||
}
|
||||
}
|
||||
// And the public FIPS network anchors at trailing priority, so one
|
||||
// degraded rendezvous (vps2, 2026-07-24) can never strand the phone.
|
||||
for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) {
|
||||
val (npub, addr, transport) = anchor
|
||||
if (info.npub == npub || incoming.any { it.optString("npub") == npub }) continue
|
||||
incoming += JSONObject().apply {
|
||||
put("npub", npub)
|
||||
put("alias", "fips-network-anchor-${i + 1}")
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", transport)
|
||||
put("addr", addr)
|
||||
put("priority", 50 + i * 10)
|
||||
}))
|
||||
}
|
||||
}
|
||||
val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
|
||||
context.fipsDataStore.edit { prefs ->
|
||||
val current = JSONArray(prefs[peersKey] ?: "[]")
|
||||
@@ -258,3 +328,14 @@ class FipsPreferences(private val context: Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Peer aliases feed the fips host map as `<alias>.fips` hostnames; anything
|
||||
* that isn't a valid DNS label ("Framework PT" — the space) gets rejected and
|
||||
* silently drops the peer from name resolution. Slug it instead of losing it.
|
||||
*/
|
||||
internal fun hostSafeAlias(alias: String): String =
|
||||
alias.lowercase()
|
||||
.replace(Regex("[^a-z0-9.-]+"), "-")
|
||||
.trim('-', '.')
|
||||
.ifBlank { "archipelago" }
|
||||
|
||||
Generated
+1
-1
@@ -464,7 +464,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
[[package]]
|
||||
name = "fips"
|
||||
version = "0.3.0-dev"
|
||||
source = "git+https://github.com/9qeklajc/fips-native?rev=46494a74fc85b878305d275d42ab719082b06ba6#46494a74fc85b878305d275d42ab719082b06ba6"
|
||||
source = "git+https://github.com/Zazawowow/fips-native?rev=07d21d4482be56b14295d2525e41f8386d1bfe6f#07d21d4482be56b14295d2525e41f8386d1bfe6f"
|
||||
dependencies = [
|
||||
"bech32",
|
||||
"chacha20poly1305",
|
||||
|
||||
@@ -23,7 +23,10 @@ crate-type = ["lib", "cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# default-features drops the ratatui TUI; tun-support enables Node::start_with_tun_fd.
|
||||
fips = { git = "https://github.com/9qeklajc/fips-native", rev = "46494a74fc85b878305d275d42ab719082b06ba6", default-features = false, features = ["tun-support"] }
|
||||
# Zazawowow/fips-native `fast-join-pinned` = upstream pinned rev 46494a74 +
|
||||
# discovery re-fire on topology change (fresh 5G join: first route no longer
|
||||
# waits out doomed pre-join lookups). Upstream 9qeklajc denies pushes.
|
||||
fips = { git = "https://github.com/Zazawowow/fips-native", rev = "07d21d4482be56b14295d2525e41f8386d1bfe6f", default-features = false, features = ["tun-support"] }
|
||||
anyhow = "1.0"
|
||||
serde_json = "1.0"
|
||||
hex = "0.4"
|
||||
|
||||
@@ -85,6 +85,27 @@ pub fn build_config(secret: &str, peers: Vec<PeerConfig>, listen_port: u16) -> C
|
||||
});
|
||||
// TCP with no bind_addr = outbound-only (fallback when UDP is blocked).
|
||||
cfg.transports.tcp = TransportInstances::Single(Default::default());
|
||||
// Fast-connect profile — a phone opens the app and expects the node NOW.
|
||||
// Stock pacing is tuned for always-on routers: a failed discovery backs
|
||||
// off 30s, session resends gap out to 8-16s, dead links redial at
|
||||
// 5s→300s. Over 5G that stacked into a ~40s first connect (observed
|
||||
// 2026-07-24: anchor +10s, session +40s). Retries only fire while a
|
||||
// link/session is down, so steady-state traffic is unchanged.
|
||||
cfg.node.retry.base_interval_secs = 1; // dead-link redial 1s,2s,4s…
|
||||
cfg.node.retry.max_backoff_secs = 30; // …capped at 30s, not 5 min
|
||||
cfg.node.retry.max_retries = 30;
|
||||
cfg.node.rate_limit.handshake_resend_interval_ms = 400;
|
||||
cfg.node.rate_limit.handshake_resend_backoff = 1.5;
|
||||
cfg.node.rate_limit.handshake_max_resends = 10;
|
||||
cfg.node.discovery.backoff_base_secs = 1; // failed lookup retries fast
|
||||
cfg.node.discovery.backoff_max_secs = 30;
|
||||
cfg.node.discovery.retry_interval_secs = 2;
|
||||
cfg.node.discovery.max_attempts = 3;
|
||||
// Lookups launched before the tree position settles are doomed; a 10s
|
||||
// completion timeout made each one cost 10s before the 1s retry could
|
||||
// fire (observed: 19s to a route on a fresh join). Fail fast instead —
|
||||
// the resend-within-window above still gives each attempt two shots.
|
||||
cfg.node.discovery.timeout_secs = 5;
|
||||
cfg.peers = peers;
|
||||
cfg
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.114-alpha (2026-07-26)
|
||||
|
||||
- Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in.
|
||||
- The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.
|
||||
- Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.
|
||||
- Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type.
|
||||
- Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets.
|
||||
|
||||
## v1.7.113-alpha (2026-07-25)
|
||||
|
||||
- Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet — only the amount you meant to send leaves it.
|
||||
- Closing a Lightning channel is no longer a leap of faith. The close used to hang (or time out with an error) even though it had actually gone through; it now comes back within seconds with the closing transaction ID. Channels mid-close appear in the channel list as Closing or Force-closing with their transaction attached, and a new closed-channels history keeps past closes visible instead of letting them vanish from the list.
|
||||
- The wallet card now leads with your total bitcoin across everything, and the on-chain balance gets its own chain icon so the rows read at a glance.
|
||||
- The companion phone app (0.5.15) connects dramatically faster away from home: a cold connect over 5G dropped from 40+ seconds to about 5. First connects no longer stall on unreachable mesh dial hints, fresh joins fail fast and retry instead of waiting out long timeouts, and the phone re-announces itself the moment the network around it changes. The node side's mesh-join handling was hardened to match.
|
||||
|
||||
## v1.7.112-alpha (2026-07-23)
|
||||
|
||||
- Sound works on TVs out of the box. Fresh installs were missing the audio system entirely, and even when present a boot-time race left HDMI silent until the cable was unplugged and replugged. Both are fixed: installer images now ship the full audio stack, and a small background helper detects the silent-HDMI state and heals it automatically.
|
||||
|
||||
Generated
+1
-1
@@ -95,7 +95,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.112-alpha"
|
||||
version = "1.7.114-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.112-alpha"
|
||||
version = "1.7.114-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -124,10 +124,12 @@ impl RpcHandler {
|
||||
}
|
||||
"lnd.getinfo" => self.handle_lnd_getinfo().await,
|
||||
"lnd.listchannels" => self.handle_lnd_listchannels().await,
|
||||
"lnd.closedchannels" => self.handle_lnd_closedchannels().await,
|
||||
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
|
||||
"lnd.closechannel" => self.handle_lnd_closechannel(params).await,
|
||||
"lnd.newaddress" => self.handle_lnd_newaddress().await,
|
||||
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
|
||||
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
|
||||
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
|
||||
|
||||
@@ -30,6 +30,8 @@ struct ChannelInfo {
|
||||
active: bool,
|
||||
status: String,
|
||||
channel_point: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
closing_txid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -58,6 +60,10 @@ struct LndChannel {
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct LndPendingChannelsResponse {
|
||||
pending_open_channels: Option<Vec<LndPendingOpenChannel>>,
|
||||
// Cooperative closes waiting for their closing tx to confirm
|
||||
waiting_close_channels: Option<Vec<LndWaitingCloseChannel>>,
|
||||
// Force closes serving out their timelock
|
||||
pending_force_closing_channels: Option<Vec<LndForceClosingChannel>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -65,6 +71,18 @@ struct LndPendingOpenChannel {
|
||||
channel: Option<LndPendingChannel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndWaitingCloseChannel {
|
||||
channel: Option<LndPendingChannel>,
|
||||
closing_txid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndForceClosingChannel {
|
||||
channel: Option<LndPendingChannel>,
|
||||
closing_txid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndPendingChannel {
|
||||
remote_node_pub: Option<String>,
|
||||
@@ -74,6 +92,52 @@ struct LndPendingChannel {
|
||||
channel_point: Option<String>,
|
||||
}
|
||||
|
||||
impl LndPendingChannel {
|
||||
fn into_channel_info(self, status: &str, closing_txid: Option<String>) -> ChannelInfo {
|
||||
let parse = |s: &Option<String>| s.as_deref().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
ChannelInfo {
|
||||
chan_id: String::new(),
|
||||
remote_pubkey: self.remote_node_pub.clone().unwrap_or_default(),
|
||||
capacity: parse(&self.capacity),
|
||||
local_balance: parse(&self.local_balance),
|
||||
remote_balance: parse(&self.remote_balance),
|
||||
active: false,
|
||||
status: status.into(),
|
||||
channel_point: self.channel_point.unwrap_or_default(),
|
||||
closing_txid: closing_txid.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct LndClosedChannelsResponse {
|
||||
channels: Option<Vec<LndClosedChannel>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndClosedChannel {
|
||||
chan_id: Option<String>,
|
||||
remote_pubkey: Option<String>,
|
||||
capacity: Option<String>,
|
||||
settled_balance: Option<String>,
|
||||
close_type: Option<String>,
|
||||
closing_tx_hash: Option<String>,
|
||||
channel_point: Option<String>,
|
||||
close_height: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ClosedChannelInfo {
|
||||
chan_id: String,
|
||||
remote_pubkey: String,
|
||||
capacity: i64,
|
||||
settled_balance: i64,
|
||||
close_type: String,
|
||||
closing_tx_hash: String,
|
||||
channel_point: String,
|
||||
close_height: i64,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_lnd_listchannels(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
@@ -131,6 +195,7 @@ impl RpcHandler {
|
||||
"inactive".into()
|
||||
},
|
||||
channel_point: ch.channel_point.unwrap_or_default(),
|
||||
closing_txid: String::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -138,31 +203,20 @@ impl RpcHandler {
|
||||
let mut pending_channels: Vec<ChannelInfo> = Vec::new();
|
||||
for pch in pending_resp.pending_open_channels.unwrap_or_default() {
|
||||
if let Some(ch) = pch.channel {
|
||||
let capacity: i64 = ch
|
||||
.capacity
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let local: i64 = ch
|
||||
.local_balance
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let remote: i64 = ch
|
||||
.remote_balance
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
pending_channels.push(ChannelInfo {
|
||||
chan_id: String::new(),
|
||||
remote_pubkey: ch.remote_node_pub.unwrap_or_default(),
|
||||
capacity,
|
||||
local_balance: local,
|
||||
remote_balance: remote,
|
||||
active: false,
|
||||
status: "pending_open".into(),
|
||||
channel_point: ch.channel_point.unwrap_or_default(),
|
||||
});
|
||||
pending_channels.push(ch.into_channel_info("pending_open", None));
|
||||
}
|
||||
}
|
||||
for wch in pending_resp.waiting_close_channels.unwrap_or_default() {
|
||||
if let Some(ch) = wch.channel {
|
||||
pending_channels.push(ch.into_channel_info("closing", wch.closing_txid));
|
||||
}
|
||||
}
|
||||
for fch in pending_resp
|
||||
.pending_force_closing_channels
|
||||
.unwrap_or_default()
|
||||
{
|
||||
if let Some(ch) = fch.channel {
|
||||
pending_channels.push(ch.into_channel_info("force_closing", fch.closing_txid));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +403,46 @@ impl RpcHandler {
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_closedchannels(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp: LndClosedChannelsResponse = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels/closed"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND closed channels response")?;
|
||||
|
||||
let channels: Vec<ClosedChannelInfo> = resp
|
||||
.channels
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|ch| ClosedChannelInfo {
|
||||
chan_id: ch.chan_id.unwrap_or_default(),
|
||||
remote_pubkey: ch.remote_pubkey.unwrap_or_default(),
|
||||
capacity: ch
|
||||
.capacity
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
settled_balance: ch
|
||||
.settled_balance
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
close_type: ch.close_type.unwrap_or_default(),
|
||||
closing_tx_hash: ch.closing_tx_hash.unwrap_or_default(),
|
||||
channel_point: ch.channel_point.unwrap_or_default(),
|
||||
close_height: ch.close_height.unwrap_or(0),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({ "channels": channels }))
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_closechannel(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -389,27 +483,35 @@ impl RpcHandler {
|
||||
"Closing Lightning channel"
|
||||
);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let (_, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
// The close endpoint is server-streaming: LND holds the connection
|
||||
// open and emits updates until the closing tx CONFIRMS on-chain
|
||||
// (potentially hours). Reading the whole body hangs the RPC even
|
||||
// though the close already went through, and the shared lnd_client's
|
||||
// 15s total timeout would abort the stream mid-read. Use a dedicated
|
||||
// client and return as soon as the first streamed update arrives.
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create streaming HTTP client")?;
|
||||
|
||||
let url = format!(
|
||||
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
|
||||
parts[0], parts[1], force
|
||||
);
|
||||
|
||||
let resp = client
|
||||
let mut resp = client
|
||||
.delete(&url)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to close channel")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse close channel response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -417,6 +519,56 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
// First streamed line is {"result":{"close_pending":…}} on success or
|
||||
// {"error":…} — the stream reports errors in-band after a 200.
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let first_update = tokio::time::timeout(std::time::Duration::from_secs(25), async {
|
||||
while let Some(chunk) = resp.chunk().await? {
|
||||
buf.extend_from_slice(&chunk);
|
||||
let line = match buf.iter().position(|&b| b == b'\n') {
|
||||
Some(pos) => &buf[..pos],
|
||||
None => &buf[..],
|
||||
};
|
||||
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) {
|
||||
return Ok::<_, anyhow::Error>(Some(v));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
})
|
||||
.await;
|
||||
|
||||
match first_update {
|
||||
Ok(Ok(Some(update))) => {
|
||||
if let Some(err) = update.get("error") {
|
||||
let msg = err
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
||||
}
|
||||
// txid arrives base64-encoded in internal byte order; flip it
|
||||
// into the display order explorers use.
|
||||
use base64::Engine as _;
|
||||
let closing_txid = update
|
||||
.pointer("/result/close_pending/txid")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
|
||||
.map(|mut bytes| {
|
||||
bytes.reverse();
|
||||
hex::encode(bytes)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
info!(channel_point, closing_txid, "Channel close initiated");
|
||||
Ok(serde_json::json!({ "success": true, "closing_txid": closing_txid }))
|
||||
}
|
||||
Ok(Ok(None)) => Err(anyhow::anyhow!(
|
||||
"LND ended the close stream without an update — check the channel list"
|
||||
)),
|
||||
Ok(Err(e)) => Err(e).context("Failed reading close channel response"),
|
||||
// No update inside the window: the close is almost certainly still
|
||||
// negotiating with the peer — report initiated, the channel list
|
||||
// will show it under Closing.
|
||||
Err(_) => Ok(serde_json::json!({ "success": true, "closing_txid": "" })),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,16 +124,41 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||
}
|
||||
|
||||
// Fee control: either a confirmation target or an explicit fee rate
|
||||
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
|
||||
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
|
||||
if target_conf.is_some() && sat_per_vbyte.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
|
||||
));
|
||||
}
|
||||
if let Some(tc) = target_conf {
|
||||
if !(1..=1008).contains(&tc) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(rate) = sat_per_vbyte {
|
||||
if !(1..=5000).contains(&rate) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid sat_per_vbyte: must be between 1 and 5000"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
addr = addr,
|
||||
amount = amount,
|
||||
send_all = send_all,
|
||||
target_conf = target_conf,
|
||||
sat_per_vbyte = sat_per_vbyte,
|
||||
"Sending on-chain Bitcoin"
|
||||
);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let send_body = match amount {
|
||||
let mut send_body = match amount {
|
||||
Some(amount) => serde_json::json!({
|
||||
"addr": addr,
|
||||
"amount": amount.to_string(),
|
||||
@@ -143,6 +168,13 @@ impl RpcHandler {
|
||||
"send_all": true,
|
||||
}),
|
||||
};
|
||||
if let Some(tc) = target_conf {
|
||||
send_body["target_conf"] = serde_json::json!(tc);
|
||||
}
|
||||
if let Some(rate) = sat_per_vbyte {
|
||||
// LND REST encodes uint64 as a JSON string
|
||||
send_body["sat_per_vbyte"] = serde_json::json!(rate.to_string());
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/transactions"))
|
||||
@@ -171,6 +203,82 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "txid": txid }))
|
||||
}
|
||||
|
||||
/// Estimate the on-chain fee for sending `amount` sats to `addr` at a
|
||||
/// confirmation target. Returns `{ fee_sat, sat_per_vbyte }` so the send
|
||||
/// UI can show the cost of each preset before the user confirms.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_estimatefee(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let addr = params
|
||||
.get("addr")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
|
||||
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||
}
|
||||
let amount = params
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||
if !(546..=21_000_000 * 100_000_000).contains(&amount) {
|
||||
return Err(anyhow::anyhow!("Invalid amount"));
|
||||
}
|
||||
let target_conf = params
|
||||
.get("target_conf")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(6);
|
||||
if !(1..=1008).contains(&target_conf) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/transactions/fee"))
|
||||
.query(&[
|
||||
(format!("AddrToAmount[{addr}]"), amount.to_string()),
|
||||
("target_conf".to_string(), target_conf.to_string()),
|
||||
])
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to estimate fee")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse fee estimate response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to estimate fee: {}", msg));
|
||||
}
|
||||
|
||||
// LND REST encodes int64 as JSON strings
|
||||
let fee_sat = body
|
||||
.get("fee_sat")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
let sat_per_vbyte = body
|
||||
.get("sat_per_vbyte")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
Ok(serde_json::json!({
|
||||
"fee_sat": fee_sat,
|
||||
"sat_per_vbyte": sat_per_vbyte,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create a Lightning invoice.
|
||||
/// Create a Lightning invoice and return `(bolt11, payment_hash_hex)`.
|
||||
///
|
||||
|
||||
@@ -156,7 +156,24 @@ pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
|
||||
.with_context(|| format!("read {}", path.display()))?;
|
||||
let anchors: Vec<SeedAnchor> =
|
||||
serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
|
||||
Ok(anchors)
|
||||
Ok(repair_legacy_anchor_set(anchors))
|
||||
}
|
||||
|
||||
/// Add the newer redundant public TCP anchors to legacy files that only carry
|
||||
/// the Archipelago-operated vps2 anchor. A deliberately custom/private anchor
|
||||
/// list remains authoritative; this only repairs the exact stale shape shipped
|
||||
/// before the join.fips.network anchors became defaults.
|
||||
fn repair_legacy_anchor_set(mut anchors: Vec<SeedAnchor>) -> Vec<SeedAnchor> {
|
||||
let has_archy = anchors.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB);
|
||||
let has_fips_network = fips_network_anchors()
|
||||
.iter()
|
||||
.any(|default| anchors.iter().any(|a| a.npub == default.npub));
|
||||
if has_archy && !has_fips_network {
|
||||
for anchor in fips_network_anchors() {
|
||||
anchors.push(anchor);
|
||||
}
|
||||
}
|
||||
anchors
|
||||
}
|
||||
|
||||
/// Persist the list. Overwrites atomically via write-then-rename so a
|
||||
@@ -338,6 +355,30 @@ mod tests {
|
||||
assert!(got.iter().all(|a| a.transport == "tcp"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_repairs_legacy_archy_only_anchor_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save(dir.path(), &[archy_anchor()]).await.unwrap();
|
||||
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB));
|
||||
for anchor in fips_network_anchors() {
|
||||
assert!(got.iter().any(|a| a.npub == anchor.npub));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_keeps_private_anchor_file_authoritative() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let private = mk("npub1private");
|
||||
save(dir.path(), std::slice::from_ref(&private))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert_eq!(got, vec![private]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_one_default_persists_and_keeps_the_other() {
|
||||
// Editing the anchor list (here removing one default) makes the file
|
||||
|
||||
@@ -81,6 +81,7 @@ async fn sudo_systemctl(verb: &str, unit: &str) -> Result<()> {
|
||||
/// Unmask + start + enable the FIPS service. Idempotent — safe to call
|
||||
/// on every backend startup once the key is on disk.
|
||||
pub async fn activate(unit: &str) -> Result<()> {
|
||||
kill_stale_daemons().await?;
|
||||
// Order matters: unmask before enable/start, otherwise enable fails
|
||||
// on a masked unit.
|
||||
sudo_systemctl("unmask", unit).await?;
|
||||
@@ -94,9 +95,42 @@ pub async fn stop(unit: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
pub async fn restart(unit: &str) -> Result<()> {
|
||||
kill_stale_daemons().await?;
|
||||
sudo_systemctl("restart", unit).await
|
||||
}
|
||||
|
||||
/// Kill orphaned `fips` processes not owned by either known systemd unit.
|
||||
///
|
||||
/// Field failure, 2026-07-24: a stale daemon survived outside systemd and kept
|
||||
/// `0.0.0.0:8443` bound. The supervised daemon then started UDP-only, so every
|
||||
/// TCP seed-anchor connect failed with "no operational transport" and phones
|
||||
/// on 5G could not discover the node. We keep the cleanup narrow: preserve the
|
||||
/// MainPID of both units and terminate only extra `pgrep -x fips` matches.
|
||||
pub async fn kill_stale_daemons() -> Result<()> {
|
||||
let script = format!(
|
||||
r#"keep="$(systemctl show -p MainPID --value {managed} 2>/dev/null; systemctl show -p MainPID --value {upstream} 2>/dev/null)"
|
||||
for pid in $(pgrep -x fips 2>/dev/null || true); do
|
||||
case " $keep " in
|
||||
*" $pid "*) ;;
|
||||
*) kill "$pid" 2>/dev/null || true ;;
|
||||
esac
|
||||
done
|
||||
"#,
|
||||
managed = super::SERVICE_UNIT,
|
||||
upstream = super::UPSTREAM_SERVICE_UNIT,
|
||||
);
|
||||
let out = Command::new("sudo")
|
||||
.args(["sh", "-c", &script])
|
||||
.output()
|
||||
.await
|
||||
.context("sudo stale fips cleanup failed to launch")?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
anyhow::bail!("stale fips cleanup failed: {}", stderr);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve which systemd unit is actually supervising the fips daemon
|
||||
/// on this host. Nodes installed from the archipelago ISO run
|
||||
/// `archipelago-fips.service`; nodes that were apt-installed (or had
|
||||
|
||||
@@ -87,6 +87,18 @@ const RECONNECT_DELAY_INIT: Duration = Duration::from_secs(5);
|
||||
/// Maximum reconnect delay (cap for exponential backoff).
|
||||
const RECONNECT_DELAY_MAX: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Minimum time a session must run before we trust it enough to reset
|
||||
/// backoff to the minimum. Without this gate, a device that connects then
|
||||
/// fails again within a couple of seconds (e.g. mid-boot-loop) never backs
|
||||
/// off — every retry immediately re-opens the port, which toggles DTR/RTS
|
||||
/// (resets many ESP32 boards' MCU on native-USB and CP2102/CH340
|
||||
/// auto-reset-circuit boards alike), turning a device that's merely
|
||||
/// unstable into a self-sustaining boot loop that outlasts whatever
|
||||
/// triggered the original instability. Confirmed live 2026-07-23: a Heltec
|
||||
/// V3 stuck retrying every ~5-15s for 5+ minutes after a failed firmware
|
||||
/// flash left it in a marginal state.
|
||||
const STABLE_SESSION_THRESHOLD: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Number of consecutive write failures before we consider the device dead
|
||||
/// and trigger a reconnection cycle.
|
||||
const MAX_CONSECUTIVE_WRITE_FAILURES: u32 = 3;
|
||||
@@ -560,6 +572,7 @@ pub fn spawn_mesh_listener(
|
||||
return;
|
||||
}
|
||||
|
||||
let session_start = std::time::Instant::now();
|
||||
match session::run_mesh_session(
|
||||
&state,
|
||||
&data_dir,
|
||||
@@ -582,13 +595,14 @@ pub fn spawn_mesh_listener(
|
||||
{
|
||||
Ok(()) => {
|
||||
info!("Mesh session ended cleanly");
|
||||
// Session was established before ending — reset backoff
|
||||
reconnect_delay = RECONNECT_DELAY_INIT;
|
||||
// Only trust a session that actually ran for a while —
|
||||
// see STABLE_SESSION_THRESHOLD's doc comment.
|
||||
if session_start.elapsed() >= STABLE_SESSION_THRESHOLD {
|
||||
reconnect_delay = RECONNECT_DELAY_INIT;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Check if session was ever connected (vs failed to open)
|
||||
let was_connected = state.status.read().await.device_connected;
|
||||
if was_connected {
|
||||
if session_start.elapsed() >= STABLE_SESSION_THRESHOLD {
|
||||
reconnect_delay = RECONNECT_DELAY_INIT;
|
||||
}
|
||||
error!("Mesh session error: {} (retry in {:?})", e, reconnect_delay);
|
||||
|
||||
@@ -269,10 +269,23 @@ async fn auto_detect_and_open(
|
||||
our_ed_pubkey_hex: &str,
|
||||
our_x25519_pubkey_hex: &str,
|
||||
device_kind: Option<DeviceType>,
|
||||
skip_path: Option<&str>,
|
||||
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||
let paths = super::super::serial::detect_serial_devices().await;
|
||||
let mut paths = super::super::serial::detect_serial_devices().await;
|
||||
// When falling back from a just-failed preferred path, don't probe that
|
||||
// same device again in the same cycle — every open() toggles DTR/RTS,
|
||||
// which resets ESP32-family boards, and back-to-back re-probes are what
|
||||
// keeps a mid-boot board from ever finishing its boot.
|
||||
if let Some(skip) = skip_path {
|
||||
let canon = |p: &str| std::fs::canonicalize(p).unwrap_or_else(|_| p.into());
|
||||
let skip_canon = canon(skip);
|
||||
paths.retain(|p| canon(p) != skip_canon);
|
||||
}
|
||||
if paths.is_empty() {
|
||||
anyhow::bail!("No serial devices found in /dev");
|
||||
anyhow::bail!(match skip_path {
|
||||
Some(skip) => format!("No serial devices found in /dev besides {skip}, which was already probed this cycle"),
|
||||
None => "No serial devices found in /dev".to_string(),
|
||||
});
|
||||
}
|
||||
for path in &paths {
|
||||
debug!(path = %path, "Probing for mesh radio device");
|
||||
@@ -359,6 +372,16 @@ pub struct DeviceProbe {
|
||||
pub max_contacts: Option<u16>,
|
||||
}
|
||||
|
||||
/// Serializes serial-port open sequences between the listener's session
|
||||
/// opens and the RPC probe (`mesh.probe-device`). Linux happily double-opens
|
||||
/// a tty, and two concurrent handshakes corrupt each other into silence —
|
||||
/// observed live on .116 (2026-07-26): the kiosk browser's hot-swap
|
||||
/// auto-probe collided with the listener's cycle on every backoff window, so
|
||||
/// neither ever succeeded, and each collision's open() DTR/RTS-reset the
|
||||
/// board again. The probe's retry-across-idle-gaps heuristic (5f01ec31)
|
||||
/// narrowed but could not close the race; this closes it.
|
||||
static PORT_OPEN_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// Probe a serial port for a mesh radio WITHOUT provisioning it: identify the
|
||||
/// firmware (same strict Reticulum→Meshcore→Meshtastic order as auto-detect,
|
||||
/// for the same RNode-wedging reason) and read what's currently configured on
|
||||
@@ -366,11 +389,10 @@ pub struct DeviceProbe {
|
||||
/// loop can pick the device up afterwards. Reticulum uses the bare KISS
|
||||
/// DETECT probe — no daemon spawn just to identify a stick.
|
||||
pub(crate) async fn probe_device(path: &str) -> Result<DeviceProbe> {
|
||||
// The listener's reconnect loop may hold this port for ~10s of every
|
||||
// backoff cycle, and Linux happily double-opens a tty — two concurrent
|
||||
// handshakes corrupt each other into silence (observed on framework-pt:
|
||||
// every firmware "failed" while the listener was mid-cycle). Retry across
|
||||
// the listener's idle gaps instead of failing on first collision.
|
||||
// Retries kept even with PORT_OPEN_LOCK closing the double-open race:
|
||||
// the board may still be mid-boot from a previous open's DTR/RTS reset,
|
||||
// and a later attempt after a quiet gap can succeed where the first
|
||||
// couldn't.
|
||||
let mut last_err = None;
|
||||
for attempt in 0..3u32 {
|
||||
if attempt > 0 {
|
||||
@@ -385,6 +407,7 @@ pub(crate) async fn probe_device(path: &str) -> Result<DeviceProbe> {
|
||||
}
|
||||
|
||||
async fn probe_device_once(path: &str) -> Result<DeviceProbe> {
|
||||
let _port_guard = PORT_OPEN_LOCK.lock().await;
|
||||
if super::super::reticulum::probe_rnode(path).await.is_ok() {
|
||||
return Ok(DeviceProbe {
|
||||
path: path.to_string(),
|
||||
@@ -963,6 +986,11 @@ pub(super) async fn run_mesh_session(
|
||||
// set, otherwise try the preferred serial path, falling back to
|
||||
// auto-detect. TCP mode is additive/dev-only; it never changes behavior
|
||||
// for existing serial/RNode deployments where `reticulum_tcp` is None.
|
||||
//
|
||||
// The whole open sequence runs under PORT_OPEN_LOCK so an RPC probe
|
||||
// can't interleave its own handshakes on the same tty (see the lock's
|
||||
// doc comment). Held only until the device is opened, then released.
|
||||
let port_guard = PORT_OPEN_LOCK.lock().await;
|
||||
let (device_path, mut device, device_info) = if let Some(tcp_cfg) = &reticulum_tcp {
|
||||
open_reticulum_tcp(tcp_cfg, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await?
|
||||
} else if let Some(path) = preferred_path {
|
||||
@@ -986,6 +1014,7 @@ pub(super) async fn run_mesh_session(
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
Some(path),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
@@ -996,9 +1025,11 @@ pub(super) async fn run_mesh_session(
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
drop(port_guard);
|
||||
|
||||
// Update status
|
||||
{
|
||||
|
||||
@@ -217,11 +217,20 @@ impl MeshtasticDevice {
|
||||
path
|
||||
))?;
|
||||
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
|
||||
// boards reset on a DTR/RTS transition, so deassert both and settle
|
||||
// before the handshake below.
|
||||
// boards (and CP2102/CH340-bridged boards wired for Arduino-style
|
||||
// auto-reset) reset on a DTR/RTS transition, so deassert both and
|
||||
// settle before the handshake below. 300ms is nowhere near a real
|
||||
// firmware boot time (LoRa radio init alone can take longer) —
|
||||
// confirmed live 2026-07-23: with every one of Reticulum/Meshcore/
|
||||
// Meshtastic's open() doing this same reset, a single auto-detect
|
||||
// cycle trying multiple protocols in sequence kept re-resetting the
|
||||
// board before it ever finished booting from the PREVIOUS attempt's
|
||||
// reset, on both a Heltec V3 and V4, regardless of firmware family —
|
||||
// a self-sustaining "never finishes booting" loop with a boot-time
|
||||
// root cause hiding behind what looked like a per-protocol failure.
|
||||
let _ = port.set_dtr(false);
|
||||
let _ = port.set_rts(false);
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
tokio::time::sleep(Duration::from_millis(2000)).await;
|
||||
info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port");
|
||||
|
||||
Ok(Self {
|
||||
|
||||
@@ -38,6 +38,14 @@ use tokio::sync::watch;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
const MESH_CONFIG_FILE: &str = "mesh-config.json";
|
||||
|
||||
/// How long `MeshService::stop()` waits for the listener task to notice its
|
||||
/// shutdown signal and exit gracefully before force-aborting it. See
|
||||
/// `stop()`'s doc comment for the real incident this guards against: without
|
||||
/// a hard abort fallback, a slow-to-notice listener could be left running
|
||||
/// forever, orphaned, racing a later independently-started listener on the
|
||||
/// same serial port.
|
||||
const LISTENER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const MESH_IGNORED_RADIO_FILE: &str = "mesh-ignored-radio-contacts.json";
|
||||
const MESH_CONTACTS_FILE: &str = "mesh-contacts.json";
|
||||
|
||||
@@ -967,8 +975,36 @@ impl MeshService {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(true);
|
||||
}
|
||||
if let Some(handle) = self.listener_handle.take() {
|
||||
let _ = handle.await;
|
||||
if let Some(mut handle) = self.listener_handle.take() {
|
||||
// Bounded wait for graceful shutdown, with a hard abort as
|
||||
// fallback — confirmed live 2026-07-23: a caller-side timeout
|
||||
// wrapping stop() (mesh::flash's STOP_LISTENER_TIMEOUT) cancelled
|
||||
// this await when the listener was slow to notice its shutdown
|
||||
// signal (mid multi-candidate probe), but `.take()` above had
|
||||
// already cleared `listener_handle` to None — so MeshService
|
||||
// believed it was stopped while the task kept running, orphaned
|
||||
// (dropping a JoinHandle does not abort the task it points to).
|
||||
// A later start() then spawned a second, fully independent
|
||||
// listener session racing the orphaned one on the same serial
|
||||
// port — neither could ever get a clean response, so every
|
||||
// mesh.configure/probe against that device failed indefinitely
|
||||
// even though the device itself was fine.
|
||||
//
|
||||
// Awaiting `&mut handle` (not `handle` by value) is what makes
|
||||
// the fallback possible: the Future is polled through the
|
||||
// reference, so if the timeout fires, this task's own `handle`
|
||||
// binding is still ours to call `.abort()` on afterward —
|
||||
// unlike moving `handle` into the timeout future outright, which
|
||||
// would drop (and thus orphan) it on timeout with nothing left
|
||||
// to abort.
|
||||
if tokio::time::timeout(LISTENER_SHUTDOWN_TIMEOUT, &mut handle)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!("Mesh listener did not shut down gracefully in time — aborting it");
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
if let Some(handle) = self.deadman_handle.take() {
|
||||
handle.abort();
|
||||
@@ -1027,8 +1063,18 @@ impl MeshService {
|
||||
/// with the reconnect loop (whichever loses just retries).
|
||||
pub async fn probe_device(&self, path: &str) -> Result<listener::DeviceProbe> {
|
||||
let status = self.state.status.read().await;
|
||||
if status.device_connected && status.device_path.as_deref() == Some(path) {
|
||||
anyhow::bail!("{path} is the active mesh radio — already connected");
|
||||
if status.device_connected {
|
||||
if let Some(active) = status.device_path.as_deref() {
|
||||
// Compare canonical paths: /dev/mesh-radio is a symlink to the
|
||||
// ttyUSB*/ttyACM* node, and a probe through the alias would
|
||||
// still open the very tty the live session is holding.
|
||||
let canon = |p: &str| {
|
||||
std::fs::canonicalize(p).unwrap_or_else(|_| std::path::PathBuf::from(p))
|
||||
};
|
||||
if canon(active) == canon(path) {
|
||||
anyhow::bail!("{path} is the active mesh radio — already connected");
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(status);
|
||||
listener::probe_device(path).await
|
||||
|
||||
@@ -58,11 +58,20 @@ impl MeshcoreDevice {
|
||||
path
|
||||
))?;
|
||||
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
|
||||
// boards reset on a DTR/RTS transition, so deassert both and settle
|
||||
// before the handshake below.
|
||||
// boards (and CP2102/CH340-bridged boards wired for Arduino-style
|
||||
// auto-reset) reset on a DTR/RTS transition, so deassert both and
|
||||
// settle before the handshake below. 300ms is nowhere near a real
|
||||
// firmware boot time (LoRa radio init alone can take longer) —
|
||||
// confirmed live 2026-07-23: with every one of Reticulum/Meshcore/
|
||||
// Meshtastic's open() doing this same reset, a single auto-detect
|
||||
// cycle trying multiple protocols in sequence kept re-resetting the
|
||||
// board before it ever finished booting from the PREVIOUS attempt's
|
||||
// reset, on both a Heltec V3 and V4, regardless of firmware family —
|
||||
// a self-sustaining "never finishes booting" loop with a boot-time
|
||||
// root cause hiding behind what looked like a per-protocol failure.
|
||||
let _ = port.set_dtr(false);
|
||||
let _ = port.set_rts(false);
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
tokio::time::sleep(Duration::from_millis(2000)).await;
|
||||
|
||||
info!(path = %path, baud = BAUD_RATE, "Opened serial port");
|
||||
|
||||
@@ -547,14 +556,30 @@ fn likely_non_mesh_serial_device(path: &str) -> bool {
|
||||
|
||||
/// Scan for serial devices that could be Meshcore radios.
|
||||
/// Returns paths to existing serial device files.
|
||||
///
|
||||
/// Candidates are deduplicated by canonical path: /dev/mesh-radio is a udev
|
||||
/// symlink to a ttyUSB*/ttyACM* node that is ALSO in the candidate list, so
|
||||
/// without this one physical board shows up (and gets probed, and gets its
|
||||
/// DTR/RTS reset toggled) twice per cycle. The first candidate wins, which
|
||||
/// keeps the stable /dev/mesh-radio name when the symlink exists.
|
||||
pub async fn detect_serial_devices() -> Vec<String> {
|
||||
let mut devices = Vec::new();
|
||||
let mut seen_canonical: Vec<std::path::PathBuf> = Vec::new();
|
||||
for path in SERIAL_CANDIDATES {
|
||||
if tokio::fs::metadata(path).await.is_ok() {
|
||||
if likely_non_mesh_serial_device(path) {
|
||||
debug!(path = %path, "Skipping known non-mesh serial device");
|
||||
continue;
|
||||
}
|
||||
let canonical = tokio::fs::canonicalize(path)
|
||||
.await
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from(path));
|
||||
if seen_canonical.contains(&canonical) {
|
||||
debug!(path = %path, canonical = %canonical.display(),
|
||||
"Skipping alias of an already-detected serial device");
|
||||
continue;
|
||||
}
|
||||
seen_canonical.push(canonical);
|
||||
devices.push(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,11 +632,30 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
|
||||
// Mark original proofs as spent
|
||||
wallet.mark_spent(&indices);
|
||||
|
||||
// Separate send proofs from change proofs
|
||||
let (send, change): (Vec<_>, Vec<_>) = swap_result
|
||||
.new_proofs
|
||||
.into_iter()
|
||||
.partition(|p| send_denoms.contains(&p.amount));
|
||||
// Separate send proofs from change proofs BY COUNT, not membership:
|
||||
// partition(contains) put EVERY proof whose denomination appeared in
|
||||
// send_denoms into the token — when change shared a denomination with
|
||||
// the send (worst case: spending from a proof worth exactly 2× the
|
||||
// amount, send [n] + change [n]), the change proofs rode along and the
|
||||
// receiver was credited double. Consume exactly one proof per needed
|
||||
// send denomination; everything else is change.
|
||||
let mut send_needed = send_denoms.clone();
|
||||
let mut send: Vec<Proof> = Vec::new();
|
||||
let mut change: Vec<Proof> = Vec::new();
|
||||
for p in swap_result.new_proofs {
|
||||
if let Some(pos) = send_needed.iter().position(|&d| d == p.amount) {
|
||||
send_needed.swap_remove(pos);
|
||||
send.push(p);
|
||||
} else {
|
||||
change.push(p);
|
||||
}
|
||||
}
|
||||
if !send_needed.is_empty() {
|
||||
anyhow::bail!(
|
||||
"Mint swap returned incomplete send denominations (missing {:?})",
|
||||
send_needed
|
||||
);
|
||||
}
|
||||
|
||||
// Add change proofs back to wallet
|
||||
if !change.is_empty() {
|
||||
|
||||
@@ -162,7 +162,8 @@ All endpoints use JSON-RPC over HTTP POST to `/rpc/v1`.
|
||||
| `lnd.openchannel` | `{ pubkey: string, amount: number }` | `{ funding_txid: string }` | Yes |
|
||||
| `lnd.closechannel` | `{ channel_point: string }` | `{ closing_txid: string }` | Yes |
|
||||
| `lnd.newaddress` | — | `{ address: string }` | Yes |
|
||||
| `lnd.sendcoins` | `{ addr: string, amount: number }` | `{ txid: string }` | Yes |
|
||||
| `lnd.sendcoins` | `{ addr: string, amount?: number, send_all?: bool, target_conf?: number, sat_per_vbyte?: number }` | `{ txid: string }` | Yes |
|
||||
| `lnd.estimatefee` | `{ addr: string, amount: number, target_conf?: number }` | `{ fee_sat: number, sat_per_vbyte: number }` | Yes |
|
||||
| `lnd.createinvoice` | `{ amount: number, memo?: string }` | `{ payment_request: string }` | Yes |
|
||||
| `lnd.payinvoice` | `{ payment_request: string }` | `{ preimage: string }` | Yes |
|
||||
| `lnd.create-psbt` | `{ outputs: object, ... }` | `{ psbt: string }` | Yes |
|
||||
|
||||
@@ -83,7 +83,7 @@ QEMU_ARGS=(
|
||||
|
||||
# Display mode
|
||||
if [ "$NOGRAPHIC" = true ]; then
|
||||
QEMU_ARGS+=(-nographic -append "console=ttyS0")
|
||||
QEMU_ARGS+=(-display none)
|
||||
else
|
||||
QEMU_ARGS+=(-vga virtio -display default)
|
||||
fi
|
||||
|
||||
@@ -3453,12 +3453,26 @@ app.post('/rpc/v1', (req, res) => {
|
||||
{ chan_id: '840921088114689', remote_pubkey: '03abcdef12345678901234567890123456789012345678901234567890abcdef12', capacity: 2000000, local_balance: 1200000, remote_balance: 800000, active: true, status: 'active', channel_point: randomHex(32) + ':1', peer_alias: 'WalletOfSatoshi' },
|
||||
{ chan_id: '840921088114690', remote_pubkey: '02fedcba98765432109876543210987654321098765432109876543210fedcba98', capacity: 10000000, local_balance: 4500000, remote_balance: 5500000, active: true, status: 'active', channel_point: randomHex(32) + ':0', peer_alias: 'Voltage' },
|
||||
{ chan_id: '840921088114691', remote_pubkey: '03456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123', capacity: 3000000, local_balance: 100000, remote_balance: 2900000, active: false, status: 'inactive', channel_point: randomHex(32) + ':0', peer_alias: 'Kraken' },
|
||||
{ chan_id: '', remote_pubkey: '028d98b9969fbed53784a36617eb489a59ab6dc9b9d77fcdca9ff55307cd98e3c4', capacity: 500000, local_balance: 500000, remote_balance: 0, active: false, status: 'pending_open', channel_point: randomHex(32) + ':0', peer_alias: 'ACINQ' },
|
||||
{ chan_id: '', remote_pubkey: '02c16cca44562b590dd279c942200bdccfd4f990c3a69fad620c10ef2f8228eaff', capacity: 800000, local_balance: 350000, remote_balance: 450000, active: false, status: 'closing', channel_point: randomHex(32) + ':0', closing_txid: randomHex(32), peer_alias: 'Bitrefill' },
|
||||
]
|
||||
return res.json({
|
||||
result: {
|
||||
channels,
|
||||
total_outbound: channels.reduce((s, c) => s + c.local_balance, 0),
|
||||
total_inbound: channels.reduce((s, c) => s + c.remote_balance, 0),
|
||||
// Totals sum open channels only, matching the real backend.
|
||||
total_outbound: channels.filter(c => ['active', 'inactive'].includes(c.status)).reduce((s, c) => s + c.local_balance, 0),
|
||||
total_inbound: channels.filter(c => ['active', 'inactive'].includes(c.status)).reduce((s, c) => s + c.remote_balance, 0),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
case 'lnd.closedchannels': {
|
||||
return res.json({
|
||||
result: {
|
||||
channels: [
|
||||
{ chan_id: '840921088110001', remote_pubkey: '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f', capacity: 1000000, settled_balance: 612000, close_type: 'COOPERATIVE_CLOSE', closing_tx_hash: randomHex(32), channel_point: randomHex(32) + ':0', close_height: 903112 },
|
||||
{ chan_id: '840921088110002', remote_pubkey: '0298f6074a454a1f5345cb2a7c6f9fce206cd0bf675d177cdbf0ca7508dd28852f', capacity: 250000, settled_balance: 0, close_type: 'REMOTE_FORCE_CLOSE', closing_tx_hash: randomHex(32), channel_point: randomHex(32) + ':1', close_height: 897540 },
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -3518,6 +3532,13 @@ app.post('/rpc/v1', (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
case 'lnd.estimatefee': {
|
||||
// ~141 vB P2WPKH spend at a rate that scales with urgency
|
||||
const target = params?.target_conf || 6
|
||||
const rate = target <= 1 ? 22 : target <= 6 ? 8 : 2
|
||||
return res.json({ result: { fee_sat: rate * 141, sat_per_vbyte: rate } })
|
||||
}
|
||||
|
||||
case 'lnd.decodepayreq': {
|
||||
return res.json({
|
||||
result: {
|
||||
|
||||
Generated
+37
-2
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.112-alpha",
|
||||
"version": "1.7.114-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.112-alpha",
|
||||
"version": "1.7.114-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
"buffer": "^6.0.3",
|
||||
@@ -2882,6 +2883,18 @@
|
||||
"url": "https://opencollective.com/js-sdsl"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -3538,6 +3551,28 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz",
|
||||
"integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@scure/base": "2.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
|
||||
"version": "3.0.0-pre1",
|
||||
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.112-alpha",
|
||||
"version": "1.7.114-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
@@ -24,6 +24,7 @@
|
||||
"generate-welcome-speech": "node scripts/generate-welcome-speech.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
"buffer": "^6.0.3",
|
||||
|
||||
Binary file not shown.
@@ -150,7 +150,7 @@ const STORAGE_KEY = 'neode_companion_intro_seen'
|
||||
// exposes the release-server address.
|
||||
const DEFAULT_DOWNLOAD_URL = IS_DEMO
|
||||
? `${window.location.origin}/packages/archipelago-companion.apk`
|
||||
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk'
|
||||
: 'http://146.59.87.168:2100/packages/archipelago-companion.apk'
|
||||
|
||||
// Deep-link scheme the companion app registers; carries the server entry the
|
||||
// app should create (see docs/companion-pairing-qr.md for the contract).
|
||||
@@ -267,12 +267,20 @@ function isTailnetIp(host: string): boolean {
|
||||
* - a tailnet 100.x address (operator browsing over Tailscale — a scanned
|
||||
* QR carried one of these on 2026-07-22 and the companion sat there
|
||||
* dialing an IP the phone had no route to)
|
||||
* - a .fips name or mesh ULA (operator browsing over the mesh — a scanned
|
||||
* QR carried npub….fips as fhost on 2026-07-24; Android's system DNS
|
||||
* can't resolve .fips, so the phone's direct dial died and first
|
||||
* connect crawled through anchor discovery instead of the LAN)
|
||||
*/
|
||||
async function resolveServerUrl(): Promise<string> {
|
||||
if (IS_DEMO) return DEMO_SERVER_URL
|
||||
const { hostname, origin } = window.location
|
||||
const phoneUnreachable =
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' || isTailnetIp(hostname)
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
isTailnetIp(hostname) ||
|
||||
hostname.endsWith('.fips') ||
|
||||
hostname.includes(':') // IPv6 literal — the node's mesh ULA
|
||||
if (!phoneUnreachable) return origin
|
||||
try {
|
||||
const res = await rpcClient.call<{ mdns_hostname?: string; lan_ip?: string | null }>({
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
</div>
|
||||
|
||||
<!-- No Channels -->
|
||||
<div v-else-if="channels.length === 0" key="empty" class="glass-card p-8 text-center">
|
||||
<div v-else-if="channels.length === 0 && closedChannels.length === 0" key="empty" class="glass-card p-8 text-center">
|
||||
<svg class="w-16 h-16 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
@@ -85,6 +85,23 @@
|
||||
|
||||
<!-- Channel List -->
|
||||
<div v-else key="channels" class="space-y-3">
|
||||
<!-- Status tabs -->
|
||||
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors flex items-center justify-center gap-1.5"
|
||||
:class="activeTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span
|
||||
class="px-1.5 py-0.5 rounded-full text-[10px] leading-none"
|
||||
:class="activeTab === tab.key ? 'bg-white/15 text-white/80' : 'bg-white/10 text-white/40'"
|
||||
>{{ tab.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
@@ -96,7 +113,7 @@
|
||||
{{ error }}
|
||||
</div>
|
||||
<div
|
||||
v-for="ch in channels"
|
||||
v-for="ch in filteredChannels"
|
||||
:key="ch.chan_id || ch.channel_point"
|
||||
class="glass-card p-4"
|
||||
:class="{ 'bg-white/5': compact }"
|
||||
@@ -109,12 +126,14 @@
|
||||
'bg-green-400': channelStatus(ch) === 'active',
|
||||
'bg-yellow-400': channelStatus(ch) === 'pending_open',
|
||||
'bg-red-400': channelStatus(ch) === 'inactive',
|
||||
'bg-gray-400': channelStatus(ch) === 'closing',
|
||||
'bg-gray-500': channelStatus(ch) === 'force_closing',
|
||||
}"
|
||||
></span>
|
||||
<span class="text-white/80 text-sm font-medium capitalize">{{ channelStatus(ch).replace('_', ' ') }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="channelStatus(ch) !== 'pending_open'"
|
||||
v-if="!['pending_open', 'closing', 'force_closing'].includes(channelStatus(ch))"
|
||||
@click="confirmClose(ch)"
|
||||
class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
|
||||
>
|
||||
@@ -148,9 +167,10 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Funding tx -->
|
||||
<div v-if="fundingTxid(ch)" class="flex justify-end">
|
||||
<!-- Funding / closing tx -->
|
||||
<div v-if="fundingTxid(ch) || ch.closing_txid" class="flex justify-end gap-3">
|
||||
<button
|
||||
v-if="fundingTxid(ch)"
|
||||
@click="openInMempool(fundingTxid(ch))"
|
||||
class="flex items-center gap-1 text-blue-400/70 hover:text-blue-400 text-xs transition-colors"
|
||||
:title="fundingTxid(ch)"
|
||||
@@ -160,8 +180,66 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="ch.closing_txid"
|
||||
@click="openInMempool(ch.closing_txid!)"
|
||||
class="flex items-center gap-1 text-orange-400/70 hover:text-orange-400 text-xs transition-colors"
|
||||
:title="ch.closing_txid"
|
||||
>
|
||||
Closing tx in Mempool
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Closed channel history (All + Closed tabs) -->
|
||||
<div
|
||||
v-for="ch in filteredClosed"
|
||||
:key="'closed-' + (ch.chan_id || ch.channel_point || ch.closing_tx_hash)"
|
||||
class="glass-card p-4 opacity-75"
|
||||
:class="{ 'bg-white/5': compact }"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-white/30"></span>
|
||||
<span class="text-white/60 text-sm font-medium">Closed</span>
|
||||
<span v-if="closeTypeLabel(ch)" class="text-white/40 text-xs">· {{ closeTypeLabel(ch) }}</span>
|
||||
</div>
|
||||
<span v-if="ch.close_height" class="text-white/35 text-xs">Block {{ ch.close_height.toLocaleString() }}</span>
|
||||
</div>
|
||||
|
||||
<p class="text-white/40 text-xs font-mono mb-3 truncate" :title="ch.remote_pubkey">
|
||||
{{ ch.remote_pubkey }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-between text-xs text-white/50 mb-2">
|
||||
<span>Settled: {{ formatSats(ch.settled_balance) }}</span>
|
||||
<span>Capacity: {{ formatSats(ch.capacity) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ch.closing_tx_hash" class="flex justify-end">
|
||||
<button
|
||||
@click="openInMempool(ch.closing_tx_hash)"
|
||||
class="flex items-center gap-1 text-blue-400/70 hover:text-blue-400 text-xs transition-colors"
|
||||
:title="ch.closing_tx_hash"
|
||||
>
|
||||
Closing tx in Mempool
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-tab empty state -->
|
||||
<div
|
||||
v-if="filteredChannels.length === 0 && filteredClosed.length === 0"
|
||||
class="glass-card p-6 text-center"
|
||||
>
|
||||
<p class="text-white/50 text-sm">{{ emptyTabMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -299,7 +377,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useTxExplorer } from '@/composables/useTxExplorer'
|
||||
|
||||
@@ -314,6 +392,18 @@ interface Channel {
|
||||
active: boolean
|
||||
status?: string
|
||||
channel_point?: string
|
||||
closing_txid?: string
|
||||
}
|
||||
|
||||
interface ClosedChannel {
|
||||
chan_id?: string
|
||||
remote_pubkey: string
|
||||
capacity: number
|
||||
settled_balance: number
|
||||
close_type?: string
|
||||
closing_tx_hash?: string
|
||||
channel_point?: string
|
||||
close_height?: number
|
||||
}
|
||||
|
||||
/** Status with a fallback derived from `active` for backends that omit it */
|
||||
@@ -321,6 +411,48 @@ function channelStatus(ch: Channel): string {
|
||||
return ch.status ?? (ch.active ? 'active' : 'inactive')
|
||||
}
|
||||
|
||||
type ChannelTab = 'all' | 'active' | 'pending' | 'closed'
|
||||
const activeTab = ref<ChannelTab>('all')
|
||||
|
||||
/** pending_open, closing and force_closing all live on the Pending tab */
|
||||
function isPendingState(ch: Channel): boolean {
|
||||
return ['pending_open', 'closing', 'force_closing'].includes(channelStatus(ch))
|
||||
}
|
||||
|
||||
const tabs = computed((): { key: ChannelTab; label: string; count: number }[] => [
|
||||
{ key: 'all', label: 'All', count: channels.value.length + closedChannels.value.length },
|
||||
{ key: 'active', label: 'Active', count: channels.value.filter(ch => !isPendingState(ch)).length },
|
||||
{ key: 'pending', label: 'Pending', count: channels.value.filter(isPendingState).length },
|
||||
{ key: 'closed', label: 'Closed', count: closedChannels.value.length },
|
||||
])
|
||||
|
||||
const filteredChannels = computed((): Channel[] => {
|
||||
switch (activeTab.value) {
|
||||
case 'closed': return []
|
||||
case 'active': return channels.value.filter(ch => !isPendingState(ch))
|
||||
case 'pending': return channels.value.filter(isPendingState)
|
||||
default: return channels.value
|
||||
}
|
||||
})
|
||||
|
||||
const filteredClosed = computed((): ClosedChannel[] =>
|
||||
activeTab.value === 'all' || activeTab.value === 'closed' ? closedChannels.value : []
|
||||
)
|
||||
|
||||
const emptyTabMessage = computed((): string => {
|
||||
switch (activeTab.value) {
|
||||
case 'active': return 'No open channels.'
|
||||
case 'pending': return 'No pending or closing channels.'
|
||||
case 'closed': return 'No closed channels yet.'
|
||||
default: return 'No channels yet.'
|
||||
}
|
||||
})
|
||||
|
||||
/** "COOPERATIVE_CLOSE" / "cooperative_close" → "cooperative close" */
|
||||
function closeTypeLabel(ch: ClosedChannel): string {
|
||||
return (ch.close_type || '').toLowerCase().replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
|
||||
|
||||
const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [
|
||||
@@ -333,6 +465,7 @@ const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: n
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const channels = ref<Channel[]>([])
|
||||
const closedChannels = ref<ClosedChannel[]>([])
|
||||
const summary = ref({ total_inbound: 0, total_outbound: 0 })
|
||||
|
||||
// Olympus by ZEUS — the LSP node behind the Zeus mobile wallet.
|
||||
@@ -419,6 +552,17 @@ async function loadChannels() {
|
||||
total_inbound: result.total_inbound || 0,
|
||||
total_outbound: result.total_outbound || 0,
|
||||
}
|
||||
// Closed history is a separate RPC — a failure here keeps the previous
|
||||
// list rather than blanking the main channel view.
|
||||
try {
|
||||
const closed = await rpcClient.call<{ channels: ClosedChannel[] }>({
|
||||
method: 'lnd.closedchannels',
|
||||
timeout: 15000,
|
||||
})
|
||||
closedChannels.value = closed.channels || []
|
||||
} catch {
|
||||
/* keep previous closed list */
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load channels'
|
||||
if (!hadChannels) channels.value = []
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex gap-1 mb-3 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="tab in ([{ key: 'words', label: 'Words' }, { key: 'qr', label: 'QR code' }] as const)"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
@click="seedTab = tab.key"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||
:class="seedTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
|
||||
<template v-if="seedTab === 'words'">
|
||||
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ hidden ? 'reveal' : 'hide' }}.</p>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
|
||||
:class="hidden ? 'blur-md' : ''"
|
||||
@click="hidden = !hidden"
|
||||
>
|
||||
<div v-for="(w, i) in words" :key="i" class="flex items-center gap-1.5 text-sm">
|
||||
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
|
||||
<span class="text-white font-mono">{{ w }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="hidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="hidden = false">Tap to reveal</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-sm text-white/60 mb-3">
|
||||
{{ aezeed ? 'Scan to copy the words into another device.' : 'Scan into a wallet that imports seeds by QR.' }}
|
||||
Tap to {{ hidden ? 'reveal' : 'hide' }}.
|
||||
</p>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex justify-center p-3 bg-white/5 rounded-lg transition-all"
|
||||
:class="hidden ? 'blur-md' : ''"
|
||||
@click="hidden = !hidden"
|
||||
>
|
||||
<canvas ref="qrCanvas" class="rounded-lg bg-white p-2"></canvas>
|
||||
</div>
|
||||
<button v-if="hidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="hidden = false">Tap to reveal</button>
|
||||
</div>
|
||||
<div v-if="!aezeed && seedQrAvailable" class="flex justify-center mt-2">
|
||||
<div class="flex p-0.5 bg-white/5 rounded-md">
|
||||
<button
|
||||
v-for="f in ([{ key: 'seedqr', label: 'SeedQR' }, { key: 'text', label: 'Plain text' }] as const)"
|
||||
:key="f.key"
|
||||
type="button"
|
||||
@click="qrFormat = f.key"
|
||||
class="px-2.5 py-1 rounded text-[11px] font-medium transition-colors"
|
||||
:class="qrFormat === f.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-2">
|
||||
<template v-if="aezeed">
|
||||
The code contains your seed words as plain text — treat it exactly like the words
|
||||
themselves. Note: this is an LND <span class="font-mono">aezeed</span>, not a BIP39
|
||||
phrase — it restores into LND-based wallets (Zeus, Blixt, another Archipelago node),
|
||||
not into hardware wallets like Passport.
|
||||
</template>
|
||||
<template v-else-if="qrFormat === 'seedqr'">
|
||||
SeedQR — scans into Passport, SeedSigner, Keystone and other wallets that import
|
||||
seeds by QR. Treat this code exactly like the words themselves.
|
||||
</template>
|
||||
<template v-else>
|
||||
Plain text words — for wallets that read the phrase as text. Treat this code exactly
|
||||
like the words themselves.
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
|
||||
// Shared seed reveal body: Words / QR code tabs behind a tap-to-reveal blur.
|
||||
// Words are always the first view. For BIP39 seeds the QR defaults to the
|
||||
// SeedQR standard (4-digit wordlist indices — what Passport Prime, SeedSigner,
|
||||
// Keystone etc. import), with a plain-text option. `aezeed` seeds (LND) are
|
||||
// NOT BIP39 and no hardware wallet can import them, so they only ever get the
|
||||
// plain-text QR plus an explanation — SeedQR-encoding one would be dishonest.
|
||||
const props = defineProps<{ words: string[]; aezeed?: boolean }>()
|
||||
|
||||
const seedTab = ref<'words' | 'qr'>('words')
|
||||
const qrFormat = ref<'seedqr' | 'text'>(props.aezeed ? 'text' : 'seedqr')
|
||||
const seedQrAvailable = ref(!props.aezeed)
|
||||
const hidden = ref(true)
|
||||
const qrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
async function renderQr() {
|
||||
await nextTick()
|
||||
if (!qrCanvas.value || props.words.length === 0) return
|
||||
try {
|
||||
let payload = props.words.join(' ')
|
||||
if (!props.aezeed && qrFormat.value === 'seedqr') {
|
||||
const { toSeedQrDigits } = await import('@/utils/seedqr')
|
||||
const digits = await toSeedQrDigits(props.words)
|
||||
if (digits) {
|
||||
payload = digits
|
||||
} else {
|
||||
// Not a BIP39 phrase after all — only plain text is honest.
|
||||
seedQrAvailable.value = false
|
||||
qrFormat.value = 'text'
|
||||
return // the qrFormat watcher re-renders as text
|
||||
}
|
||||
}
|
||||
const QRCode = await import('qrcode')
|
||||
await QRCode.toCanvas(qrCanvas.value, payload, { width: 260, margin: 1 })
|
||||
} catch { /* QR is a convenience — the words remain authoritative */ }
|
||||
}
|
||||
watch(seedTab, (t) => { if (t === 'qr') void renderQr() })
|
||||
watch(qrFormat, () => { if (seedTab.value === 'qr') void renderQr() })
|
||||
watch(() => props.words, () => {
|
||||
seedTab.value = 'words' // fresh reveal always shows words first
|
||||
hidden.value = true
|
||||
seedQrAvailable.value = !props.aezeed
|
||||
qrFormat.value = props.aezeed ? 'text' : 'seedqr'
|
||||
})
|
||||
</script>
|
||||
@@ -78,6 +78,10 @@
|
||||
</span>
|
||||
<span class="text-sm font-medium text-white/80">−{{ confirmAmount.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<div v-if="effectiveMethod === 'onchain'" class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/50">Network fee</span>
|
||||
<span class="text-sm font-medium text-white/80">{{ feeEstimateLabel }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/50">Balance after</span>
|
||||
<span class="text-sm font-medium" :class="insufficient ? 'text-red-400' : 'text-white/80'">
|
||||
@@ -117,7 +121,19 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-white/60 text-sm">{{ amountLabel }}</label>
|
||||
<!-- sats/BTC entry toggle (on-chain only) -->
|
||||
<div v-if="sendMethod === 'onchain'" class="flex p-0.5 bg-white/5 rounded-md">
|
||||
<button
|
||||
v-for="u in (['sats', 'btc'] as const)"
|
||||
:key="u"
|
||||
@click="setAmountUnit(u)"
|
||||
class="px-2 py-0.5 rounded text-[11px] font-medium transition-colors"
|
||||
:class="amountUnit === u ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ u === 'btc' ? 'BTC' : 'sats' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="pastedInvoiceAmount !== null" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
|
||||
<button
|
||||
v-if="sendMethod === 'onchain'"
|
||||
@@ -131,10 +147,11 @@
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="amount"
|
||||
v-model.number="amountEntry"
|
||||
type="number"
|
||||
min="1"
|
||||
:placeholder="sendAll ? '' : pastedInvoiceAmount !== null ? '' : '1000'"
|
||||
:min="amountUnit === 'btc' && sendMethod === 'onchain' ? '0.00000001' : '1'"
|
||||
:step="amountUnit === 'btc' && sendMethod === 'onchain' ? '0.00000001' : '1'"
|
||||
:placeholder="sendAll ? '' : pastedInvoiceAmount !== null ? '' : amountUnit === 'btc' && sendMethod === 'onchain' ? '0.001' : '1000'"
|
||||
:disabled="sendAll || pastedInvoiceAmount !== null"
|
||||
class="w-full input-glass disabled:opacity-50"
|
||||
/>
|
||||
@@ -147,6 +164,7 @@
|
||||
<p v-else-if="effectiveMethod === 'lightning' && dest.trim()" class="text-xs text-white/50 mt-1">
|
||||
Zero-amount invoice — enter how many sats to pay.
|
||||
</p>
|
||||
<p v-else-if="unitConversionHint" class="text-xs text-white/40 mt-1">{{ unitConversionHint }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
|
||||
@@ -165,6 +183,48 @@
|
||||
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : effectiveMethod === 'ark' ? 'tark1… / lnbc… / user@lnaddress' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Network fee (on-chain only) -->
|
||||
<div v-if="sendMethod === 'onchain'" class="mb-3">
|
||||
<label class="text-white/60 text-sm block mb-1">Network fee</label>
|
||||
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="preset in onchainFeePresets"
|
||||
:key="preset.key"
|
||||
@click="feePreset = preset.key"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||
:class="feePreset === preset.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ preset.label }}</button>
|
||||
</div>
|
||||
<p v-if="feePreset !== 'custom'" class="text-white/40 text-xs mt-1">
|
||||
{{ onchainFeePresets.find(p => p.key === feePreset)?.hint }}
|
||||
</p>
|
||||
<div v-else class="grid grid-cols-2 gap-3 mt-2">
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">Target blocks</label>
|
||||
<input
|
||||
v-model.number="customConfTarget"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1008"
|
||||
placeholder="6"
|
||||
class="w-full input-glass"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">Sats per vByte</label>
|
||||
<input
|
||||
v-model.number="customSatPerVbyte"
|
||||
type="number"
|
||||
min="1"
|
||||
max="5000"
|
||||
placeholder="—"
|
||||
class="w-full input-glass"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-white/40 text-xs col-span-2">Set one — sats per vByte takes precedence when both are set</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="ecashToken" class="mb-3 p-2 bg-white/5 rounded-lg">
|
||||
<p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p>
|
||||
<!-- QR so the recipient can scan the token straight off this screen
|
||||
@@ -208,7 +268,47 @@ const emit = defineEmits<{ close: []; sent: []; scan: [] }>()
|
||||
// 'auto' remains in the type for the effectiveMethod logic but is no longer
|
||||
// offered as a tab (hidden per operator request 2026-07-22).
|
||||
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'fedimint' | 'ark'>('lightning')
|
||||
const amount = ref<number>(0)
|
||||
|
||||
// --- Amount entry with a sats/BTC unit toggle (on-chain). `amountEntry` is
|
||||
// --- what the user types in the chosen unit; `amount` stays the canonical
|
||||
// --- sats value the rest of the flow reads and writes.
|
||||
const amountUnit = ref<'sats' | 'btc'>('sats')
|
||||
const amountEntry = ref<number>(0)
|
||||
const amount = computed<number>({
|
||||
get: () =>
|
||||
amountUnit.value === 'btc'
|
||||
? Math.round((amountEntry.value || 0) * 100_000_000)
|
||||
: Math.floor(amountEntry.value || 0),
|
||||
set: (sats: number) => {
|
||||
amountEntry.value = amountUnit.value === 'btc' ? (sats || 0) / 100_000_000 : sats || 0
|
||||
},
|
||||
})
|
||||
|
||||
/** Switch entry unit, converting whatever is already typed. */
|
||||
function setAmountUnit(unit: 'sats' | 'btc') {
|
||||
if (unit === amountUnit.value) return
|
||||
const sats = amount.value
|
||||
amountUnit.value = unit
|
||||
amount.value = sats
|
||||
}
|
||||
|
||||
// Only the on-chain tab offers BTC entry — leaving it snaps back to sats so
|
||||
// the lightning/ecash flows (and their sats-only hints) stay consistent.
|
||||
watch(sendMethod, (m) => {
|
||||
if (m !== 'onchain' && amountUnit.value !== 'sats') setAmountUnit('sats')
|
||||
})
|
||||
|
||||
const amountLabel = computed(() =>
|
||||
sendMethod.value === 'onchain' && amountUnit.value === 'btc' ? 'Amount (BTC)' : t('sendBitcoin.amountSats')
|
||||
)
|
||||
|
||||
const unitConversionHint = computed(() => {
|
||||
if (sendMethod.value !== 'onchain' || !amountEntry.value) return ''
|
||||
return amountUnit.value === 'btc'
|
||||
? `= ${amount.value.toLocaleString()} sats`
|
||||
: `= ${(amount.value / 100_000_000).toFixed(8).replace(/0+$/, '').replace(/\.$/, '')} BTC`
|
||||
})
|
||||
|
||||
const dest = ref('')
|
||||
const processing = ref(false)
|
||||
const error = ref('')
|
||||
@@ -248,6 +348,78 @@ function toggleSendAll() {
|
||||
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
|
||||
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
|
||||
|
||||
// --- On-chain network fee: presets map to LND confirmation targets; custom
|
||||
// --- takes a block target or an explicit sat/vB rate (rate wins).
|
||||
type OnchainFeePreset = 'fast' | 'standard' | 'slow' | 'custom'
|
||||
|
||||
const onchainFeePresets: { key: OnchainFeePreset; label: string; hint?: string; confTarget?: number }[] = [
|
||||
{ key: 'fast', label: 'Fast', hint: 'Targets the next block (~10 minutes)', confTarget: 1 },
|
||||
{ key: 'standard', label: 'Standard', hint: 'Confirms within ~6 blocks (about an hour)', confTarget: 6 },
|
||||
{ key: 'slow', label: 'Slow', hint: 'Confirms within ~144 blocks (about a day)', confTarget: 144 },
|
||||
{ key: 'custom', label: 'Custom' },
|
||||
]
|
||||
|
||||
const feePreset = ref<OnchainFeePreset>('standard')
|
||||
const customConfTarget = ref<number | null>(null)
|
||||
const customSatPerVbyte = ref<number | null>(null)
|
||||
// Resolved at review time so confirm + send use the same params.
|
||||
const resolvedFeeParams = ref<{ target_conf?: number; sat_per_vbyte?: number }>({})
|
||||
|
||||
function onchainFeeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
|
||||
if (feePreset.value !== 'custom') {
|
||||
return { target_conf: onchainFeePresets.find(p => p.key === feePreset.value)?.confTarget ?? 6 }
|
||||
}
|
||||
const rate = customSatPerVbyte.value
|
||||
const conf = customConfTarget.value
|
||||
if (rate != null && rate !== 0) {
|
||||
if (rate < 1 || rate > 5000) { error.value = 'Sats per vByte must be between 1 and 5000'; return null }
|
||||
return { sat_per_vbyte: Math.floor(rate) }
|
||||
}
|
||||
if (conf != null && conf !== 0) {
|
||||
if (conf < 1 || conf > 1008) { error.value = 'Target blocks must be between 1 and 1008'; return null }
|
||||
return { target_conf: Math.floor(conf) }
|
||||
}
|
||||
error.value = 'Custom fee requires target blocks or sats per vByte'
|
||||
return null
|
||||
}
|
||||
|
||||
// Fee estimate for the confirm pane (best-effort — LND's own estimator).
|
||||
const feeEstimate = ref<{ fee_sat: number; sat_per_vbyte: number } | null>(null)
|
||||
const feeEstimateLoading = ref(false)
|
||||
|
||||
const feeEstimateLabel = computed(() => {
|
||||
if (feeEstimate.value) {
|
||||
return `~${feeEstimate.value.fee_sat.toLocaleString()} sats · ${feeEstimate.value.sat_per_vbyte} sat/vB`
|
||||
}
|
||||
if (resolvedFeeParams.value.sat_per_vbyte) {
|
||||
return `${resolvedFeeParams.value.sat_per_vbyte} sat/vB (custom)`
|
||||
}
|
||||
if (feeEstimateLoading.value) return '…'
|
||||
return isSweep.value ? 'deducted from swept amount' : 'estimated at broadcast'
|
||||
})
|
||||
|
||||
async function loadFeeEstimate() {
|
||||
feeEstimate.value = null
|
||||
// Explicit sat/vB shows as-is; sweeps have no fixed amount to estimate on.
|
||||
if (resolvedFeeParams.value.sat_per_vbyte || isSweep.value) return
|
||||
const addr = dest.value.trim()
|
||||
const amt = confirmAmount.value
|
||||
if (!addr || amt < 546) return
|
||||
feeEstimateLoading.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ fee_sat: number; sat_per_vbyte: number }>({
|
||||
method: 'lnd.estimatefee',
|
||||
params: { addr, amount: amt, target_conf: resolvedFeeParams.value.target_conf ?? 6 },
|
||||
timeout: 10000,
|
||||
})
|
||||
if (res.fee_sat > 0) feeEstimate.value = res
|
||||
} catch {
|
||||
/* estimate is a preview — the label falls back to prose */
|
||||
} finally {
|
||||
feeEstimateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Clipboard read needs a secure context (or the companion bridge); hide the
|
||||
// button where it can't work — the textarea still accepts a manual paste.
|
||||
const canReadClipboard = typeof navigator !== 'undefined' && !!navigator.clipboard?.readText
|
||||
@@ -367,6 +539,15 @@ function review() {
|
||||
if (!isSweep.value && confirmAmount.value <= 0 && invoiceAmountSats.value === null) {
|
||||
error.value = t('sendBitcoin.amountSats'); return
|
||||
}
|
||||
if (method === 'onchain') {
|
||||
const fee = onchainFeeParams()
|
||||
if (!fee) return
|
||||
resolvedFeeParams.value = fee
|
||||
void loadFeeEstimate()
|
||||
} else {
|
||||
resolvedFeeParams.value = {}
|
||||
feeEstimate.value = null
|
||||
}
|
||||
void loadConfirmBalance()
|
||||
confirming.value = true
|
||||
}
|
||||
@@ -449,8 +630,8 @@ async function send() {
|
||||
const res = await rpcClient.call<{ txid: string }>({
|
||||
method: 'lnd.sendcoins',
|
||||
params: isSweep.value
|
||||
? { addr: dest.value.trim(), send_all: true }
|
||||
: { addr: dest.value.trim(), amount: amount.value },
|
||||
? { addr: dest.value.trim(), send_all: true, ...resolvedFeeParams.value }
|
||||
: { addr: dest.value.trim(), amount: amount.value, ...resolvedFeeParams.value },
|
||||
})
|
||||
successInfo.value = {
|
||||
amount: paidAmount,
|
||||
|
||||
@@ -415,6 +415,7 @@
|
||||
"totalEarned": "Total Earned",
|
||||
"monthlyAvg": "Monthly Avg",
|
||||
"ecashBalance": "Ecash Balance",
|
||||
"totalBitcoin": "Total Bitcoin",
|
||||
"onChain": "On-chain",
|
||||
"lightning": "Lightning",
|
||||
"ecash": "Ecash",
|
||||
|
||||
@@ -413,6 +413,7 @@
|
||||
"totalEarned": "Total ganado",
|
||||
"monthlyAvg": "Promedio mensual",
|
||||
"ecashBalance": "Saldo Ecash",
|
||||
"totalBitcoin": "Bitcoin total",
|
||||
"onChain": "On-chain",
|
||||
"lightning": "Lightning",
|
||||
"ecash": "Ecash",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* SeedQR encoding (SeedSigner standard, supported by Passport/Passport Prime,
|
||||
* SeedSigner, Keystone, Nunchuk, Sparrow, …): each BIP39 word becomes its
|
||||
* zero-padded 4-digit wordlist index (0000–2047), concatenated into one
|
||||
* digit stream and rendered as a numeric-mode QR. A 24-word seed is 96
|
||||
* digits. Spec: github.com/SeedSigner/seedsigner/blob/main/docs/seed_qr
|
||||
*
|
||||
* Only valid for real BIP39 mnemonics — LND's aezeed shares the wordlist but
|
||||
* is NOT BIP39, and hardware wallets cannot import it; never SeedQR-encode it.
|
||||
*/
|
||||
export async function toSeedQrDigits(words: string[]): Promise<string | null> {
|
||||
if (words.length === 0) return null
|
||||
const { wordlist } = await import('@scure/bip39/wordlists/english.js')
|
||||
const digits: string[] = []
|
||||
for (const raw of words) {
|
||||
const idx = wordlist.indexOf(raw.trim().toLowerCase())
|
||||
if (idx < 0) return null // not a BIP39 word — caller falls back to text
|
||||
digits.push(idx.toString().padStart(4, '0'))
|
||||
}
|
||||
return digits.join('')
|
||||
}
|
||||
@@ -44,7 +44,19 @@
|
||||
|
||||
<!-- Word Grid -->
|
||||
<div v-if="words.length > 0" class="w-full max-w-[600px]">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-1 sm:gap-1.5">
|
||||
<!-- Words / QR tabs — words first; QR for wallets that import by scan -->
|
||||
<div class="flex gap-1 mb-2 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="tab in ([{ key: 'words', label: 'Words' }, { key: 'qr', label: 'QR code' }] as const)"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
@click="seedTab = tab.key"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||
:class="seedTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="seedTab === 'words'" class="grid grid-cols-2 sm:grid-cols-4 gap-1 sm:gap-1.5">
|
||||
<div
|
||||
v-for="(word, i) in words"
|
||||
:key="i"
|
||||
@@ -55,6 +67,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col items-center gap-2 py-2">
|
||||
<canvas ref="seedQrCanvas" class="rounded-lg bg-white p-2"></canvas>
|
||||
<div class="flex p-0.5 bg-white/5 rounded-md">
|
||||
<button
|
||||
v-for="f in ([{ key: 'seedqr', label: 'SeedQR' }, { key: 'text', label: 'Plain text' }] as const)"
|
||||
:key="f.key"
|
||||
type="button"
|
||||
@click="qrFormat = f.key"
|
||||
class="px-2.5 py-1 rounded text-[11px] font-medium transition-colors"
|
||||
:class="qrFormat === f.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 text-center max-w-[420px]">
|
||||
{{ qrFormat === 'seedqr'
|
||||
? 'SeedQR — scans into Passport, SeedSigner, Keystone and other wallets that import seeds by QR.'
|
||||
: 'Plain text words — for wallets that read the phrase as text.' }}
|
||||
Treat this code exactly like the words themselves.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<div class="mt-3 bg-orange-500/10 border border-orange-500/20 rounded-lg px-3 py-2.5">
|
||||
<p class="text-xs sm:text-sm text-orange-300/90">
|
||||
@@ -99,6 +131,32 @@ import { playNavSound } from '@/composables/useNavSounds'
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const words = ref<string[]>([])
|
||||
|
||||
// Words / QR code view of the seed — words are the default first view.
|
||||
// The QR tab defaults to SeedQR (BIP39 word-index digit stream), the format
|
||||
// hardware wallets like Passport Prime / SeedSigner actually import; plain
|
||||
// text stays available for wallets that read the phrase as text.
|
||||
const seedTab = ref<'words' | 'qr'>('words')
|
||||
const qrFormat = ref<'seedqr' | 'text'>('seedqr')
|
||||
const seedQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
async function renderSeedQr() {
|
||||
await nextTick()
|
||||
if (!seedQrCanvas.value || words.value.length === 0) return
|
||||
try {
|
||||
let payload = words.value.join(' ')
|
||||
if (qrFormat.value === 'seedqr') {
|
||||
const { toSeedQrDigits } = await import('@/utils/seedqr')
|
||||
const digits = await toSeedQrDigits(words.value)
|
||||
if (digits) payload = digits
|
||||
else qrFormat.value = 'text' // non-BIP39 word — only text is honest
|
||||
}
|
||||
const QRCode = await import('qrcode')
|
||||
await QRCode.toCanvas(seedQrCanvas.value, payload, { width: 240, margin: 1 })
|
||||
} catch { /* QR is a convenience — the words remain authoritative */ }
|
||||
}
|
||||
watch(seedTab, (tab) => { if (tab === 'qr') void renderSeedQr() })
|
||||
watch(qrFormat, () => { if (seedTab.value === 'qr') void renderSeedQr() })
|
||||
const confirmed = ref(false)
|
||||
const loading = ref(false)
|
||||
const waitingForServer = ref(false)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
||||
|
||||
// Lightning seed (aezeed) backup card. Shown on the LND app detail page.
|
||||
// The backend captures the aezeed at wallet-init time; until the user
|
||||
@@ -166,20 +167,8 @@ async function confirmBackedUp() {
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
|
||||
:class="wordsHidden ? 'blur-md' : ''"
|
||||
@click="wordsHidden = !wordsHidden"
|
||||
>
|
||||
<div v-for="(w, i) in revealedWords" :key="i" class="flex items-center gap-1.5 text-sm">
|
||||
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
|
||||
<span class="text-white font-mono">{{ w }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
|
||||
</div>
|
||||
<SeedRevealPanel :words="revealedWords" aezeed />
|
||||
|
||||
<div class="flex gap-2 pt-4">
|
||||
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
|
||||
<button type="button" :disabled="acking" @click="confirmBackedUp" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50">
|
||||
|
||||
@@ -112,9 +112,18 @@
|
||||
</transition>
|
||||
|
||||
<div class="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between p-3 bg-white/10 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg text-orange-500 font-bold">₿</span>
|
||||
<span class="text-sm font-medium text-white">{{ t('web5.totalBitcoin') }}</span>
|
||||
</div>
|
||||
<span class="text-white text-sm font-semibold">{{ walletTotal.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-orange-500" role="img" aria-label="On-chain" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/80">{{ t('web5.onChain') }}</span>
|
||||
</div>
|
||||
<span class="text-orange-500 text-sm font-medium">{{ walletOnchain.toLocaleString() }} sats</span>
|
||||
@@ -232,6 +241,10 @@ defineEmits<{
|
||||
|
||||
const showIncomingTxPanel = ref(false)
|
||||
|
||||
const walletTotal = computed(() =>
|
||||
props.walletOnchain + props.walletLightning + props.walletEcash + props.walletFedimint + (props.walletArk ?? 0)
|
||||
)
|
||||
|
||||
function isOnchain(tx: WalletTransaction): boolean {
|
||||
return !tx.kind || tx.kind === 'onchain'
|
||||
}
|
||||
|
||||
@@ -362,6 +362,33 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.114-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.114-alpha</span>
|
||||
<span class="text-xs text-white/40">July 26, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in.</p>
|
||||
<p>The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.</p>
|
||||
<p>Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.</p>
|
||||
<p>Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type.</p>
|
||||
<p>Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.113-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.113-alpha</span>
|
||||
<span class="text-xs text-white/40">July 25, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet — only the amount you meant to send leaves it.</p>
|
||||
<p>Closing a Lightning channel is no longer a leap of faith. The close used to hang (or time out with an error) even though it had actually gone through; it now comes back within seconds with the closing transaction ID. Channels mid-close appear in the channel list as Closing or Force-closing with their transaction attached, and a new closed-channels history keeps past closes visible instead of letting them vanish from the list.</p>
|
||||
<p>The wallet card now leads with your total bitcoin across everything, and the on-chain balance gets its own chain icon so the rows read at a glance.</p>
|
||||
<p>The companion phone app (0.5.15) connects dramatically faster away from home: a cold connect over 5G dropped from 40+ seconds to about 5. First connects no longer stall on unreachable mesh dial hints, fresh joins fail fast and retry instead of waiting out long timeouts, and the phone re-announces itself the moment the network around it changes. The node side's mesh-join handling was hardened to match.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.112-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -329,20 +330,7 @@ defineExpose({ loadBackups })
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
|
||||
:class="wordsHidden ? 'blur-md' : ''"
|
||||
@click="wordsHidden = !wordsHidden"
|
||||
>
|
||||
<div v-for="(w, i) in revealedWords" :key="i" class="flex items-center gap-1.5 text-sm">
|
||||
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
|
||||
<span class="text-white font-mono">{{ w }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
|
||||
</div>
|
||||
<SeedRevealPanel :words="revealedWords" />
|
||||
<div class="flex gap-2 pt-4">
|
||||
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
|
||||
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30">Done</button>
|
||||
|
||||
@@ -95,10 +95,21 @@
|
||||
<div v-if="walletError" class="alert-error mb-3">{{ walletError }}</div>
|
||||
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<!-- Total Balance -->
|
||||
<div class="flex items-center justify-between p-3 bg-white/10 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg text-orange-500 font-bold">₿</span>
|
||||
<span class="text-white text-sm font-medium">{{ t('web5.totalBitcoin') }}</span>
|
||||
</div>
|
||||
<span class="text-white text-sm font-semibold">{{ (lndOnchainBalance + lndChannelBalance + ecashBalance).toLocaleString() }} sats</span>
|
||||
</div>
|
||||
|
||||
<!-- On-chain Balance -->
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg text-orange-500 font-bold">₿</span>
|
||||
<svg class="w-5 h-5 text-orange-500" role="img" aria-label="On-chain" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">{{ t('web5.onChain') }}</span>
|
||||
</div>
|
||||
<span class="text-orange-500 text-sm font-medium">{{ lndOnchainBalance.toLocaleString() }} sats</span>
|
||||
|
||||
+19
-24
@@ -1,36 +1,31 @@
|
||||
{
|
||||
"changelog": [
|
||||
"Sound works on TVs out of the box. Fresh installs were missing the audio system entirely, and even when present a boot-time race left HDMI silent until the cable was unplugged and replugged. Both are fixed: installer images now ship the full audio stack, and a small background helper detects the silent-HDMI state and heals it automatically.",
|
||||
"Plug in a game controller and drive the whole TV interface with it — navigation, menus, and media playback all respond to the gamepad, and dialogs that pop up are controller-navigable too.",
|
||||
"The companion phone app took a huge leap (0.5.9). Your node and its apps now work from anywhere — on 5G or any internet connection, the phone reaches the node over the encrypted mesh with zero port forwarding or VPN setup. Startup away from home is instant, apps on your node open inside the app, the phone's native camera handles QR scanning, and a branded full-screen loader shows while the mesh connects.",
|
||||
"Mesh Party: two phones scan each other's QR and instantly get a direct encrypted chat and app sharing between them — plus a \"Share this app\" QR that anyone can scan with a normal camera to install the companion app.",
|
||||
"Pairing a second phone no longer silently logs out the first. Every device now keeps its own named access credential, ending the mystery reconnects when a household paired more than one phone.",
|
||||
"The companion pairing QR is scannable again (it had grown too dense for phone cameras) and now identifies your node by its identity key, so the app recognizes your node even after it moves or gets a new address.",
|
||||
"Every app your node serves on your home network is now also reachable over the mesh — remote access covers the apps themselves, not just the dashboard.",
|
||||
"Selling files: you now choose which payment methods you accept (Lightning, ecash, …) and buyers are only offered those — enforced by the node itself, not just the buttons. Paying twice for the same file is impossible now, purchases file themselves into a new Paid Files tab, purchased music always plays in the bottom-bar player, and videos get picture-in-picture.",
|
||||
"Sending Lightning is invoice-first: paste or scan an invoice and the amount fills in and locks by itself. An expired invoice now tells you plainly to ask for a fresh one instead of failing cryptically, and payment errors always reach your screen.",
|
||||
"Sending to a pasted address gets a confirmation step showing exactly what will happen before any money moves, and buying ecash is an explicit two-step — no more accidental purchases."
|
||||
"Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the \"device detected\" popup for the port. Radios that could never connect now come up within a minute of being plugged in.",
|
||||
"The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.",
|
||||
"Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.",
|
||||
"Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type.",
|
||||
"Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.112-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.112-alpha/archipelago",
|
||||
"current_version": "1.7.114-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.114-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.112-alpha",
|
||||
"sha256": "7cd7ab9dd9b433db929841328cbf553a056937f18814d0b7368b0a5c44dc2b9b",
|
||||
"size_bytes": 51469560
|
||||
"new_version": "1.7.114-alpha",
|
||||
"sha256": "524039b6517c6e506f2d7933051349310704f3babd490d8125ced9ba24a0b0fc",
|
||||
"size_bytes": 51705848
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.112-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.112-alpha/archipelago-frontend-1.7.112-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.112-alpha.tar.gz",
|
||||
"new_version": "1.7.112-alpha",
|
||||
"sha256": "02d916cadb54f76c19910376d6d42e820ee7b42fff7c1ea071bb5ec9b2bea3b8",
|
||||
"size_bytes": 177952072
|
||||
"current_version": "1.7.114-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.114-alpha/archipelago-frontend-1.7.114-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.114-alpha.tar.gz",
|
||||
"new_version": "1.7.114-alpha",
|
||||
"sha256": "fc48209cdf18eaf935e589b2779c46f392e925c93c56ccd085437509c13d65fd",
|
||||
"size_bytes": 177972573
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-24",
|
||||
"signature": "ffe1d1576e63c68f27dc6924f6db4381f745f48e1e279a9b20351c1104cf6e9a8756596cef99e8b3436925361bbbc9f305c14b0632e7c2f095b817783bbfa60d",
|
||||
"release_date": "2026-07-26",
|
||||
"signature": "25ca2cf334da4454cc0ad04165d8fc683a81527a33286bd7253c34a965a791204467fd7e9dba2e6e749469bce699867f7f99fea22d3278d88621ff6aa04ed109",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.112-alpha"
|
||||
"version": "1.7.114-alpha"
|
||||
}
|
||||
|
||||
+19
-24
@@ -1,36 +1,31 @@
|
||||
{
|
||||
"changelog": [
|
||||
"Sound works on TVs out of the box. Fresh installs were missing the audio system entirely, and even when present a boot-time race left HDMI silent until the cable was unplugged and replugged. Both are fixed: installer images now ship the full audio stack, and a small background helper detects the silent-HDMI state and heals it automatically.",
|
||||
"Plug in a game controller and drive the whole TV interface with it — navigation, menus, and media playback all respond to the gamepad, and dialogs that pop up are controller-navigable too.",
|
||||
"The companion phone app took a huge leap (0.5.9). Your node and its apps now work from anywhere — on 5G or any internet connection, the phone reaches the node over the encrypted mesh with zero port forwarding or VPN setup. Startup away from home is instant, apps on your node open inside the app, the phone's native camera handles QR scanning, and a branded full-screen loader shows while the mesh connects.",
|
||||
"Mesh Party: two phones scan each other's QR and instantly get a direct encrypted chat and app sharing between them — plus a \"Share this app\" QR that anyone can scan with a normal camera to install the companion app.",
|
||||
"Pairing a second phone no longer silently logs out the first. Every device now keeps its own named access credential, ending the mystery reconnects when a household paired more than one phone.",
|
||||
"The companion pairing QR is scannable again (it had grown too dense for phone cameras) and now identifies your node by its identity key, so the app recognizes your node even after it moves or gets a new address.",
|
||||
"Every app your node serves on your home network is now also reachable over the mesh — remote access covers the apps themselves, not just the dashboard.",
|
||||
"Selling files: you now choose which payment methods you accept (Lightning, ecash, …) and buyers are only offered those — enforced by the node itself, not just the buttons. Paying twice for the same file is impossible now, purchases file themselves into a new Paid Files tab, purchased music always plays in the bottom-bar player, and videos get picture-in-picture.",
|
||||
"Sending Lightning is invoice-first: paste or scan an invoice and the amount fills in and locks by itself. An expired invoice now tells you plainly to ask for a fresh one instead of failing cryptically, and payment errors always reach your screen.",
|
||||
"Sending to a pasted address gets a confirmation step showing exactly what will happen before any money moves, and buying ecash is an explicit two-step — no more accidental purchases."
|
||||
"Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the \"device detected\" popup for the port. Radios that could never connect now come up within a minute of being plugged in.",
|
||||
"The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.",
|
||||
"Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.",
|
||||
"Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type.",
|
||||
"Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.112-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.112-alpha/archipelago",
|
||||
"current_version": "1.7.114-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.114-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.112-alpha",
|
||||
"sha256": "7cd7ab9dd9b433db929841328cbf553a056937f18814d0b7368b0a5c44dc2b9b",
|
||||
"size_bytes": 51469560
|
||||
"new_version": "1.7.114-alpha",
|
||||
"sha256": "524039b6517c6e506f2d7933051349310704f3babd490d8125ced9ba24a0b0fc",
|
||||
"size_bytes": 51705848
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.112-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.112-alpha/archipelago-frontend-1.7.112-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.112-alpha.tar.gz",
|
||||
"new_version": "1.7.112-alpha",
|
||||
"sha256": "02d916cadb54f76c19910376d6d42e820ee7b42fff7c1ea071bb5ec9b2bea3b8",
|
||||
"size_bytes": 177952072
|
||||
"current_version": "1.7.114-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.114-alpha/archipelago-frontend-1.7.114-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.114-alpha.tar.gz",
|
||||
"new_version": "1.7.114-alpha",
|
||||
"sha256": "fc48209cdf18eaf935e589b2779c46f392e925c93c56ccd085437509c13d65fd",
|
||||
"size_bytes": 177972573
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-24",
|
||||
"signature": "ffe1d1576e63c68f27dc6924f6db4381f745f48e1e279a9b20351c1104cf6e9a8756596cef99e8b3436925361bbbc9f305c14b0632e7c2f095b817783bbfa60d",
|
||||
"release_date": "2026-07-26",
|
||||
"signature": "25ca2cf334da4454cc0ad04165d8fc683a81527a33286bd7253c34a965a791204467fd7e9dba2e6e749469bce699867f7f99fea22d3278d88621ff6aa04ed109",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.112-alpha"
|
||||
"version": "1.7.114-alpha"
|
||||
}
|
||||
|
||||
Executable
+199
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env bash
|
||||
# Gated ISO release build — the single command that turns a signed release
|
||||
# on `main` into a tested installer ISO.
|
||||
#
|
||||
# Stages (fail-fast, each logged with timing):
|
||||
# 0. preflight — Linux, clean tree on main, version parity across
|
||||
# Cargo.toml / package.json / releases/manifest.json /
|
||||
# CHANGELOG / git tag, manifest signature present
|
||||
# 1. gates — tests/release/run.sh (static + frontend + backend
|
||||
# slice), strict catalog drift, FULL cargo test suite
|
||||
# 2. artifacts — release binary embeds the version, frontend dist
|
||||
# matches, AIUI present (OTA-strip regression guard)
|
||||
# 3. build — image-recipe/build-debian-iso.sh (unbundled by default)
|
||||
# 4. smoke — scripts/iso-smoke-test.sh (mount-level, version-checked)
|
||||
# 5. qemu — headless boot test (skippable with --no-qemu)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/build-iso-release.sh [--skip-gates] [--no-qemu] [--bundled] [--rc N]
|
||||
#
|
||||
# The ISO is NOT signed here — run scripts/sign-iso-checksums.sh with the
|
||||
# offline RELEASE_MASTER_MNEMONIC afterwards (publisher only).
|
||||
|
||||
set -u
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO"
|
||||
|
||||
SKIP_GATES=0 NO_QEMU=0 UNBUNDLED=1 RC_OVERRIDE=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--skip-gates) SKIP_GATES=1 ;;
|
||||
--no-qemu) NO_QEMU=1 ;;
|
||||
--bundled) UNBUNDLED=0 ;;
|
||||
--rc) RC_OVERRIDE="${2:?--rc needs a number}"; shift ;;
|
||||
*) echo "unknown flag: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||||
|
||||
PASS=() FAIL=()
|
||||
stage() { # stage <name> <cmd...>
|
||||
local name="$1"; shift
|
||||
local t0=$SECONDS
|
||||
echo
|
||||
echo "═══ [$name] $*"
|
||||
if "$@"; then
|
||||
echo "═══ [$name] PASS ($((SECONDS - t0))s)"
|
||||
PASS+=("$name")
|
||||
else
|
||||
local rc=$?
|
||||
echo "═══ [$name] FAIL exit=$rc ($((SECONDS - t0))s)"
|
||||
FAIL+=("$name")
|
||||
summary 1
|
||||
fi
|
||||
}
|
||||
summary() {
|
||||
echo
|
||||
echo "──────── ISO release build summary ────────"
|
||||
printf 'PASS: %s\n' "${PASS[@]:-none}"
|
||||
[[ ${#FAIL[@]} -gt 0 ]] && printf 'FAIL: %s\n' "${FAIL[@]}"
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
# ── Stage 0: preflight ───────────────────────────────────────────────
|
||||
preflight() {
|
||||
[ "$(uname -s)" = "Linux" ] || { echo "ISO builds run on Linux only"; return 1; }
|
||||
|
||||
local branch; branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||
[ "$branch" = "main" ] || { echo "must build from main (on: $branch)"; return 1; }
|
||||
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "working tree is not clean — release ISOs build from committed state only:"
|
||||
git status --porcelain | head -20
|
||||
return 1
|
||||
fi
|
||||
|
||||
VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
|
||||
local ui_ver manifest_ver
|
||||
ui_ver="$(python3 -c 'import json;print(json.load(open("neode-ui/package.json"))["version"])')"
|
||||
manifest_ver="$(python3 -c 'import json;print(json.load(open("releases/manifest.json"))["version"])')"
|
||||
echo " Cargo.toml: $VERSION"
|
||||
echo " package.json: $ui_ver"
|
||||
echo " releases/manifest: $manifest_ver"
|
||||
[ "$VERSION" = "$ui_ver" ] || { echo "version mismatch Cargo vs package.json"; return 1; }
|
||||
[ "$VERSION" = "$manifest_ver" ] || { echo "version mismatch Cargo vs releases/manifest.json"; return 1; }
|
||||
|
||||
head -5 CHANGELOG.md | grep -qF "v$VERSION" \
|
||||
|| { echo "CHANGELOG.md top entry is not v$VERSION"; return 1; }
|
||||
|
||||
git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null \
|
||||
|| { echo "tag v$VERSION does not exist — cut the release first (scripts/create-release.sh)"; return 1; }
|
||||
|
||||
# The ISO must only ever be cut from a ceremony-signed manifest.
|
||||
python3 - <<'EOF' || return 1
|
||||
import json, sys
|
||||
m = json.load(open("releases/manifest.json"))
|
||||
sig, by = m.get("signature"), m.get("signed_by", "")
|
||||
if not sig or not by.startswith("did:key:"):
|
||||
print("releases/manifest.json is UNSIGNED — run the signing ceremony first")
|
||||
sys.exit(1)
|
||||
print(f" manifest signed by {by[:32]}…")
|
||||
EOF
|
||||
|
||||
echo " version: $VERSION @ $(git rev-parse --short HEAD), tree clean, manifest signed"
|
||||
}
|
||||
stage "preflight" preflight
|
||||
VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
|
||||
|
||||
# ── Stage 1: gates ───────────────────────────────────────────────────
|
||||
if [ "$SKIP_GATES" = "0" ]; then
|
||||
stage "release-gate-harness" bash tests/release/run.sh
|
||||
stage "catalog-drift-strict" python3 scripts/check-app-catalog-drift.py --release --strict
|
||||
# Full Rust suite — the release harness only runs a 6-module slice;
|
||||
# ~1000 tests otherwise go unverified at ISO time (hardening plan §H).
|
||||
stage "cargo-test-full" timeout 5400 env CARGO_INCREMENTAL=0 \
|
||||
nice -n 10 cargo test --manifest-path core/Cargo.toml -p archipelago --bin archipelago
|
||||
else
|
||||
echo; echo "═══ [gates] SKIPPED (--skip-gates)"
|
||||
fi
|
||||
|
||||
# ── Stage 2: artifact verification ───────────────────────────────────
|
||||
verify_artifacts() {
|
||||
local bin="core/target/release/archipelago"
|
||||
[ -x "$bin" ] || { echo "missing release binary $bin — build it first"; return 1; }
|
||||
strings "$bin" | grep -qF "$VERSION" \
|
||||
|| { echo "release binary does not embed $VERSION — stale build"; return 1; }
|
||||
echo " backend binary embeds $VERSION ($(du -h "$bin" | cut -f1))"
|
||||
|
||||
[ -f web/dist/neode-ui/index.html ] || { echo "missing frontend dist"; return 1; }
|
||||
grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js \
|
||||
|| { echo "frontend dist does not contain $VERSION — stale build"; return 1; }
|
||||
echo " frontend dist contains $VERSION"
|
||||
|
||||
# AIUI must ride inside the dist BEFORE packaging or OTA upgrades
|
||||
# silently strip it from nodes in the field.
|
||||
[ -f web/dist/neode-ui/aiui/index.html ] \
|
||||
|| { echo "AIUI missing from web/dist/neode-ui/aiui — fold it in before building"; return 1; }
|
||||
echo " AIUI present in frontend dist"
|
||||
}
|
||||
stage "verify-artifacts" verify_artifacts
|
||||
|
||||
# ── Stage 3: build the ISO ───────────────────────────────────────────
|
||||
build_iso() {
|
||||
local env_args=(
|
||||
UNBUNDLED="$UNBUNDLED"
|
||||
BUILD_FROM_SOURCE=0
|
||||
DEV_SERVER=localhost
|
||||
ARCHIPELAGO_BIN="$REPO/core/target/release/archipelago"
|
||||
)
|
||||
[ -n "$RC_OVERRIDE" ] && env_args+=(RC="$RC_OVERRIDE")
|
||||
sudo -E env "${env_args[@]}" nice -n 5 bash image-recipe/build-debian-iso.sh
|
||||
}
|
||||
stage "build-iso" build_iso
|
||||
|
||||
find_iso() {
|
||||
ls -t "$REPO"/image-recipe/results/archipelago-installer-"$VERSION"*-x86_64_RC*.iso 2>/dev/null | head -1
|
||||
}
|
||||
ISO="$(find_iso)"
|
||||
[ -n "$ISO" ] || { echo "FAIL: no ISO produced for $VERSION in image-recipe/results/"; FAIL+=("locate-iso"); summary 1; }
|
||||
|
||||
# ── Stage 4: mount-level smoke test ──────────────────────────────────
|
||||
stage "iso-smoke" bash scripts/iso-smoke-test.sh "$ISO" "$VERSION"
|
||||
|
||||
# ── Stage 5: QEMU boot test (best-effort) ────────────────────────────
|
||||
# The ISO's kernel cmdline has no serial console, so the serial-log
|
||||
# sanity grep can miss a perfectly healthy boot. Run it, report it,
|
||||
# but don't fail an otherwise-green build on it.
|
||||
if [ "$NO_QEMU" = "0" ] && command -v qemu-system-x86_64 >/dev/null 2>&1; then
|
||||
echo
|
||||
echo "═══ [qemu-boot] (best-effort) test-iso-qemu.sh $ISO 180"
|
||||
if bash image-recipe/_archived/test-iso-qemu.sh "$ISO" 180; then
|
||||
echo "═══ [qemu-boot] PASS"
|
||||
PASS+=("qemu-boot")
|
||||
else
|
||||
echo "═══ [qemu-boot] INCONCLUSIVE (not gating — verify on real hardware)"
|
||||
PASS+=("qemu-boot(inconclusive)")
|
||||
fi
|
||||
else
|
||||
echo; echo "═══ [qemu-boot] SKIPPED"
|
||||
fi
|
||||
|
||||
# ── Done ─────────────────────────────────────────────────────────────
|
||||
SHA_FILE="$ISO.sha256"
|
||||
[ -f "$SHA_FILE" ] || (cd "$(dirname "$ISO")" && sha256sum "$(basename "$ISO")" > "$SHA_FILE")
|
||||
|
||||
echo
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " ISO RELEASE BUILD COMPLETE — v$VERSION"
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " ISO: $ISO ($(du -h "$ISO" | cut -f1))"
|
||||
echo " SHA256: $(cut -d' ' -f1 "$SHA_FILE")"
|
||||
echo
|
||||
echo " Next steps (publisher, offline mnemonic required):"
|
||||
echo " 1. scripts/sign-iso-checksums.sh $ISO"
|
||||
echo " 2. upload ISO + .sha256 + signed checksum JSON alongside the"
|
||||
echo " v$VERSION Gitea release assets"
|
||||
summary 0
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
# Mount-level smoke test for an Archipelago installer ISO.
|
||||
#
|
||||
# Verifies boot plumbing (BIOS + UEFI + live-boot), the auto-installer
|
||||
# payload, and — the check that has bitten before — that the backend
|
||||
# binary inside the ISO actually embeds the version the filename claims.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/iso-smoke-test.sh <path-to-iso> [expected-version]
|
||||
#
|
||||
# expected-version defaults to core/archipelago/Cargo.toml. Needs sudo
|
||||
# (loop mount). Exits non-zero on the first hard failure; prints a
|
||||
# PASS/FAIL table either way.
|
||||
|
||||
set -u
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ISO="${1:-}"
|
||||
EXPECTED_VERSION="${2:-$(grep -m1 '^version' "$REPO/core/archipelago/Cargo.toml" | sed 's/.*"\(.*\)".*/\1/')}"
|
||||
|
||||
if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then
|
||||
echo "usage: $0 <path-to-iso> [expected-version]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
FAIL=0
|
||||
ok() { echo " OK: $*"; }
|
||||
bad() { echo " FAIL: $*"; FAIL=1; }
|
||||
warn() { echo " WARN: $*"; }
|
||||
|
||||
echo "ISO smoke test"
|
||||
echo " ISO: $ISO ($(du -h "$ISO" | cut -f1))"
|
||||
echo " Version: $EXPECTED_VERSION (expected)"
|
||||
|
||||
# ── Filename ↔ version parity (gap: ISO version can silently drift) ──
|
||||
case "$(basename "$ISO")" in
|
||||
*"$EXPECTED_VERSION"*) ok "filename contains $EXPECTED_VERSION" ;;
|
||||
*) bad "filename does not contain expected version $EXPECTED_VERSION" ;;
|
||||
esac
|
||||
|
||||
MNT="$(mktemp -d)"
|
||||
INITRD_DIR=""
|
||||
cleanup() {
|
||||
sudo umount "$MNT" 2>/dev/null || true
|
||||
rmdir "$MNT" 2>/dev/null || true
|
||||
[ -n "$INITRD_DIR" ] && sudo rm -rf "$INITRD_DIR" 2>/dev/null
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if ! sudo mount -o loop,ro "$ISO" "$MNT"; then
|
||||
echo " FAIL: could not loop-mount ISO" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Required boot + installer files ──────────────────────────────────
|
||||
for f in live/vmlinuz live/initrd.img live/filesystem.squashfs \
|
||||
isolinux/isolinux.bin isolinux/isolinux.cfg \
|
||||
boot/grub/grub.cfg EFI/BOOT/BOOTX64.EFI \
|
||||
archipelago/auto-install.sh archipelago/rootfs.tar; do
|
||||
if [ -e "$MNT/$f" ]; then
|
||||
ok "$f ($(sudo du -h "$MNT/$f" 2>/dev/null | cut -f1))"
|
||||
else
|
||||
bad "missing $f"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── GRUB must boot the live system ───────────────────────────────────
|
||||
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then
|
||||
ok "grub.cfg has boot=live"
|
||||
else
|
||||
bad "grub.cfg missing boot=live"
|
||||
fi
|
||||
|
||||
# ── initrd must contain live-boot scripts ────────────────────────────
|
||||
if command -v unmkinitramfs >/dev/null 2>&1; then
|
||||
INITRD_DIR="$(mktemp -d)"
|
||||
sudo unmkinitramfs "$MNT/live/initrd.img" "$INITRD_DIR" 2>/dev/null
|
||||
if [ -e "$INITRD_DIR/scripts/live" ] || [ -e "$INITRD_DIR/main/scripts/live" ]; then
|
||||
ok "initrd has live-boot scripts"
|
||||
else
|
||||
bad "initrd missing live-boot scripts"
|
||||
fi
|
||||
else
|
||||
warn "unmkinitramfs not installed — skipping initrd live-boot check"
|
||||
fi
|
||||
|
||||
# ── Backend binary inside the ISO embeds the expected version ────────
|
||||
# (the v1.4.0-binary-in-a-v1.5-ISO incident: a stale captured binary
|
||||
# shipped and the fleet rejected its fips.yaml on Activate)
|
||||
BIN_IN_ISO=""
|
||||
if [ -f "$MNT/archipelago/bin/archipelago" ]; then
|
||||
BIN_IN_ISO="$MNT/archipelago/bin/archipelago"
|
||||
if sudo strings "$BIN_IN_ISO" 2>/dev/null | grep -qF "$EXPECTED_VERSION"; then
|
||||
ok "payload backend binary embeds $EXPECTED_VERSION"
|
||||
else
|
||||
bad "payload backend binary does NOT embed $EXPECTED_VERSION (stale binary)"
|
||||
fi
|
||||
else
|
||||
# Fall back to the copy inside rootfs.tar
|
||||
TMPBIN="$(mktemp -d)"
|
||||
if sudo tar -xf "$MNT/archipelago/rootfs.tar" -C "$TMPBIN" \
|
||||
usr/local/bin/archipelago 2>/dev/null; then
|
||||
if sudo strings "$TMPBIN/usr/local/bin/archipelago" | grep -qF "$EXPECTED_VERSION"; then
|
||||
ok "rootfs backend binary embeds $EXPECTED_VERSION"
|
||||
else
|
||||
bad "rootfs backend binary does NOT embed $EXPECTED_VERSION (stale binary)"
|
||||
fi
|
||||
else
|
||||
bad "no backend binary found at archipelago/bin/ or in rootfs.tar"
|
||||
fi
|
||||
sudo rm -rf "$TMPBIN"
|
||||
fi
|
||||
|
||||
# ── Frontend payload present ─────────────────────────────────────────
|
||||
if [ -f "$MNT/archipelago/web-ui/index.html" ]; then
|
||||
ok "frontend payload (archipelago/web-ui/index.html)"
|
||||
if [ -f "$MNT/archipelago/web-ui/aiui/index.html" ]; then
|
||||
ok "AIUI included in frontend payload"
|
||||
else
|
||||
warn "AIUI missing from archipelago/web-ui (verify rootfs copy before shipping)"
|
||||
fi
|
||||
else
|
||||
warn "no archipelago/web-ui payload on ISO (frontend may live in rootfs.tar only)"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAIL" = "1" ]; then
|
||||
echo "ISO SMOKE TEST: FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "ISO SMOKE TEST: PASSED"
|
||||
@@ -62,7 +62,7 @@ summary() {
|
||||
# ── Stage 1: static ──────────────────────────────────────────────────
|
||||
stage "git-diff-check" git diff --check
|
||||
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
|
||||
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py
|
||||
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py --release --strict
|
||||
# Every release must surface its CHANGELOG entry in the Settings "What's New"
|
||||
# modal. The modal hardcodes a block per version and has drifted behind before
|
||||
# (sat at v1.7.84 while the fleet shipped to v1.7.92). Fail if any CHANGELOG
|
||||
|
||||
Reference in New Issue
Block a user