diff --git a/.github/workflows/build-startos-s9pk.yml b/.github/workflows/build-startos-s9pk.yml new file mode 100644 index 0000000..363b8d0 --- /dev/null +++ b/.github/workflows/build-startos-s9pk.yml @@ -0,0 +1,107 @@ +name: Build StartOS s9pk + +# Isolated from the Umbrel image workflow (which triggers on v* tags). This one +# builds the StartOS .s9pk from the start9/ package directory. +on: + workflow_dispatch: + push: + branches: [start9-adaptation] + tags: + - 'startos-v*' + +permissions: + contents: write + +defaults: + run: + working-directory: start9 + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + arch: [x86_64] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install system dependencies + run: | + sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + squashfs-tools-ng \ + qemu-user-static \ + jq + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-docker-action@v5 + - uses: docker/setup-buildx-action@v4 + + - name: Install start-cli + run: | + ARCH=$(uname -m) + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ASSET_NAME="start-cli_${ARCH}-${OS}" + DOWNLOAD_URL=$(curl -fsS \ + -H "Authorization: token ${{ github.token }}" \ + https://api.github.com/repos/Start9Labs/start-os/releases \ + | jq -r '[.[].assets[] | select(.name=="'"$ASSET_NAME"'")] | first | .browser_download_url') + if [ -z "$DOWNLOAD_URL" ] || [ "$DOWNLOAD_URL" = "null" ]; then + echo "Error: Could not find asset '$ASSET_NAME' in Start9Labs/start-os releases" + exit 1 + fi + curl -fsSL \ + -H "Authorization: token ${{ github.token }}" \ + -H "Accept: application/octet-stream" \ + "$DOWNLOAD_URL" -o /tmp/start-cli + sudo install -m 755 /tmp/start-cli /usr/local/bin/start-cli + start-cli --version + + - name: Build s9pk (${{ matrix.arch }}) + env: + DEV_KEY: ${{ secrets.DEV_KEY }} + run: | + test -f icon.png + test $(stat -c%s icon.png) -le 40960 + npm install + npm run check + # Build the ncc bundle (javascript/index.js) BEFORE packing: start-cli + # s9pk pack/list-ingredients needs it to exist. + npm run build + if [ -n "$DEV_KEY" ]; then + mkdir -p "$HOME/.startos" + printf '%s' "$DEV_KEY" > "$HOME/.startos/developer.key.pem" + else + start-cli init-key + fi + make arch/${{ matrix.arch }} + sha256sum broadcast-pool_${{ matrix.arch }}.s9pk + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: broadcast-pool_${{ matrix.arch }}.s9pk + path: start9/broadcast-pool_${{ matrix.arch }}.s9pk + compression-level: 0 + + release: + if: startsWith(github.ref, 'refs/tags/startos-v') + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + pattern: broadcast-pool_* + merge-multiple: true + + - uses: softprops/action-gh-release@v2 + with: + files: | + broadcast-pool_x86_64.s9pk + generate_release_notes: true diff --git a/Cargo.lock b/Cargo.lock index 1df6e55..4a14a06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -294,7 +294,7 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "broadcast-pool" -version = "0.3.12" +version = "0.3.15" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index a8a0ddc..fb34e53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "broadcast-pool" -version = "0.3.12" +version = "0.3.15" edition = "2021" description = "Bitcoin Broadcast Pool - Privacy-preserving transaction scheduling and migration tool" license = "MIT" diff --git a/docs/superpowers/specs/2026-06-25-start9-adaptation-design.md b/docs/superpowers/specs/2026-06-25-start9-adaptation-design.md new file mode 100644 index 0000000..00ca9e4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-start9-adaptation-design.md @@ -0,0 +1,116 @@ +# Diseño: Adaptación de Broadcast Pool a Start9 (StartOS) + +Fecha: 2026-06-25 · Rama: `start9-adaptation` + +## Contexto + +Broadcast Pool ya funciona empaquetado para Umbrel (`umbrel-app/sparrow-broadcast-pool/`). +El binario Rust `broadcast-pool` es un intermediario Electrum entre wallets (Sparrow/Liana) +y un indexador (electrs/Fulcrum), con mempool virtual para programar/retrasar broadcasts. + +Queremos ofrecerlo también en **Start9 (StartOS)** sin afectar al proyecto Umbrel. El binario +es **agnóstico de distro**: su superficie de integración es su contrato de variables de entorno +(`BROADCAST_POOL_*`). Se verificó que funciona en modo **genérico** (sin `BROADCAST_POOL_UMBREL=1`): +`BROADCAST_POOL_INDEXER_URL` se lee directo y la resolución de genesis usa solo `BROADCAST_POOL_RPC_*`. + +**Objetivo:** un paquete `.s9pk` que envuelve el mismo binario, declarando dependencias de +`bitcoind` + `electrs` y exponiendo el proxy Electrum (Sparrow/Liana) y el dashboard web. + +**Decisiones tomadas (brainstorming):** +- Mismo repo, **rama aislada** `start9-adaptation`, todo bajo `start9/` (no toca archivos Umbrel). +- Nodo de pruebas Start9 disponible con acceso SSH/web, indexador **electrs**, red **mainnet**. +- **Multi-red (testnet4, signet, mainnet) con la red autodetectada desde el nodo Bitcoin.** El + binario ya lo hace: `discovery::apply_network_from_rpc` (incondicional) mapea + `getblockchaininfo.chain` → red. El `entrypoint.sh` **no** fija `BROADCAST_POOL_NETWORK`. + (Se corrigió el orden en `src/main.rs`: detectar red ANTES de abrir la DB, que es por-red.) +- Verificación = **pruebas de humo seguras** (conexión, genesis, handshake, dashboard, que + Sparrow/Liana conecten). **NO** se difundirá una tx de valor real. + +## Arquitectura + +Patrón espejo de `criptoworld8484/sparrow-frigate-startos` (StartOS TypeScript SDK), que ya +depende de bitcoind+electrs y expone una interfaz Electrum — forma casi idéntica a la nuestra. + +``` +Sparrow / Liana ──TCP LAN:50050──▶ broadcast-pool (.s9pk) + │ (mismo binario Rust; entrypoint StartOS) + ├──▶ electrs.startos:50001 (upstream índice) + └──▶ bitcoind.startos:8332 (genesis/red/altura, cookie auth) +Navegador ──LAN/Tor HTTPS──▶ dashboard web :8080 +``` + +El binario Rust **no se modifica** (salvo un posible ajuste menor: que una `INDEXER_URL` +explícita no sea pisada por el LAN-scan en modo no-Umbrel; se valida en implementación). + +## Componentes (todo bajo `start9/`) + +Estructura TS SDK (como frigate): +- `start9/startos/manifest/index.ts` — id `broadcast-pool`, imágenes (dockerBuild), deps bitcoind+electrs. +- `start9/startos/dependencies.ts` — bitcoind (running, prune=0) + electrs (running). +- `start9/startos/interfaces.ts` — **dos** interfaces: + - `electrum` (TCP, sin SSL) en 50050 — Sparrow/Liana. + - `ui` (web) en 8080 — dashboard. +- `start9/startos/main.ts` — subcontenedor, monta volumen `main`→`/data` y bitcoind RO→`/mnt/bitcoind`, + daemon `entrypoint.sh`, health-check `checkPortListening(50050)`. +- `start9/startos/utils.ts` — constantes de puerto (`electrumPort=50050`, `webPort=8080`). +- `start9/startos/{index,versions/current,init,backups,actions,sdk,i18n}.ts` — plumbing estándar. +- `start9/Dockerfile` — **base de la imagen ya publicada** `broadcast-pool-umbrel:0.3.11` (que ya + contiene el binario + dashboard) + overlay del entrypoint/env de StartOS. Decisión: el contexto de + build del s9pk es el dir del paquete (`start9/`), que no contiene el crate Rust; empaquetar desde un + artefacto preconstruido es lo idiomático en StartOS (frigate descarga un binario). **v1 es x86_64** + (la imagen base es linux/amd64); aarch64 queda como follow-up (imagen multi-arch o binarios de release). +- `start9/entrypoint.sh` — traduce StartOS → env vars del binario (ver mapeo). +- `start9/{Makefile,s9pk.mk,package.json,tsconfig.json}` + `.github/workflows/build-s9pk.yml`. + +## Mapeo de configuración (entrypoint.sh StartOS → binario) + +| Binario (env) | Valor en StartOS | +|-----------------------------------|----------------------------------------------------| +| `BROADCAST_POOL_NETWORK` | `mainnet` (red del nodo Start9) | +| `BROADCAST_POOL_DATA_DIR` | `/data` | +| `BROADCAST_POOL_RPC_URL` | `http://bitcoind.startos:8332` | +| `BROADCAST_POOL_RPC_USER`/`PASS` | leídos del cookie `/mnt/bitcoind/.cookie` (`__cookie__:`) | +| `BROADCAST_POOL_INDEXER_URL` | `tcp://electrs.startos:50001` | +| `BROADCAST_POOL_ELECTRUM_HOST` | `0.0.0.0` | +| `BROADCAST_POOL_ELECTRUM_PORT` | `50050` | +| `BROADCAST_POOL_WEB_HOST`/`PORT` | `0.0.0.0` / `8080` | +| `BROADCAST_POOL_NETWORK` | **no se define** — autodetectada por RPC | +| `BROADCAST_POOL_UMBREL` | **no se define** (modo genérico) | + +El cookie de bitcoind se resuelve igual que frigate (`/mnt/bitcoind/.cookie`, con fallback a +`find`), se parte por `:` en usuario/contraseña, y se espera a que exista antes de arrancar. + +## Manejo de errores +- Esperar el cookie de bitcoind (bucle con timeout) antes de lanzar el binario. +- Health-check de StartOS sobre el puerto 50050: el servicio aparece "starting" hasta que escucha. +- Si electrs aún sincroniza, el binario ya tolera respuestas lentas/erróneas del indexador + (timeouts + fallback de la mempool virtual); StartOS marca la dependencia electrs por su propio health. + +## Pruebas / Verificación (mainnet, sin mover fondos) +1. Build local del `.s9pk` (Makefile) y/o vía workflow `build-s9pk.yml`. +2. Sideload/instalar en el nodo Start9; comprobar que el servicio arranca y la interfaz Electrum + queda **verde** (health). +3. Smoke test del proxy en la dirección LAN:50050: + - `server.version` responde. + - `blockchain.block.header(0)` → hash = genesis mainnet + `000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f`. + - `server.features.genesis_hash` coincide con el del nodo. +4. Apuntar **Sparrow** (y opcionalmente **Liana en mainnet**) a la URL Electrum LAN → conecta y + carga el monedero (solo lectura). **No** difundir una tx real. +5. Dashboard web accesible por la interfaz `ui`. + +Tests automatizados del binario Rust: sin cambios (la suite existente cubre la lógica; el +empaquetado no añade lógica Rust nueva salvo el posible ajuste de INDEXER_URL, que llevaría su test). + +## Alcance / YAGNI +- v1 expone **un** puerto Electrum (50050) para Sparrow y Liana; el `liana_port` separado + (PoC de altura/anti-fee-sniping) **no** se expone en v1. +- **Multi-red por autodetección RPC** (testnet4/signet/mainnet); sin selector manual en la UI. +- Sin acciones/actions extra más allá de las de plumbing en v1. + +## Riesgos +- Posible necesidad de un pequeño ajuste en `discovery.rs`/`main.rs` para que una `INDEXER_URL` + explícita no sea sobrescrita por el LAN-scan en modo no-Umbrel. Se valida al implementar; si + hace falta, es un cambio mínimo y testeado, compartido con Umbrel (no lo rompe). +- Versionado/SDK de StartOS: usar la misma versión del `@start9labs/start-sdk` que frigate para + evitar incompatibilidades. diff --git a/src/api/dashboard.html b/src/api/dashboard.html index 59286c0..f9186dc 100644 --- a/src/api/dashboard.html +++ b/src/api/dashboard.html @@ -892,7 +892,7 @@

Configuration

Configure Sparrow or Liana to connect here (Electrum server)
-
-
Use your Umbrel LAN IP (same as in the browser). Do not use the electrs indexer.
+
Use your node’s LAN IP (the same host you use in the browser). Do not use the electrs/Fulcrum indexer.
Use this URL in your wallet server settings
@@ -1216,7 +1216,7 @@

Settings

cfg_min_delay: 'Min Delay (hours)', cfg_min_delay_hint: 'Minimum random delay before broadcast', cfg_max_delay: 'Max Delay (hours)', cfg_max_delay_hint: 'Maximum random delay before broadcast', cfg_reset: 'Reset', cfg_save: 'Save Settings', - cfg_wallet_title: 'Wallet Connection', cfg_wallet_hint: 'Configure your wallet to connect to Broadcast Pool here', cfg_wallet_url: 'Use this URL in your wallet server settings', cfg_wallet_warning: 'Use your Umbrel LAN IP (same as in the browser). Do not use the electrs indexer. In Sparrow: Settings → Network → disable Tor proxy or broadcasts bypass this pool.', + cfg_wallet_title: 'Wallet Connection', cfg_wallet_hint: 'Configure your wallet to connect to Broadcast Pool here', cfg_wallet_url: 'Use this URL in your wallet server settings', cfg_wallet_warning: 'Use your node’s LAN IP (the same host you use in the browser). Do not use the electrs/Fulcrum indexer. In Sparrow: Settings → Network → disable Tor proxy or broadcasts bypass this pool.', cfg_wallet_startos: 'On StartOS, connect your wallet using the Electrum (TCP) address shown in this service’s Interfaces page — not an internal IP.', detail_title: 'Transaction Details', detail_pool_id: 'Pool ID', detail_status: 'Status', detail_network: 'Network', detail_scheduled: 'Scheduled', detail_fee_rate: 'Fee Rate', detail_utxos: 'UTXOs', detail_broadcast_at: 'Broadcast At', detail_confirmed_at: 'Confirmed At', @@ -1326,7 +1326,7 @@

Settings

cfg_min_delay: 'Retraso M\u00ednimo (horas)', cfg_min_delay_hint: 'Retraso aleatorio m\u00ednimo antes de transmisi\u00f3n', cfg_max_delay: 'Retraso M\u00e1ximo (horas)', cfg_max_delay_hint: 'Retraso aleatorio m\u00e1ximo antes de transmisi\u00f3n', cfg_reset: 'Restablecer', cfg_save: 'Guardar Configuraci\u00f3n', - cfg_wallet_title: 'Conexi\u00f3n Wallet', cfg_wallet_hint: 'Configura tu wallet para conectarse aqu\u00ed', cfg_wallet_url: 'Usar esta URL en la configuraci\u00f3n del servidor de tu wallet', cfg_wallet_warning: 'Usa la IP LAN de Umbrel (la misma del navegador). No uses el indexer electrs. En Sparrow: Ajustes \u2192 Red \u2192 desactiva el proxy Tor o los env\u00edos no llegan al pool.', + cfg_wallet_title: 'Conexi\u00f3n Wallet', cfg_wallet_hint: 'Configura tu wallet para conectarse aqu\u00ed', cfg_wallet_url: 'Usar esta URL en la configuraci\u00f3n del servidor de tu wallet', cfg_wallet_warning: 'Usa la IP LAN de tu nodo (el mismo host que usas en el navegador). No uses el indexer electrs/Fulcrum. En Sparrow: Ajustes \u2192 Red \u2192 desactiva el proxy Tor o los env\u00edos no llegan al pool.', cfg_wallet_startos: 'En StartOS, conecta tu wallet con la direcci\u00f3n Electrum (TCP) que se muestra en la p\u00e1gina de Interfaces de este servicio \u2014 no una IP interna.', detail_title: 'Detalles de Transacci\u00f3n', detail_pool_id: 'ID del Pool', detail_status: 'Estado', detail_network: 'Red', detail_scheduled: 'Programada', detail_fee_rate: 'Tarifa', detail_utxos: 'UTXOs', detail_broadcast_at: 'Transmitida el', detail_confirmed_at: 'Confirmada el', @@ -2230,9 +2230,17 @@

Settings

} function updateWalletUrls(cfg) { - const walletUrl = cfg.wallet_connect_url || cfg.sparrow_connect_url; const walletEl = document.getElementById('wallet-url'); - if (walletEl && walletUrl) walletEl.textContent = walletUrl; + if (walletEl) { + if (cfg.startos_mode) { + // On StartOS the wallet address comes from the service's Interfaces page, + // not an auto-detected container IP. Show guidance instead of a bogus URL. + walletEl.textContent = t('cfg_wallet_startos'); + } else { + const walletUrl = cfg.wallet_connect_url || cfg.sparrow_connect_url; + if (walletUrl) walletEl.textContent = walletUrl; + } + } appUmbrelMode = !!cfg.umbrel_mode; const externalGroup = document.getElementById('cfg-external-indexer-group'); diff --git a/src/api/mod.rs b/src/api/mod.rs index 6c57823..7c3120a 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -121,6 +121,7 @@ fn config_to_response(config: &Config, network_changed: bool) -> ConfigResponse indexer_is_manual: is_manual, network_editable: !umbrel, umbrel_mode: umbrel, + startos_mode: crate::discovery::is_startos_mode(), network: config.network.network_type.data_dir_name().to_string(), broadcast_mode: config.schedule.broadcast_mode.to_string(), default_delay_hours: config.schedule.default_delay_hours, @@ -409,6 +410,7 @@ struct ConfigResponse { indexer_is_manual: bool, network_editable: bool, umbrel_mode: bool, + startos_mode: bool, network: String, broadcast_mode: String, default_delay_hours: u64, diff --git a/src/discovery.rs b/src/discovery.rs index 95502c5..2ce28a2 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -64,7 +64,7 @@ pub fn detect_lan_ip() -> Option { { if out.status.success() { let ip = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if is_plausible_lan_ip(&ip) { + if is_plausible_lan_ip(&ip) && !is_likely_docker_bridge(&ip) { return Some(ip); } } @@ -88,15 +88,32 @@ fn is_plausible_lan_ip(ip: &str) -> bool { !ip.starts_with("127.") && !ip.starts_with("0.") && ip.contains('.') } -/// Umbrel / Docker internal subnets — not useful for Sparrow on the LAN. +/// Container/overlay subnets that are NOT reachable by wallets on the physical LAN: +/// Umbrel Docker (10.21.x), default Docker bridges (172.17/18.x), and the StartOS +/// service overlay (10.0.3.x). Detecting one of these as the "LAN IP" would show a +/// useless wallet URL, so they are excluded. fn is_likely_docker_bridge(ip: &str) -> bool { - ip.starts_with("10.21.") || ip.starts_with("172.17.") || ip.starts_with("172.18.") + ip.starts_with("10.21.") + || ip.starts_with("172.17.") + || ip.starts_with("172.18.") + || ip.starts_with("10.0.3.") +} + +/// True when running as a StartOS service. The StartOS entrypoint sets +/// `BROADCAST_POOL_PLATFORM=startos`. On StartOS the wallet connects via the address +/// shown in the service's Interfaces page, not an auto-detected container IP. +pub fn is_startos_mode() -> bool { + std::env::var("BROADCAST_POOL_PLATFORM") + .map(|v| v.eq_ignore_ascii_case("startos")) + .unwrap_or(false) } pub fn resolve_lan_host(config: &Config) -> Option { if let Some(ref h) = config.electrum_server.lan_connect_host { let h = h.trim(); - if !h.is_empty() { + // Ignore a stale container/overlay IP persisted by an earlier version (e.g. the + // StartOS overlay 10.0.3.x) — it is not reachable by wallets on the LAN. + if !h.is_empty() && !is_likely_docker_bridge(h) { return Some(h.to_string()); } } @@ -1129,6 +1146,17 @@ pub fn save_config_to_disk(config: &Config) -> anyhow::Result<()> { mod tests { use super::*; + // Container/overlay IPs must NOT be offered as a wallet LAN IP. The StartOS overlay + // (10.0.3.x) was being shown in the dashboard as the (useless) wallet URL. + #[test] + fn overlay_ips_are_not_lan() { + assert!(is_likely_docker_bridge("10.0.3.72"), "StartOS overlay"); + assert!(is_likely_docker_bridge("10.21.21.10"), "Umbrel docker"); + assert!(is_likely_docker_bridge("172.17.0.2"), "docker bridge"); + assert!(!is_likely_docker_bridge("192.168.50.134"), "real LAN"); + assert!(!is_likely_docker_bridge("10.0.0.5"), "non-overlay private"); + } + #[test] fn maps_bitcoin_chains() { assert_eq!(network_from_bitcoin_chain("main"), NetworkType::Mainnet); diff --git a/src/electrum_server/mod.rs b/src/electrum_server/mod.rs index 0788c9d..5116eb8 100644 --- a/src/electrum_server/mod.rs +++ b/src/electrum_server/mod.rs @@ -241,6 +241,35 @@ fn peek_line_methods(line: &str) -> Option> { .map(|m| vec![m.to_string()]) } +/// Record `server.version` (and its client-name param) on the session. Sparrow always +/// sends this during its handshake; Liana's electrum-client skips it. The presence/absence +/// plus the client name let the broadcast path tell the wallets apart on the shared port. +fn capture_wallet_fingerprint(line: &str, session: &mut SessionState) { + let Ok(v) = serde_json::from_str::(line.trim()) else { + return; + }; + let items: Vec<&serde_json::Value> = match v.as_array() { + Some(arr) => arr.iter().collect(), + None => vec![&v], + }; + for item in items { + if item.get("method").and_then(|m| m.as_str()) != Some("server.version") { + continue; + } + session.saw_server_version = true; + if let Some(name) = item + .get("params") + .and_then(|p| p.as_array()) + .and_then(|a| a.first()) + .and_then(|n| n.as_str()) + { + if !name.is_empty() { + session.wallet_label = Some(name.to_string()); + } + } + } +} + /// Parse broadcast RPC even when strict struct decode would fail. /// Supports `[hex]`, `"hex"`, object params, and broadcast_package batches. fn params_first_string(params: &serde_json::Value) -> Option { @@ -352,6 +381,13 @@ struct SessionState { /// Last tx acked this session — Sparrow polls input scripthashes before enrichment finishes. recent_broadcast_txid: Option, rpc_lines_handled: u32, + /// Whether this session sent `server.version`. Sparrow always does during its handshake; + /// Liana (electrum-client / Rust) skips it. Used to tell the two wallets apart on the + /// SHARED Electrum port so Liana's block-height nLockTime txs are ingested as manual + /// (schedulable by date/time) instead of by_block. + saw_server_version: bool, + /// Client name from `server.version` params (e.g. "Sparrow 2.x"), when sent. + wallet_label: Option, } impl SessionState { @@ -364,7 +400,25 @@ impl SessionState { broadcast_intercepted: false, recent_broadcast_txid: None, rpc_lines_handled: 0, + saw_server_version: false, + wallet_label: None, + } + } + + /// Effective ingest source for a broadcast on this session. On the shared Sparrow + /// port, a wallet that never sent `server.version` (or whose client name is Liana) + /// is treated as Liana so its tx is ingested as manual/pending. + fn effective_source<'a>(&self, listener_label: &'a str) -> &'a str { + if listener_label == "sparrow" { + let looks_liana = self + .wallet_label + .as_deref() + .is_some_and(|l| l.to_ascii_lowercase().contains("liana")); + if looks_liana || !self.saw_server_version { + return "liana"; + } } + listener_label } fn log_disconnect_summary(&self, peer_addr: std::net::SocketAddr, source_label: &str) { @@ -1344,9 +1398,22 @@ async fn intercept_and_handle_broadcast( tracing::info!("Broadcast ack sent to wallet (outputs pre-stored), txid={}", preview.txid); // ── Phase 2: DB insert + input scripthash enrichment in background ── + // Attribute the tx to the actual wallet: on the shared Sparrow port, a session that + // never sent server.version (or whose client name is Liana) is Liana, so its tx is + // ingested as manual/pending (date/time scheduling) instead of by_block. + let effective_source = session.effective_source(source_label); + if effective_source != source_label { + tracing::info!( + "Broadcast on '{}' port attributed to '{}' (saw_server_version={}, wallet={:?})", + source_label, + effective_source, + session.saw_server_version, + session.wallet_label + ); + } let pm = pool_manager.clone(); let cfg = config.clone(); - let src = source_label.to_string(); + let src = effective_source.to_string(); let hex_owned = hex_param; let preview_txid = preview.txid.clone(); @@ -1672,6 +1739,10 @@ async fn process_client_line( session.rpc_lines_handled += 1; + // Wallet fingerprint: record server.version (and its client name) so a broadcast + // later in the session can be attributed to Sparrow vs Liana on the shared port. + capture_wallet_fingerprint(line_str, session); + // Handshake RPCs: always answer locally (never block on upstream electrs). if let Ok(handshake) = parse_subrequests(line_str) { if !handshake.is_empty() @@ -2291,6 +2362,40 @@ mod tests { const SAMPLE_TX: &str = "0100000002f327e86da3e66bd20e1129b1fb36d07056f0b9a117199e759396526b8f3a20780000000000fffffffff0ede03d75050f20801d50358829ae02c058e8677d2cc74df51f738285013c260000000000ffffffff02f028d6dc010000001976a914ffb035781c3c69e076d48b60c3d38592e7ce06a788ac00ca9a3b000000001976a914fa5139067622fd7e1e722a05c17c2bb7d5fd6df088ac00000000"; + // On the shared Sparrow port, Liana is identified by the absence of server.version + // (or a Liana client name) so its block-height nLockTime tx becomes manual, not by_block. + #[test] + fn effective_source_detects_liana_on_shared_port() { + // Sparrow: sends server.version → stays sparrow. + let mut sparrow = SessionState::new(); + capture_wallet_fingerprint( + r#"{"jsonrpc":"2.0","method":"server.version","params":["Sparrow 2.1.0","1.4"],"id":1}"#, + &mut sparrow, + ); + assert!(sparrow.saw_server_version); + assert_eq!(sparrow.effective_source("sparrow"), "sparrow"); + + // Liana: never sends server.version → treated as liana on the sparrow port. + let mut liana = SessionState::new(); + capture_wallet_fingerprint( + r#"{"jsonrpc":"2.0","method":"blockchain.block.header","params":[0],"id":1}"#, + &mut liana, + ); + assert!(!liana.saw_server_version); + assert_eq!(liana.effective_source("sparrow"), "liana"); + + // Liana that DOES send a Liana-named server.version → still liana. + let mut liana_named = SessionState::new(); + capture_wallet_fingerprint( + r#"{"jsonrpc":"2.0","method":"server.version","params":["Liana","1.4"],"id":1}"#, + &mut liana_named, + ); + assert_eq!(liana_named.effective_source("sparrow"), "liana"); + + // The dedicated liana listener is always liana regardless of fingerprint. + assert_eq!(sparrow.effective_source("liana"), "liana"); + } + #[test] fn extract_broadcast_hex_array_params() { let line = format!( diff --git a/src/main.rs b/src/main.rs index 3452ac3..73286f4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -338,13 +338,11 @@ async fn main() -> Result<()> { } } - let data_dir = get_data_dir(&config)?; - std::fs::create_dir_all(&data_dir)?; - - let db_path = config.db_path(&data_dir); - let db = Arc::new(Database::open(&db_path)?); - - // RPC is only created when needed (lazy init) + // RPC is only created when needed (lazy init). Built BEFORE deriving the data dir / DB + // so network auto-detection can correct config.network first: both the data dir and the + // DB filename are network-specific, so detecting the network AFTER opening the DB would + // bind the wrong network. This matters when BROADCAST_POOL_NETWORK is unset and we rely + // purely on auto-detection (e.g. StartOS), where the config default is testnet4. let rpc_needed = matches!( &cli.command, Commands::Start { .. } | Commands::TestRpc | Commands::BroadcastAll { .. } @@ -365,8 +363,14 @@ async fn main() -> Result<()> { None }; - // Auto-detect network (Bitcoin RPC), indexer (50001/50002), and LAN IP for wallet URL. + // Auto-detect network from Bitcoin Core BEFORE deriving the network-specific data dir and DB. discovery::apply_network_from_rpc(&mut config, rpc.as_deref()); + + let data_dir = get_data_dir(&config)?; + std::fs::create_dir_all(&data_dir)?; + + let db_path = config.db_path(&data_dir); + let db = Arc::new(Database::open(&db_path)?); let indexer_before = config.indexer.clone(); let indexer_found = if discovery::is_umbrel_mode() { discovery::heal_umbrel_indexer_config(&mut config); diff --git a/start9/.gitignore b/start9/.gitignore new file mode 100644 index 0000000..300d670 --- /dev/null +++ b/start9/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +javascript/ +*.s9pk diff --git a/start9/Dockerfile b/start9/Dockerfile new file mode 100644 index 0000000..46fc07a --- /dev/null +++ b/start9/Dockerfile @@ -0,0 +1,26 @@ +# StartOS image for broadcast-pool. +# +# Idiomatic StartOS packaging builds the image from a prebuilt artifact rather than +# from repo source (the s9pk build context is this package dir, start9/, which does +# not contain the Rust crate). We reuse the already-published binary image as the base +# layer and only overlay the StartOS entrypoint + env. The base image carries the +# broadcast-pool binary (/usr/local/bin/broadcast-pool) and the dashboard. +# +# NOTE: the base image is currently linux/amd64 only, so this package is x86_64 only. +# Multi-arch (aarch64) is a follow-up: publish a multi-arch base image or per-arch +# release binaries and download the matching one here. +FROM ghcr.io/criptoworld8484/broadcast-pool-umbrel:0.3.15 + +USER root +COPY --chmod=0755 entrypoint.sh /entrypoint.sh + +# StartOS data volume + auto-detected network (do NOT pin BROADCAST_POOL_NETWORK). +ENV BROADCAST_POOL_DATA_DIR=/data +ENV BROADCAST_POOL_ELECTRUM_PORT=50050 +ENV BROADCAST_POOL_WEB_PORT=8080 + +# StartOS runs service subcontainers as root and mounts /data owned by root, so run +# as root (the base image's trailing `USER app` would hit EPERM writing to /data). +USER root + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/start9/LICENSE b/start9/LICENSE new file mode 100644 index 0000000..6551de0 --- /dev/null +++ b/start9/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 criptoworld8484 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/start9/Makefile b/start9/Makefile new file mode 100644 index 0000000..9babe8f --- /dev/null +++ b/start9/Makefile @@ -0,0 +1,2 @@ +ARCHES := x86 arm +include s9pk.mk diff --git a/start9/assets/ABOUT.md b/start9/assets/ABOUT.md new file mode 100644 index 0000000..253bb95 --- /dev/null +++ b/start9/assets/ABOUT.md @@ -0,0 +1,9 @@ +# Broadcast Pool + +Privacy-preserving Bitcoin broadcast scheduler for StartOS. Sits between your +wallet (Sparrow or Liana) and your node's Electrum index, holding signed +transactions in a virtual mempool until a chosen trigger fires (immediate, +scheduled time, fiat price, or block height). Network auto-detected from +Bitcoin Core (mainnet, testnet4, signet). + +Upstream: https://github.com/criptoworld8484/broadcast-pool diff --git a/start9/entrypoint.sh b/start9/entrypoint.sh new file mode 100644 index 0000000..64719d4 --- /dev/null +++ b/start9/entrypoint.sh @@ -0,0 +1,86 @@ +#!/bin/sh +set -eu + +# StartOS entrypoint for broadcast-pool. +# +# Translates the StartOS environment into the binary's generic BROADCAST_POOL_* contract. +# The network is NOT set here on purpose: the binary auto-detects it from Bitcoin Core +# (discovery::apply_network_from_rpc, via getblockchaininfo.chain), so the same image works +# on mainnet, testnet4 and signet without changes. +# +# Connection details default to the StartOS internal service hostnames but can be overridden +# by main.ts (which can read the real RPC/electrs addresses from the StartOS dependency config). + +DATA_DIR="${BROADCAST_POOL_DATA_DIR:-/data}" +BITCOIN_DATA_DIR="${BITCOIN_DATA_DIR:-/mnt/bitcoind}" +# StartOS normalizes the Bitcoin Core RPC port to 8332 on every network (verified: +# bitcoin.conf has "[testnet4] rpcbind=0.0.0.0:8332"), so this default is correct for +# mainnet/testnet4/signet. The indexer is Fulcrum (Electrum TCP on 50001). +BITCOIN_RPC_URL="${BROADCAST_POOL_RPC_URL:-http://bitcoind.startos:8332}" +ELECTRS_URL="${BROADCAST_POOL_INDEXER_URL:-tcp://fulcrum.startos:50001}" + +BOOT_LOG="${DATA_DIR}/startos-boot.log" +mkdir -p "${DATA_DIR}" + +log() { echo "$(date -Iseconds) $*" >> "${BOOT_LOG}"; } + +log "=== broadcast-pool StartOS boot ===" +log "BITCOIN_RPC_URL=${BITCOIN_RPC_URL}" +log "ELECTRS_URL=${ELECTRS_URL}" +log "BITCOIN_DATA_DIR=${BITCOIN_DATA_DIR}" + +# --- Resolve the bitcoind RPC cookie (auth) --------------------------------- +# Bitcoin Core writes a .cookie file in its (network-specific) data dir as +# "__cookie__:". The cookie may live in a network subdirectory +# (e.g. signet/.cookie), so fall back to a recursive find like the frigate package. +resolve_cookie_file() { + if [ -f "${BITCOIN_DATA_DIR}/.cookie" ]; then + echo "${BITCOIN_DATA_DIR}/.cookie" + return 0 + fi + find "${BITCOIN_DATA_DIR}" -maxdepth 4 -name '.cookie' -type f 2>/dev/null | head -1 +} + +# Wait for the cookie to appear (bitcoind may still be starting). +WAIT_MAX="${BITCOIN_WAIT_MAX:-180}" +WAIT_INTERVAL="${BITCOIN_WAIT_INTERVAL:-5}" +elapsed=0 +COOKIE_FILE="" +while [ "${elapsed}" -lt "${WAIT_MAX}" ]; do + COOKIE_FILE="$(resolve_cookie_file || true)" + if [ -n "${COOKIE_FILE}" ] && [ -f "${COOKIE_FILE}" ]; then + break + fi + log "Waiting for Bitcoin Core cookie under ${BITCOIN_DATA_DIR} (${elapsed}s/${WAIT_MAX}s)" + sleep "${WAIT_INTERVAL}" + elapsed=$((elapsed + WAIT_INTERVAL)) +done + +if [ -z "${COOKIE_FILE}" ] || [ ! -f "${COOKIE_FILE}" ]; then + echo "ERROR: Bitcoin Core cookie not found under ${BITCOIN_DATA_DIR} after ${WAIT_MAX}s" >&2 + log "ERROR: cookie not found after ${WAIT_MAX}s" + exit 1 +fi + +COOKIE="$(cat "${COOKIE_FILE}")" +RPC_USER="${COOKIE%%:*}" +RPC_PASS="${COOKIE#*:}" +log "Cookie resolved from ${COOKIE_FILE} (user=${RPC_USER})" + +# --- Export the binary's generic contract ----------------------------------- +export BROADCAST_POOL_DATA_DIR="${DATA_DIR}" +export BROADCAST_POOL_RPC_URL="${BITCOIN_RPC_URL}" +export BROADCAST_POOL_RPC_USER="${RPC_USER}" +export BROADCAST_POOL_RPC_PASS="${RPC_PASS}" +export BROADCAST_POOL_INDEXER_URL="${ELECTRS_URL}" +export BROADCAST_POOL_ELECTRUM_HOST="${BROADCAST_POOL_ELECTRUM_HOST:-0.0.0.0}" +export BROADCAST_POOL_ELECTRUM_PORT="${BROADCAST_POOL_ELECTRUM_PORT:-50050}" +export BROADCAST_POOL_WEB_HOST="${BROADCAST_POOL_WEB_HOST:-0.0.0.0}" +export BROADCAST_POOL_WEB_PORT="${BROADCAST_POOL_WEB_PORT:-8080}" +# Platform hint: the dashboard can't auto-detect a LAN IP here (the container only sees +# the StartOS overlay), so it directs the user to the service's Interfaces page instead. +export BROADCAST_POOL_PLATFORM="startos" +# Deliberately NOT set: BROADCAST_POOL_NETWORK (auto-detected), BROADCAST_POOL_UMBREL. + +log "Starting broadcast-pool (network auto-detected from Bitcoin Core)" +exec broadcast-pool start --foreground diff --git a/start9/icon.png b/start9/icon.png new file mode 100644 index 0000000..c2046c0 Binary files /dev/null and b/start9/icon.png differ diff --git a/start9/instructions.md b/start9/instructions.md new file mode 100644 index 0000000..709cac5 --- /dev/null +++ b/start9/instructions.md @@ -0,0 +1,29 @@ +# Broadcast Pool + +Broadcast Pool sits between your wallet (Sparrow or Liana) and your node's +Electrum index. It intercepts transaction broadcasts and holds the signed +transactions in a **virtual mempool** until a chosen criterion is met +(immediate, scheduled date/time, fiat price, or block height). + +## Setup + +1. Make sure **Bitcoin Core** (pruning disabled) and **Electrs** are installed, + running, and synced. +2. Start Broadcast Pool. The network (mainnet, testnet4 or signet) is detected + automatically from Bitcoin Core. +3. Open the **Interfaces** section and copy the **Electrum (TCP)** LAN address. + +## Connecting a wallet + +- In **Sparrow** or **Liana**, set the Electrum server to the **Electrum (TCP)** + address shown in the Interfaces section (plain TCP, no SSL). Make sure the + wallet's network matches your node's network. +- Use the **Web Dashboard** interface to monitor pending transactions and to + schedule or trigger broadcasts. + +## Important + +A transaction shown as *pending* is **retained in the virtual mempool and has +NOT been broadcast to the network** until its trigger fires. Keep this service +and its data volume running so scheduled broadcasts are not lost. The signed +transaction hex is retained and can be re-broadcast manually if needed. diff --git a/start9/package-lock.json b/start9/package-lock.json new file mode 100644 index 0000000..d8aa8d4 --- /dev/null +++ b/start9/package-lock.json @@ -0,0 +1,426 @@ +{ + "name": "broadcast-pool-startos", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "broadcast-pool-startos", + "dependencies": { + "@start9labs/start-sdk": "1.5.3", + "bitcoin-core-startos": "github:Start9Labs/bitcoin-core-startos#next/28.x" + }, + "devDependencies": { + "@types/node": "^22.19.1", + "@vercel/ncc": "^0.38.4", + "prettier": "^3.6.2", + "typescript": "^5.9.3" + } + }, + "node_modules/@iarna/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==", + "license": "ISC" + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.5.3.tgz", + "integrity": "sha512-OyHe9J6hMvyA5ZavcLkxdVQvZcuTH9J9kagV6NDI83eAG/YpJFIq62gP/n/2PPNdHWwNSXVQmSwnsvsV8Gyg+A==", + "license": "MIT", + "dependencies": { + "@iarna/toml": "^3.0.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0", + "@types/ini": "^4.1.1", + "deep-equality-data-structures": "^2.0.0", + "fast-xml-parser": "~5.7.0", + "ini": "^5.0.0", + "isomorphic-fetch": "^3.0.0", + "mime": "^4.1.0", + "yaml": "^2.8.3", + "zod": "4.3.6", + "zod-deep-partial": "^1.2.0" + } + }, + "node_modules/@types/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vercel/ncc": { + "version": "0.38.4", + "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz", + "integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==", + "dev": true, + "license": "MIT", + "bin": { + "ncc": "dist/ncc/cli.js" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/bitcoin-core-startos": { + "name": "bitcoin-core", + "resolved": "git+ssh://git@github.com/Start9Labs/bitcoin-core-startos.git#cfe246bbb5e9806fb4498b02ec9d542452c25b2d", + "dependencies": { + "@start9labs/start-sdk": "1.5.2", + "diskusage": "^1.2.0" + } + }, + "node_modules/bitcoin-core-startos/node_modules/@start9labs/start-sdk": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.5.2.tgz", + "integrity": "sha512-3L4OKuH9kUtuH6G20BSPk85yN/jS3+0WNkP19ahwO9Nc7L6STel4hujvKii3osf0p0tU2q5Mk2Y8b9f2dVR1ng==", + "license": "MIT", + "dependencies": { + "@iarna/toml": "^3.0.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0", + "@types/ini": "^4.1.1", + "deep-equality-data-structures": "^2.0.0", + "fast-xml-parser": "~5.7.0", + "ini": "^5.0.0", + "isomorphic-fetch": "^3.0.0", + "mime": "^4.1.0", + "yaml": "^2.8.3", + "zod": "4.3.6", + "zod-deep-partial": "^1.2.0" + } + }, + "node_modules/deep-equality-data-structures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz", + "integrity": "sha512-qgrUr7MKXq7VRN+WUpQ48QlXVGL0KdibAoTX8KRg18lgOgqbEKMAW1WZsVCtakY4+XX42pbAJzTz/DlXEFM2Fg==", + "license": "MIT", + "dependencies": { + "object-hash": "^3.0.0" + } + }, + "node_modules/diskusage": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/diskusage/-/diskusage-1.2.0.tgz", + "integrity": "sha512-2u3OG3xuf5MFyzc4MctNRUKjjwK+UkovRYdD2ed/NZNZPrt0lqHnLKxGhlFVvAb4/oufIgQG3nWgwmeTbHOvXA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "es6-promise": "^4.2.8", + "nan": "^2.18.0" + } + }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz", + "integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/prettier": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.5.tgz", + "integrity": "sha512-zxcTTCedNGJM4R8sj/Cq/F0W/c4iE0afWBcBwMTRtw4WHYP9TWkYjdiH3npPRUYsXQCPR0hTU9yjovOu+E6EQA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-deep-partial": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/zod-deep-partial/-/zod-deep-partial-1.4.4.tgz", + "integrity": "sha512-aWkPl7hVStgE01WzbbSxCgX4O+sSpgt8JOjvFUtMTF75VgL6MhWQbiZi+AWGN85SfSTtI9gsOtL1vInoqfDVaA==", + "license": "MIT", + "peerDependencies": { + "zod": "^4.1.13" + } + } + } +} diff --git a/start9/package.json b/start9/package.json new file mode 100644 index 0000000..603d8fb --- /dev/null +++ b/start9/package.json @@ -0,0 +1,24 @@ +{ + "name": "broadcast-pool-startos", + "scripts": { + "build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", + "prettier": "prettier --write startos", + "check": "tsc --noEmit" + }, + "dependencies": { + "@start9labs/start-sdk": "1.5.3", + "bitcoin-core-startos": "github:Start9Labs/bitcoin-core-startos#next/28.x" + }, + "devDependencies": { + "@types/node": "^22.19.1", + "@vercel/ncc": "^0.38.4", + "prettier": "^3.6.2", + "typescript": "^5.9.3" + }, + "prettier": { + "trailingComma": "all", + "tabWidth": 2, + "semi": false, + "singleQuote": true + } +} diff --git a/start9/s9pk.mk b/start9/s9pk.mk new file mode 100644 index 0000000..4c47870 --- /dev/null +++ b/start9/s9pk.mk @@ -0,0 +1,138 @@ +# ** Plumbing. DO NOT EDIT **. +# This file is imported by ./Makefile. Make edits there + +PACKAGE_ID := $(shell awk -F"'" '/id:/ {print $$2}' startos/manifest/index.ts) +INGREDIENTS := $(shell start-cli s9pk list-ingredients 2>/dev/null) +# Resolve the actual git dir so this works inside git worktrees, where .git +# is a file pointing at
/.git/worktrees/ rather than a directory. +GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null) +GIT_DEPS := $(if $(GIT_DIR),$(GIT_DIR)/HEAD $(GIT_DIR)/index) +ARCHES ?= x86 arm riscv +# TARGETS is the list of leaf make-targets the build matrix fans out over. +# Defaults to the arches; variant packages override (e.g. immich, ollama, vllm +# set this to a list of variant or variant-arch leaf targets). +TARGETS ?= $(ARCHES) +ifdef VARIANT +BASE_NAME := $(PACKAGE_ID)_$(VARIANT) +else +BASE_NAME := $(PACKAGE_ID) +endif + +.PHONY: all arches aarch64 x86_64 riscv64 arm arm64 x86 riscv arch/* clean install check-deps check-init package ingredients +.DELETE_ON_ERROR: +.SECONDARY: + +define SUMMARY + @manifest=$$(start-cli s9pk inspect $(1) manifest); \ + size=$$(du -h $(1) | awk '{print $$1}'); \ + title=$$(printf '%s' "$$manifest" | jq -r .title); \ + version=$$(printf '%s' "$$manifest" | jq -r .version); \ + arches=$$(printf '%s' "$$manifest" | jq -r '[.images[].arch // []] | flatten | unique | join(", ")'); \ + sdkv=$$(printf '%s' "$$manifest" | jq -r .sdkVersion); \ + gitHash=$$(printf '%s' "$$manifest" | jq -r .gitHash | sed -E 's/(.*-modified)$$/\x1b[0;31m\1\x1b[0m/'); \ + printf "\n"; \ + printf "\033[1;32m✅ Build Complete!\033[0m\n"; \ + printf "\n"; \ + printf "\033[1;37m📦 $$title\033[0m \033[36mv$$version\033[0m\n"; \ + printf "───────────────────────────────\n"; \ + printf " \033[1;36mFilename:\033[0m %s\n" "$(1)"; \ + printf " \033[1;36mSize:\033[0m %s\n" "$$size"; \ + printf " \033[1;36mArch:\033[0m %s\n" "$$arches"; \ + printf " \033[1;36mSDK:\033[0m %s\n" "$$sdkv"; \ + printf " \033[1;36mGit:\033[0m %s\n" "$$gitHash"; \ + echo "" +endef + +all: $(TARGETS) + +arches: $(ARCHES) + +# Generic make-variable introspection. Used by the release workflow to +# read $(TARGETS) and fan out one matrix runner per target. `make -s +# print-TARGETS` echoes the list with no other output. +print-%: + @echo '$($*)' + +universal: $(BASE_NAME).s9pk + $(call SUMMARY,$<) + +arch/%: $(BASE_NAME)_%.s9pk + $(call SUMMARY,$<) + +x86 x86_64: arch/x86_64 +arm arm64 aarch64: arch/aarch64 +riscv riscv64: arch/riscv64 + +$(BASE_NAME).s9pk: $(INGREDIENTS) $(GIT_DEPS) + @$(MAKE) --no-print-directory ingredients + @echo " Packing '$@'..." + start-cli s9pk pack -o $@ + +$(BASE_NAME)_%.s9pk: $(INGREDIENTS) $(GIT_DEPS) + @$(MAKE) --no-print-directory ingredients + @echo " Packing '$@'..." + start-cli s9pk pack --arch=$* -o $@ + +ingredients: $(INGREDIENTS) + @echo " Re-evaluating ingredients..." + +install: | check-deps check-init + @HOST=$$(awk -F'/' '/^host:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$HOST" ]; then \ + echo "Error: You must define \"host: http://server-name.local\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + S9PK=$$(ls -t *.s9pk 2>/dev/null | head -1); \ + if [ -z "$$S9PK" ]; then \ + echo "Error: No .s9pk file found. Run 'make' first."; \ + exit 1; \ + fi; \ + printf "\n🚀 Installing %s to %s ...\n" "$$S9PK" "$$HOST"; \ + start-cli package install -s "$$S9PK" + +publish: | all + @REGISTRY=$$(awk -F'/' '/^registry:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$REGISTRY" ]; then \ + echo "Error: You must define \"registry: https://my-registry.tld\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + S3BASE=$$(awk -F'/' '/^s9pk-s3base:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$S3BASE" ]; then \ + echo "Error: You must define \"s3base: https://s9pks.my-s3-bucket.tld\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + command -v s3cmd >/dev/null || \ + (echo "Error: s3cmd not found. It must be installed to publish using s3." && exit 1); \ + printf "\n🚀 Publishing to %s; indexing on %s ...\n" "$$S3BASE" "$$REGISTRY"; \ + for s9pk in *.s9pk; do \ + age=$$(( $$(date +%s) - $$(stat -c %Y "$$s9pk") )); \ + if [ "$$age" -gt 3600 ]; then \ + printf "\033[1;33m⚠️ %s is %d minutes old. Publish anyway? [y/N] \033[0m" "$$s9pk" "$$((age / 60))"; \ + read -r ans; \ + case "$$ans" in [yY]*) ;; *) echo "Skipping $$s9pk"; continue ;; esac; \ + fi; \ + start-cli s9pk publish "$$s9pk"; \ + done + +check-deps: + @command -v start-cli >/dev/null || \ + (echo "Error: start-cli not found. Please see https://docs.start9.com/latest/developer-guide/sdk/installing-the-sdk" && exit 1) + @command -v npm >/dev/null || \ + (echo "Error: npm not found. Please install Node.js and npm." && exit 1) + +check-init: + @if [ ! -f ~/.startos/developer.key.pem ]; then \ + echo "Initializing StartOS developer environment..."; \ + start-cli init-key; \ + fi + +javascript/index.js: $(shell find startos -type f) tsconfig.json node_modules + npm run check + npm run build + +node_modules: package-lock.json package.json + npm ci + +clean: + @echo "Cleaning up build artifacts..." + @rm -rf $(PACKAGE_ID).s9pk $(PACKAGE_ID)_x86_64.s9pk $(PACKAGE_ID)_aarch64.s9pk $(PACKAGE_ID)_riscv64.s9pk javascript node_modules diff --git a/start9/startos/actions/index.ts b/start9/startos/actions/index.ts new file mode 100644 index 0000000..c82cb95 --- /dev/null +++ b/start9/startos/actions/index.ts @@ -0,0 +1,3 @@ +import { sdk } from '../sdk' + +export const actions = sdk.Actions.of() diff --git a/start9/startos/backups.ts b/start9/startos/backups.ts new file mode 100644 index 0000000..8561946 --- /dev/null +++ b/start9/startos/backups.ts @@ -0,0 +1,9 @@ +import { sdk } from './sdk' + +// Back up the whole main volume. The pending/scheduled transactions in the +// virtual mempool are the recovery-critical state, so nothing is excluded. +export const { createBackup, restoreInit } = sdk.setupBackups(async () => + sdk.Backups.ofVolumes('main').setOptions({ + exclude: [], + }), +) diff --git a/start9/startos/dependencies.ts b/start9/startos/dependencies.ts new file mode 100644 index 0000000..2e881b2 --- /dev/null +++ b/start9/startos/dependencies.ts @@ -0,0 +1,21 @@ +import { sdk } from './sdk' + +// Broadcast Pool needs Bitcoin Core RPC (genesis / network detection / block height, +// via the .cookie) and a working Electrum indexer. This node ships Fulcrum, which is +// Electrum-protocol compatible (the binary works with electrs or Fulcrum). Health-check +// ids come from the installed packages: bitcoind -> [bitcoind, sync-progress]; +// fulcrum -> [primary, sync-progress]. +export const setDependencies = sdk.setupDependencies(async () => { + return { + bitcoind: { + kind: 'running', + versionRange: '>=28.0:0', + healthChecks: ['bitcoind', 'sync-progress'], + }, + fulcrum: { + kind: 'running', + versionRange: '>=1.9:0', + healthChecks: ['primary', 'sync-progress'], + }, + } +}) diff --git a/start9/startos/fileModels/store.json.ts b/start9/startos/fileModels/store.json.ts new file mode 100644 index 0000000..c469c6c --- /dev/null +++ b/start9/startos/fileModels/store.json.ts @@ -0,0 +1,16 @@ +import { FileHelper, z } from '@start9labs/start-sdk' +import { sdk } from '../sdk' + +export const shape = z + .object({ + initialized: z.boolean().catch(false), + }) + .strip() + +export const storeJson = FileHelper.json( + { + base: sdk.volumes.main, + subpath: '/store.json', + }, + shape, +) diff --git a/start9/startos/i18n/dictionaries/default.ts b/start9/startos/i18n/dictionaries/default.ts new file mode 100644 index 0000000..3069c4f --- /dev/null +++ b/start9/startos/i18n/dictionaries/default.ts @@ -0,0 +1,18 @@ +export const DEFAULT_LANG = 'en_US' + +const dict = { + 'Starting Broadcast Pool!': 0, + 'Electrum (TCP)': 1, + 'The Electrum interface is ready': 2, + 'The Electrum interface is not ready': 3, + 'Web Dashboard': 4, + 'The main Electrum interface for Sparrow and Liana (plain TCP, no SSL)': 5, + 'Web dashboard to monitor and schedule pending broadcasts': 6, + 'Provides blockchain data, RPC, and cookie authentication': 7, + 'Provides the Electrum index backend for address and history lookups': 8, + 'Pruning must be disabled for Electrs and transaction lookups to work.': 9, +} as const + +export type LangDict = Record + +export default dict diff --git a/start9/startos/i18n/dictionaries/translations.ts b/start9/startos/i18n/dictionaries/translations.ts new file mode 100644 index 0000000..af732a2 --- /dev/null +++ b/start9/startos/i18n/dictionaries/translations.ts @@ -0,0 +1,18 @@ +import { LangDict } from './default' + +const translations: Record = { + es_ES: { + 0: '¡Iniciando Broadcast Pool!', + 1: 'Electrum (TCP)', + 2: 'La interfaz Electrum está lista', + 3: 'La interfaz Electrum no está lista', + 4: 'Panel web', + 5: 'Interfaz Electrum principal para Sparrow y Liana (TCP plano, sin SSL)', + 6: 'Panel web para monitorizar y programar las difusiones pendientes', + 7: 'Proporciona datos de blockchain, RPC y autenticación por cookie', + 8: 'Proporciona el backend de índice Electrum para direcciones e historial', + 9: 'La poda debe estar deshabilitada para que Electrs y las búsquedas de transacciones funcionen.', + }, +} + +export default translations diff --git a/start9/startos/i18n/index.ts b/start9/startos/i18n/index.ts new file mode 100644 index 0000000..1849d6d --- /dev/null +++ b/start9/startos/i18n/index.ts @@ -0,0 +1,5 @@ +import { setupI18n } from '@start9labs/start-sdk' +import defaultDict, { DEFAULT_LANG } from './dictionaries/default' +import translations from './dictionaries/translations' + +export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG) diff --git a/start9/startos/index.ts b/start9/startos/index.ts new file mode 100644 index 0000000..7af589b --- /dev/null +++ b/start9/startos/index.ts @@ -0,0 +1,11 @@ +/** + * Plumbing. DO NOT EDIT. + */ +export { createBackup } from './backups' +export { main } from './main' +export { init, uninit } from './init' +export { actions } from './actions' +import { buildManifest } from '@start9labs/start-sdk' +import { manifest as sdkManifest } from './manifest' +import { versionGraph } from './versions' +export const manifest = buildManifest(versionGraph, sdkManifest) diff --git a/start9/startos/init/index.ts b/start9/startos/init/index.ts new file mode 100644 index 0000000..93439c5 --- /dev/null +++ b/start9/startos/init/index.ts @@ -0,0 +1,18 @@ +import { restoreInit } from '../backups' +import { setDependencies } from '../dependencies' +import { setInterfaces } from '../interfaces' +import { sdk } from '../sdk' +import { versionGraph } from '../versions' +import { actions } from '../actions' +import { seedFiles } from './seedFiles' + +export const init = sdk.setupInit( + restoreInit, + versionGraph, + seedFiles, + setInterfaces, + setDependencies, + actions, +) + +export const uninit = sdk.setupUninit(versionGraph) diff --git a/start9/startos/init/seedFiles.ts b/start9/startos/init/seedFiles.ts new file mode 100644 index 0000000..bf5e790 --- /dev/null +++ b/start9/startos/init/seedFiles.ts @@ -0,0 +1,6 @@ +import { storeJson } from '../fileModels/store.json' +import { sdk } from '../sdk' + +export const seedFiles = sdk.setupOnInit(async (effects) => { + await storeJson.merge(effects, {}) +}) diff --git a/start9/startos/interfaces.ts b/start9/startos/interfaces.ts new file mode 100644 index 0000000..b9c2237 --- /dev/null +++ b/start9/startos/interfaces.ts @@ -0,0 +1,52 @@ +import { i18n } from './i18n' +import { sdk } from './sdk' +import { electrumPort, webPort } from './utils' + +export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { + // --- Electrum proxy (plain TCP) for Sparrow / Liana --- + const electrumHost = sdk.MultiHost.of(effects, 'electrum') + const electrumOrigin = await electrumHost.bindPort(electrumPort, { + protocol: null, + preferredExternalPort: electrumPort, + addSsl: null, + secure: { ssl: false }, + }) + const electrum = sdk.createInterface(effects, { + id: 'electrum', + name: i18n('Electrum (TCP)'), + description: i18n( + 'The main Electrum interface for Sparrow and Liana (plain TCP, no SSL)', + ), + type: 'api', + masked: false, + schemeOverride: null, + username: null, + path: '', + query: {}, + }) + + // --- Web dashboard (HTTP UI) --- + const webHost = sdk.MultiHost.of(effects, 'web') + const webOrigin = await webHost.bindPort(webPort, { + protocol: 'http', + preferredExternalPort: webPort, + }) + const web = sdk.createInterface(effects, { + id: 'web', + name: i18n('Web Dashboard'), + description: i18n( + 'Web dashboard to monitor and schedule pending broadcasts', + ), + type: 'ui', + masked: false, + schemeOverride: null, + username: null, + path: '', + query: {}, + }) + + return [ + await electrumOrigin.export([electrum]), + await webOrigin.export([web]), + ] +}) diff --git a/start9/startos/main.ts b/start9/startos/main.ts new file mode 100644 index 0000000..f73fec2 --- /dev/null +++ b/start9/startos/main.ts @@ -0,0 +1,57 @@ +import { i18n } from './i18n' +import { sdk } from './sdk' +import { electrumPort } from './utils' + +export const main = sdk.setupMain(async ({ effects }) => { + console.info('Starting Broadcast Pool!') + + const container = await sdk.SubContainer.of( + effects, + { imageId: 'broadcast-pool' }, + sdk.Mounts.of() + .mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: '/data', + readonly: false, + }) + // Bitcoin Core data dir, read-only, for the RPC .cookie (auth) — the + // entrypoint reads it and derives RPC user/pass. Network is auto-detected + // from Bitcoin Core, so the same image works on mainnet/testnet4/signet. + .mountDependency({ + dependencyId: 'bitcoind', + volumeId: 'main', + subpath: null, + mountpoint: '/mnt/bitcoind', + readonly: true, + }), + 'broadcast-pool', + ) + + return sdk.Daemons.of(effects) + .addDaemon('broadcast-pool', { + subcontainer: container, + exec: { command: ['/entrypoint.sh'] }, + ready: { + display: i18n('Electrum (TCP)'), + fn: async () => { + const result = await sdk.healthCheck.checkPortListening( + effects, + electrumPort, + { + successMessage: i18n('The Electrum interface is ready'), + errorMessage: i18n('The Electrum interface is not ready'), + }, + ) + + if (result.result === 'success') return result + + return { + result: 'starting', + message: i18n('The Electrum interface is not ready'), + } + }, + }, + requires: [], + }) +}) diff --git a/start9/startos/manifest/i18n.ts b/start9/startos/manifest/i18n.ts new file mode 100644 index 0000000..046aedd --- /dev/null +++ b/start9/startos/manifest/i18n.ts @@ -0,0 +1,37 @@ +export const short = { + en_US: 'Schedule and delay Bitcoin broadcasts from Sparrow/Liana', + es_ES: 'Programa y retrasa difusiones de Bitcoin desde Sparrow/Liana', +} + +export const long = { + en_US: + 'Broadcast Pool sits between your wallet (Sparrow or Liana) and your node\'s Electrum index. It intercepts transaction broadcasts and holds the signed transactions in a virtual mempool until a chosen criterion is met (immediate, scheduled date/time, fiat price, or block height). It connects automatically to Bitcoin Core and Fulcrum on StartOS and exposes a plain TCP Electrum interface plus a web dashboard. The network (mainnet, testnet4 or signet) is detected automatically from Bitcoin Core.', + es_ES: + 'Broadcast Pool se sitúa entre tu billetera (Sparrow o Liana) y el índice Electrum de tu nodo. Intercepta las difusiones de transacciones y retiene las transacciones firmadas en una mempool virtual hasta que se cumple el criterio elegido (inmediato, fecha/hora programada, precio fiat o altura de bloque). Se conecta automáticamente a Bitcoin Core y Fulcrum en StartOS y expone una interfaz Electrum TCP y un panel web. La red (mainnet, testnet4 o signet) se detecta automáticamente desde Bitcoin Core.', +} + +export const alertInstall = { + en_US: + 'Broadcast Pool requires Bitcoin Core (pruning disabled) and Fulcrum. A transaction shown as pending is retained in the virtual mempool and is NOT yet broadcast to the network until its trigger fires.', + es_ES: + 'Broadcast Pool requiere Bitcoin Core (sin poda) y Fulcrum. Una transacción marcada como pendiente se retiene en la mempool virtual y NO se difunde a la red hasta que se dispara su criterio.', +} + +export const alertStart = { + en_US: + 'Broadcast Pool will connect to Bitcoin Core and Electrs. Point Sparrow or Liana at the Electrum (TCP) interface shown below.', + es_ES: + 'Broadcast Pool se conectará a Bitcoin Core y Electrs. Apunta Sparrow o Liana a la interfaz Electrum (TCP) que se muestra abajo.', +} + +export const bitcoindDescription = { + en_US: 'Provides blockchain data, RPC, and cookie authentication', + es_ES: 'Proporciona datos de blockchain, RPC y autenticación por cookie', +} + +export const fulcrumDescription = { + en_US: + 'Provides the Electrum index backend (Fulcrum) for address and history lookups', + es_ES: + 'Proporciona el backend de índice Electrum (Fulcrum) para direcciones e historial', +} diff --git a/start9/startos/manifest/index.ts b/start9/startos/manifest/index.ts new file mode 100644 index 0000000..832986e --- /dev/null +++ b/start9/startos/manifest/index.ts @@ -0,0 +1,61 @@ +import { setupManifest } from '@start9labs/start-sdk' +import { + alertInstall, + alertStart, + bitcoindDescription, + fulcrumDescription, + long, + short, +} from './i18n' + +export const manifest = setupManifest({ + id: 'broadcast-pool', + title: 'Broadcast Pool', + license: 'MIT', + packageRepo: 'https://github.com/criptoworld8484/broadcast-pool', + upstreamRepo: 'https://github.com/criptoworld8484/broadcast-pool', + marketingUrl: 'https://github.com/criptoworld8484/broadcast-pool', + donationUrl: null, + description: { short, long }, + volumes: ['main'], + images: { + 'broadcast-pool': { + source: { + dockerBuild: { + // Build context is this package dir (start9/). The Dockerfile bases off the + // already-published binary image, so no Rust source is needed in the context. + dockerfile: 'Dockerfile', + workdir: '.', + }, + }, + // x86_64 only for now: the base binary image is linux/amd64. aarch64 is a follow-up. + arch: ['x86_64'], + }, + }, + alerts: { + install: alertInstall, + update: null, + uninstall: null, + restore: null, + start: alertStart, + stop: null, + }, + dependencies: { + bitcoind: { + description: bitcoindDescription, + optional: false, + metadata: { + title: 'Bitcoin Core', + icon: 'https://raw.githubusercontent.com/Start9Labs/bitcoin-core-startos/refs/heads/30.x/dep-icon.svg', + }, + }, + fulcrum: { + description: fulcrumDescription, + optional: false, + metadata: { + title: 'Fulcrum', + icon: 'https://raw.githubusercontent.com/Start9Labs/fulcrum-startos/refs/heads/master/icon.png', + }, + }, + }, +}) diff --git a/start9/startos/sdk.ts b/start9/startos/sdk.ts new file mode 100644 index 0000000..a2969a4 --- /dev/null +++ b/start9/startos/sdk.ts @@ -0,0 +1,7 @@ +import { StartSdk } from '@start9labs/start-sdk' +import { manifest } from './manifest' + +/** + * Plumbing. DO NOT EDIT. + */ +export const sdk = StartSdk.of().withManifest(manifest).build(true) diff --git a/start9/startos/utils.ts b/start9/startos/utils.ts new file mode 100644 index 0000000..6b0e588 --- /dev/null +++ b/start9/startos/utils.ts @@ -0,0 +1,4 @@ +// Electrum proxy port for Sparrow / Liana (plain TCP). +export const electrumPort = 50050 +// Web dashboard port. +export const webPort = 8080 diff --git a/start9/startos/versions/current.ts b/start9/startos/versions/current.ts new file mode 100644 index 0000000..452c222 --- /dev/null +++ b/start9/startos/versions/current.ts @@ -0,0 +1,13 @@ +import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk' + +export const current = VersionInfo.of({ + version: '0.3.15:1', + releaseNotes: { + en_US: 'Initial StartOS package: Electrum broadcast pool for Sparrow/Liana with virtual mempool and scheduled broadcasting. Network auto-detected from Bitcoin Core.', + es_ES: 'Paquete inicial para StartOS: pool de difusión Electrum para Sparrow/Liana con mempool virtual y difusión programada. Red autodetectada desde Bitcoin Core.', + }, + migrations: { + up: async () => {}, + down: IMPOSSIBLE, + }, +}) diff --git a/start9/startos/versions/index.ts b/start9/startos/versions/index.ts new file mode 100644 index 0000000..e596b0c --- /dev/null +++ b/start9/startos/versions/index.ts @@ -0,0 +1,7 @@ +import { VersionGraph } from '@start9labs/start-sdk' +import { current } from './current' + +export const versionGraph = VersionGraph.of({ + current, + other: [], +}) diff --git a/start9/tsconfig.json b/start9/tsconfig.json new file mode 100644 index 0000000..a2945a5 --- /dev/null +++ b/start9/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["startos/**/*.ts", "node_modules/**/startos"], + "compilerOptions": { + "target": "ES2018", + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true + } +}