From 0305e45dd45027e596d33bb7413b006e27d5208b Mon Sep 17 00:00:00 2001 From: Odenrider <54157783+odenrider@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:18:31 -0600 Subject: [PATCH 01/11] classify USDSOQ by witness version (v7 holding) + first test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TxOutput.isUsdsoq now recognises a v7 holding (OP_7<32>) by witness version (isV7UsdsoqHolding || assetType==1) + static isV7UsdsoqHoldingScript helper, so v7 holdings classify as USDSOQ independent of the byte-derived RPC assetType field — which will be absent after the Phase-4 byte removal. First test in this package: dart test green (4 groups). CTxOut migration Phase 3. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/models/transaction.dart | 21 +++++++++++++++-- test/transaction_v7_test.dart | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 test/transaction_v7_test.dart diff --git a/lib/src/models/transaction.dart b/lib/src/models/transaction.dart index dcb743a..0524ba8 100644 --- a/lib/src/models/transaction.dart +++ b/lib/src/models/transaction.dart @@ -161,8 +161,25 @@ class TxOutput { this.visibility = 0, }); - /// Whether this output carries USDSOQ. - bool get isUsdsoq => assetType == 1; + /// Whether this output is a v7 USDSOQ holding (OP_7 <32-byte program>), by witness + /// version. CTxOut migration Phase 3: USDSOQ classification follows the witness VERSION, + /// independent of the nAssetType byte. Mirrors the node's CTxOut::IsUSDSOQ() and + /// soq-signer's classify hardening. + bool get isV7UsdsoqHolding => isV7UsdsoqHoldingScript(scriptPubKey); + + /// Whether this output carries USDSOQ — by witness version (v7) OR the legacy + /// nAssetType byte. The byte path is a TRANSITION dual-signal; post-Phase-4 (byte + /// removed, and the byte-derived RPC `assetType` field gone) the version path is the + /// sole classifier — which is why this must NOT rely on `assetType == 1` alone. + bool get isUsdsoq => isV7UsdsoqHolding || assetType == 1; + + /// True if [scriptPubKeyHex] is a v7 USDSOQ holding: OP_7 (0x57) || PUSH_32 (0x20) || + /// 32-byte program = 34 bytes (68 hex chars). Reusable so balance/UTXO classification + /// across the wallet agrees with consensus on the new type. + static bool isV7UsdsoqHoldingScript(String scriptPubKeyHex) { + if (scriptPubKeyHex.length != 68) return false; + return scriptPubKeyHex.substring(0, 4).toLowerCase() == '5720'; + } /// Whether this output is confidential (Lattice-BP++ hidden amount). bool get isConfidential => visibility == 1; diff --git a/test/transaction_v7_test.dart b/test/transaction_v7_test.dart new file mode 100644 index 0000000..4f053e2 --- /dev/null +++ b/test/transaction_v7_test.dart @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// transaction_v7_test.dart — v7 USDSOQ holding classification (CTxOut migration Phase 3). +// USDSOQ is recognised by the witness VERSION (OP_7), not just the nAssetType byte / the +// byte-derived RPC `assetType` field — so a v7 holding stays USDSOQ post-Phase-4. + +import 'package:soqushield_sdk/src/models/transaction.dart'; +import 'package:test/test.dart'; + +TxOutput out(String spk, int asset) => + TxOutput(value: 1, valueSat: 1, n: 0, scriptPubKey: spk, assetType: asset); + +void main() { + final v7 = '5720${'aa' * 32}'; // OP_7 <32> + final v1 = '5120${'cd' * 32}'; // OP_1 <32> + + group('v7 USDSOQ holding classification', () { + test('v7 holding is USDSOQ by version even with the byte clear', () { + expect(out(v7, 0).isUsdsoq, isTrue); + expect(out(v7, 0).isV7UsdsoqHolding, isTrue); + }); + + test('v1 output with byte clear is native SOQ', () { + expect(out(v1, 0).isUsdsoq, isFalse); + expect(out(v1, 0).isV7UsdsoqHolding, isFalse); + }); + + test('legacy v1 + byte set is still USDSOQ (transition dual-recognition)', () { + expect(out(v1, 1).isUsdsoq, isTrue); + }); + + test('static script classifier matches', () { + expect(TxOutput.isV7UsdsoqHoldingScript(v7), isTrue); + expect(TxOutput.isV7UsdsoqHoldingScript(v1), isFalse); + expect(TxOutput.isV7UsdsoqHoldingScript('5720aa'), isFalse); // wrong length + expect(TxOutput.isV7UsdsoqHoldingScript('1720${'aa' * 32}'), isFalse); // wrong version + }); + }); +} From be725600149827108f37028ff86ce63ca9d1b389 Mon Sep 17 00:00:00 2001 From: Odenrider <54157783+odenrider@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:18:30 -0600 Subject: [PATCH 02/11] lightning: scaffold Dart eLTOO SDK port (Opt2 foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the node-proven TypeScript soq-lightning-sdk into soqushield_sdk so Opt2 (LSP-trusted, ship-sooner) and Opt3 (self-custodial DilithiumEltoo, flagship) are the SAME code path — Casey's 2026-06-24 one-path decision. lsp_models — request/response types (grounded in rest.go / client.ts) lsp_client — LspClient REST transport over Dio (12 endpoints, LspError) update_tx_builder — the SWAPPABLE seam: PlaceholderTxBuilder (Opt2) today; DilithiumEltooBuilder (Opt3) swaps in with zero facade change soq_lightning — SoqLightning facade (fundAndOpen/openChannel/pay/close/ towers), preserving the live-verified invariants: faucet-cap clamp, balance conservation, monotonic state advance, dropped-close resilience 14 unit tests against a stateful in-memory fake LSP (full lifecycle + every invariant branch + the placeholder/real builder seam). Full package: dart analyze clean, 18/18 tests green. Opt2 only — no real on-chain crypto yet; placeholders are visibly placeholders so nothing downstream mistakes LSP-trusted custody for self-custody. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/soqushield_sdk.dart | 5 +- lib/src/lightning/lightning.dart | 17 ++ lib/src/lightning/lsp_client.dart | 134 ++++++++ lib/src/lightning/lsp_models.dart | 327 ++++++++++++++++++++ lib/src/lightning/soq_lightning.dart | 248 +++++++++++++++ lib/src/lightning/update_tx_builder.dart | 75 +++++ test/lightning_test.dart | 373 +++++++++++++++++++++++ 7 files changed, 1176 insertions(+), 3 deletions(-) create mode 100644 lib/src/lightning/lightning.dart create mode 100644 lib/src/lightning/lsp_client.dart create mode 100644 lib/src/lightning/lsp_models.dart create mode 100644 lib/src/lightning/soq_lightning.dart create mode 100644 lib/src/lightning/update_tx_builder.dart create mode 100644 test/lightning_test.dart diff --git a/lib/soqushield_sdk.dart b/lib/soqushield_sdk.dart index 8ca0edb..6796e67 100644 --- a/lib/soqushield_sdk.dart +++ b/lib/soqushield_sdk.dart @@ -13,7 +13,6 @@ /// - **Address** — Soqucoin Bech32m address encoding/decoding with PQ /// witness program support. /// - **RPC** — Typed client for the Soqucoin node JSON-RPC interface. -/// - **Vault** — pSOQ↔SOQ bridge vault operations with ML-DSA-44 signing. /// /// ## Quick Start /// @@ -63,8 +62,8 @@ export 'src/address/address_codec.dart'; export 'src/rpc/rpc_client.dart'; export 'src/rpc/rpc_models.dart' hide TxOutput; -// Vault bridge -export 'src/vault/vault_client.dart'; +// Lightning (eLTOO, quantum-safe) — LSP client + SoqLightning facade + swappable TX builder +export 'src/lightning/lightning.dart'; // High-level wallet export 'src/wallet.dart'; diff --git a/lib/src/lightning/lightning.dart b/lib/src/lightning/lightning.dart new file mode 100644 index 0000000..bdd9e6d --- /dev/null +++ b/lib/src/lightning/lightning.dart @@ -0,0 +1,17 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// lightning.dart — barrel for the Soqucoin Lightning (eLTOO) layer of the SoquShield SDK. +// +// Quantum-safe (ML-DSA-44) eLTOO Lightning, ported from the node-proven TypeScript SDK. +// Layers: +// lsp_models — request/response types for the LSP REST API +// lsp_client — LspClient REST transport for the live LSP (https://lsp.soqu.org) +// update_tx_builder — the SWAPPABLE eLTOO TX-construction seam (Opt2 placeholder → Opt3 real) +// soq_lightning — high-level SoqLightning facade (open/pay/close + tower liveness) +library; + +export 'lsp_models.dart'; +export 'lsp_client.dart'; +export 'update_tx_builder.dart'; +export 'soq_lightning.dart'; diff --git a/lib/src/lightning/lsp_client.dart b/lib/src/lightning/lsp_client.dart new file mode 100644 index 0000000..bee138c --- /dev/null +++ b/lib/src/lightning/lsp_client.dart @@ -0,0 +1,134 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// lsp_client.dart — REST transport for the Soqucoin Lightning LSP gateway. +// +// Ported from soq-lightning-sdk/src/client.ts. The LSP fronts the soq-lightning-peer at +// https://lsp.soqu.org (valid Let's Encrypt TLS as of 2026-06-24 — no NODE_TLS hack). +// We parse responses as plain text then JSON-decode ourselves so non-JSON bodies (e.g. a +// 404 returning text) surface as a clean [LspError] rather than a Dio decode failure — +// mirroring the TS req() helper exactly. + +import 'dart:convert'; +import 'package:dio/dio.dart'; +import 'lsp_models.dart'; + +/// Raised for any non-2xx LSP response or a non-JSON body where JSON was expected. +class LspError implements Exception { + final int status; + final String message; + final Object? body; + + LspError(this.status, this.message, [this.body]); + + @override + String toString() => 'LspError $status: $message'; +} + +/// Thin typed REST client for the LSP. One instance per [baseUrl]. +class LspClient { + final String baseUrl; + final Dio _dio; + + LspClient({required String baseUrl, Dio? dio}) + : baseUrl = _stripTrailingSlash(baseUrl), + _dio = dio ?? Dio() { + // We own status handling (mirror of res.ok in the TS client): never let Dio throw on + // a 4xx/5xx, and never let it auto-decode — we read text and parse to control errors. + _dio.options + ..validateStatus = ((_) => true) + ..responseType = ResponseType.plain + ..headers['accept'] = 'application/json'; + } + + static String _stripTrailingSlash(String s) => + s.endsWith('/') ? s.substring(0, s.length - 1) : s; + + /// Issue a request and parse the JSON body, raising [LspError] on a non-2xx status or a + /// body that should be JSON but is not. + Future _req(String method, String path, [Object? body]) async { + final res = await _dio.request( + '$baseUrl$path', + data: body == null ? null : jsonEncode(body), + options: Options( + method: method, + headers: body == null ? null : {'content-type': 'application/json'}, + ), + ); + + final text = res.data ?? ''; + final status = res.statusCode ?? 0; + + dynamic json; + if (text.isNotEmpty) { + try { + json = jsonDecode(text); + } catch (_) { + json = null; // some endpoints (e.g. 404) return plain text, not JSON + } + } + + final ok = status >= 200 && status < 300; + if (!ok) { + final msg = (json is Map) + ? (json['error'] ?? json['reject_reason'] ?? text).toString().trim() + : text.trim(); + throw LspError(status, msg, json ?? text); + } + if (json == null) { + final snippet = text.length > 80 ? text.substring(0, 80) : text; + throw LspError(status, 'non-JSON body: $snippet', text); + } + return json; + } + + Future> _reqMap(String method, String path, [Object? body]) async => + (await _req(method, path, body)) as Map; + + // ─── Health / identity ─── + + /// LSP liveness — `{ status: "ok", ... }` when the peer + its node backend are healthy. + Future> health() => _reqMap('GET', '/v1/health'); + + /// LSP identity/capabilities — peer name, version, network, fee policy, `max_channel_sat`. + Future> info() => _reqMap('GET', '/v1/info'); + + // ─── Faucet ─── + + Future> faucetStatus() => _reqMap('GET', '/v1/faucet'); + + /// POST /v1/faucet drips test SOQ AND (default) auto-opens a channel. + Future faucetDrip(FaucetReq req) async => + FaucetResp.fromJson(await _reqMap('POST', '/v1/faucet', req.toJson())); + + // ─── Channels ─── + + Future openChannel(OpenChannelReq req) async => + OpenChannelResp.fromJson(await _reqMap('POST', '/v1/channels', req.toJson())); + + Future> listChannels() async { + final m = await _reqMap('GET', '/v1/channels'); + final list = (m['channels'] as List? ?? const []); + return list.map((c) => LnChannel.fromJson(c as Map)).toList(); + } + + Future getChannel(String id) async => + LnChannel.fromJson(await _reqMap('GET', '/v1/channels/$id')); + + Future updateState(String id, UpdateStateReq req) async => + UpdateStateResp.fromJson( + await _reqMap('POST', '/v1/channels/$id/update', req.toJson())); + + Future closeChannel(String id) async => + CloseResp.fromJson(await _reqMap('POST', '/v1/channels/$id/close')); + + // ─── Diagnostics ─── + + Future> dashboard() => _reqMap('GET', '/v1/dashboard'); + + Future> channelHealth() => _reqMap('GET', '/v1/channels/health'); + + /// Watchtower health, proxied through the LSP (the towers are firewalled internal-only). + Future towerStatus() async => + TowerProxyStatus.fromJson(await _reqMap('GET', '/v1/tower/status')); +} diff --git a/lib/src/lightning/lsp_models.dart b/lib/src/lightning/lsp_models.dart new file mode 100644 index 0000000..a913a59 --- /dev/null +++ b/lib/src/lightning/lsp_models.dart @@ -0,0 +1,327 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// lsp_models.dart — request/response types for the Soqucoin Lightning LSP REST API. +// +// Ported from the node-proven TypeScript SDK (soq-lightning-sdk/src/client.ts), which is +// in turn grounded in soq-lightning-peer/internal/server/rest.go (the live endpoints on the +// Services VPS, fronted at https://lsp.soqu.org). Field names mirror the Go/TS structs +// verbatim over the wire (snake_case); Dart accessors are camelCase. + +/// One eLTOO payment channel as committed on the LSP (channelToMap, rest.go:304-317). +class LnChannel { + final String channelId; + final String initiatorPubKeyHex; + final String peerPubKeyHex; + final int capacitySat; + final int initiatorBalanceSat; + final int peerBalanceSat; + final int stateIndex; + final String state; // "open" | "closing" | "closed" | ... + final int csvDelay; + final int createdAtUnix; + + const LnChannel({ + required this.channelId, + required this.initiatorPubKeyHex, + required this.peerPubKeyHex, + required this.capacitySat, + required this.initiatorBalanceSat, + required this.peerBalanceSat, + required this.stateIndex, + required this.state, + required this.csvDelay, + required this.createdAtUnix, + }); + + bool get isOpen => state == 'open'; + bool get isClosed => state == 'closed' || state == 'closing'; + + factory LnChannel.fromJson(Map json) => LnChannel( + channelId: json['channel_id'] as String, + initiatorPubKeyHex: json['initiator_pub_key_hex'] as String? ?? '', + peerPubKeyHex: json['peer_pub_key_hex'] as String? ?? '', + capacitySat: (json['capacity_sat'] as num).toInt(), + initiatorBalanceSat: (json['initiator_balance_sat'] as num).toInt(), + peerBalanceSat: (json['peer_balance_sat'] as num).toInt(), + stateIndex: (json['state_index'] as num?)?.toInt() ?? 0, + state: json['state'] as String? ?? 'unknown', + csvDelay: (json['csv_delay'] as num?)?.toInt() ?? 0, + createdAtUnix: (json['created_at_unix'] as num?)?.toInt() ?? 0, + ); +} + +/// Request: open a channel directly when you already hold funds (rest.go POST /v1/channels). +class OpenChannelReq { + final String initiatorPubKeyHex; + final int capacitySat; + final String initiatorName; + final int csvDelay; // settlement_csv tier (spec §6.2.1: 288 transparent) + final String initiatorAddress; + + const OpenChannelReq({ + required this.initiatorPubKeyHex, + required this.capacitySat, + required this.initiatorName, + required this.csvDelay, + required this.initiatorAddress, + }); + + Map toJson() => { + 'initiator_pub_key_hex': initiatorPubKeyHex, + 'capacity_sat': capacitySat, + 'initiator_name': initiatorName, + 'csv_delay': csvDelay, + 'initiator_address': initiatorAddress, + }; +} + +/// Response to an open request (rest.go:161-176). +class OpenChannelResp { + final bool accepted; + final String? rejectReason; + final String? peerPubKeyHex; + final String? channelId; + final String? peerAddress; + + const OpenChannelResp({ + required this.accepted, + this.rejectReason, + this.peerPubKeyHex, + this.channelId, + this.peerAddress, + }); + + factory OpenChannelResp.fromJson(Map json) => OpenChannelResp( + accepted: json['accepted'] as bool? ?? false, + rejectReason: json['reject_reason'] as String?, + peerPubKeyHex: json['peer_pub_key_hex'] as String?, + channelId: json['channel_id'] as String?, + peerAddress: json['peer_address'] as String?, + ); +} + +/// Request: drip stagenet SOQ and (default) auto-open a channel (faucet.go:78-96). +class FaucetReq { + final String address; + final int? amountSat; // default 500M, clamped [100M, 1B] server-side + final bool? openChannel; // default true + final String? pubKeyHex; // required when openChannel + final String? name; // default "faucet-user" + + const FaucetReq({ + required this.address, + this.amountSat, + this.openChannel, + this.pubKeyHex, + this.name, + }); + + Map toJson() => { + 'address': address, + if (amountSat != null) 'amount_sat': amountSat, + if (openChannel != null) 'open_channel': openChannel, + if (pubKeyHex != null) 'pub_key_hex': pubKeyHex, + if (name != null) 'name': name, + }; +} + +/// Response from the faucet (faucet.go:258-264). +class FaucetResp { + final bool success; + final String? txid; + final int? amountSat; + final String? amountSoq; + final String? channelId; + final String? error; + final String? retryAfter; + + const FaucetResp({ + required this.success, + this.txid, + this.amountSat, + this.amountSoq, + this.channelId, + this.error, + this.retryAfter, + }); + + factory FaucetResp.fromJson(Map json) => FaucetResp( + success: json['success'] as bool? ?? false, + txid: json['txid'] as String?, + amountSat: (json['amount_sat'] as num?)?.toInt(), + amountSoq: json['amount_soq']?.toString(), + channelId: json['channel_id'] as String?, + error: json['error'] as String?, + retryAfter: json['retry_after'] as String?, + ); +} + +/// Request: propose one eLTOO state transition (rest.go POST /v1/channels/{id}/update). +/// +/// [updateTxHex]/[settlementTxHex]/[ctvHash] come from the swappable [UpdateTxBuilder]: +/// placeholder strings on the Opt2 LSP-trusted path, real 0x42-signed TX hex on Opt3. +class UpdateStateReq { + final int stateIndex; + final int initiatorBalanceSat; + final int peerBalanceSat; + final String updateTxHex; + final String settlementTxHex; + final String ctvHash; + + const UpdateStateReq({ + required this.stateIndex, + required this.initiatorBalanceSat, + required this.peerBalanceSat, + required this.updateTxHex, + required this.settlementTxHex, + required this.ctvHash, + }); + + Map toJson() => { + 'state_index': stateIndex, + 'initiator_balance_sat': initiatorBalanceSat, + 'peer_balance_sat': peerBalanceSat, + 'update_tx_hex': updateTxHex, + 'settlement_tx_hex': settlementTxHex, + 'ctv_hash': ctvHash, + }; +} + +/// Response to a state update (rest.go updateState). +class UpdateStateResp { + final bool accepted; + final String? rejectReason; + final String? peerSignatureHex; // "countersigned" on success + final UpdateStateEcho? echo; + + const UpdateStateResp({ + required this.accepted, + this.rejectReason, + this.peerSignatureHex, + this.echo, + }); + + factory UpdateStateResp.fromJson(Map json) => UpdateStateResp( + accepted: json['accepted'] as bool? ?? false, + rejectReason: json['reject_reason'] as String?, + peerSignatureHex: json['peer_signature_hex'] as String?, + echo: json['echo'] is Map + ? UpdateStateEcho.fromJson(json['echo'] as Map) + : null, + ); +} + +/// The peer's echoed view of the committed state (present in echo_mode). +class UpdateStateEcho { + final int stateIndex; + final int initiatorBalanceSat; + final int peerBalanceSat; + + const UpdateStateEcho({ + required this.stateIndex, + required this.initiatorBalanceSat, + required this.peerBalanceSat, + }); + + factory UpdateStateEcho.fromJson(Map json) => UpdateStateEcho( + stateIndex: (json['state_index'] as num?)?.toInt() ?? 0, + initiatorBalanceSat: (json['initiator_balance_sat'] as num?)?.toInt() ?? 0, + peerBalanceSat: (json['peer_balance_sat'] as num?)?.toInt() ?? 0, + ); +} + +/// Response to a cooperative close (rest.go cooperativeClose). +class CloseResp { + final bool accepted; + final String? rejectReason; + final String? settlementTxid; // present when L1 settlement enqueued + + const CloseResp({ + required this.accepted, + this.rejectReason, + this.settlementTxid, + }); + + factory CloseResp.fromJson(Map json) => CloseResp( + accepted: json['accepted'] as bool? ?? false, + rejectReason: json['reject_reason'] as String?, + settlementTxid: json['settlement_txid'] as String?, + ); +} + +/// Per-tower entry in the LSP's watchtower-health proxy (one per armed tower). +class TowerProxyEntry { + final String name; // e.g. "eLTOOwatchtower" | "monitoring-vps" + final bool available; + final String? error; + final TowerStatusDetail? status; + + const TowerProxyEntry({ + required this.name, + required this.available, + this.error, + this.status, + }); + + factory TowerProxyEntry.fromJson(Map json) => TowerProxyEntry( + name: json['name'] as String? ?? '', + available: json['available'] as bool? ?? false, + error: json['error'] as String?, + status: json['status'] is Map + ? TowerStatusDetail.fromJson(json['status'] as Map) + : null, + ); +} + +/// Detail block inside a [TowerProxyEntry]. +class TowerStatusDetail { + final int watchedChannels; + final int totalChecks; + final int totalTriggers; // >0 means the tower has had to supersede a stale state + final String lastCheck; + final String pollInterval; + final int slaBlocks; + + const TowerStatusDetail({ + required this.watchedChannels, + required this.totalChecks, + required this.totalTriggers, + required this.lastCheck, + required this.pollInterval, + required this.slaBlocks, + }); + + factory TowerStatusDetail.fromJson(Map json) => TowerStatusDetail( + watchedChannels: (json['watched_channels'] as num?)?.toInt() ?? 0, + totalChecks: (json['total_checks'] as num?)?.toInt() ?? 0, + totalTriggers: (json['total_triggers'] as num?)?.toInt() ?? 0, + lastCheck: json['last_check'] as String? ?? '', + pollInterval: json['poll_interval'] as String? ?? '', + slaBlocks: (json['sla_blocks'] as num?)?.toInt() ?? 0, + ); +} + +/// The LSP's aggregated watchtower-health view (/v1/tower/status). +class TowerProxyStatus { + final bool available; // at least one tower reachable + final int towerCount; + final List towers; + + const TowerProxyStatus({ + required this.available, + required this.towerCount, + required this.towers, + }); + + /// Number of towers currently reachable. + int get reachable => towers.where((t) => t.available).length; + + factory TowerProxyStatus.fromJson(Map json) => TowerProxyStatus( + available: json['available'] as bool? ?? false, + towerCount: (json['tower_count'] as num?)?.toInt() ?? 0, + towers: (json['towers'] as List? ?? const []) + .map((t) => TowerProxyEntry.fromJson(t as Map)) + .toList(), + ); +} diff --git a/lib/src/lightning/soq_lightning.dart b/lib/src/lightning/soq_lightning.dart new file mode 100644 index 0000000..f973dc7 --- /dev/null +++ b/lib/src/lightning/soq_lightning.dart @@ -0,0 +1,248 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// soq_lightning.dart — high-level developer/wallet facade for Soqucoin Lightning. +// +// Ported 1:1 from the node-proven TypeScript SDK (soq-lightning-sdk/src/sdk.ts). One +// ergonomic surface over the REST [LspClient]; the eLTOO TX construction is abstracted +// behind the swappable [UpdateTxBuilder] (PlaceholderTxBuilder today = Opt2, the real +// DilithiumEltooBuilder later = Opt3, with NO change to this API or the wallet UI). +// +// final ln = SoqLightning(baseUrl: 'https://lsp.soqu.org'); +// final ch = await ln.openChannel(OpenChannelParams( +// pubKeyHex: pk, address: addr, capacitySat: 100000000)); +// await ln.pay(ch.channelId, 20000000); // move 0.2 SOQ to the peer +// await ln.close(ch.channelId); +// +// The invariants enforced here (balance conservation, monotonic state advance, faucet-cap +// clamp, dropped-close resilience) are the ones VERIFIED LIVE against the stagenet LSP on +// 2026-06-24 — keep them. + +import 'package:dio/dio.dart'; + +import 'lsp_client.dart'; +import 'lsp_models.dart'; +import 'update_tx_builder.dart'; + +/// Optional watchtower arming hook (spec §1.6 persist→arm→ack). On stagenet the LSP arms the +/// firewalled dual towers on the spoke's behalf, so this is unused by default; it exists so +/// the Opt3 self-custodial path can arm a tower with state i+1 BEFORE [SoqLightning.pay] +/// returns success. +abstract class WatchtowerArming { + /// The funding outpoint the tower monitors for [channelId]. + Future<({String fundingTxid, int fundingVout})> fundingFor(String channelId); + + /// Register/arm the tower with the freshly-built state i+1. + Future arm({ + required String channelId, + required String fundingTxid, + required int fundingVout, + required int stateIndex, + required UpdateTx tx, + }); +} + +/// Parameters for opening a channel. +class OpenChannelParams { + final String pubKeyHex; // ML-DSA-44 public key (hex) + final String address; // L1 settlement address + final int capacitySat; + final String? name; + final int? csvDelay; // settlement_csv tier (default 288, transparent — spec §6.2.1) + + const OpenChannelParams({ + required this.pubKeyHex, + required this.address, + required this.capacitySat, + this.name, + this.csvDelay, + }); +} + +class SoqLightning { + final LspClient client; + final UpdateTxBuilder _builder; + final WatchtowerArming? _watchtower; + + /// [baseUrl] is the LSP gateway (e.g. https://lsp.soqu.org). [txBuilder] defaults to the + /// LSP-trusted [PlaceholderTxBuilder] (Opt2); pass a real builder for Opt3. [dio] is + /// injectable for tests. + SoqLightning({ + required String baseUrl, + UpdateTxBuilder txBuilder = const PlaceholderTxBuilder(), + WatchtowerArming? watchtower, + Dio? dio, + }) : client = LspClient(baseUrl: baseUrl, dio: dio), + _builder = txBuilder, + _watchtower = watchtower; + + /// Construct over an existing [LspClient] (e.g. a shared/pre-configured transport). + SoqLightning.withClient( + this.client, { + UpdateTxBuilder txBuilder = const PlaceholderTxBuilder(), + WatchtowerArming? watchtower, + }) : _builder = txBuilder, + _watchtower = watchtower; + + /// Fund via faucet + auto-open a channel (stagenet path). Returns the opened channel. + /// + /// The LSP caps channel capacity at `max_channel_sat`; the faucet drips the requested + /// funds but SILENTLY declines to open a channel if asked for more (success + txid, no + /// channel_id — verified live 2026-06-24). We clamp the request to the advertised cap so + /// the open actually happens; the returned [LnChannel] reflects the real capacity. + Future fundAndOpen(OpenChannelParams p) async { + Map info; + try { + info = await client.info(); + } catch (_) { + info = const {}; + } + final maxCap = (info['max_channel_sat'] as num?)?.toInt() ?? p.capacitySat; + final capacitySat = p.capacitySat < maxCap ? p.capacitySat : maxCap; + + final r = await client.faucetDrip(FaucetReq( + address: p.address, + pubKeyHex: p.pubKeyHex, + openChannel: true, + amountSat: capacitySat, + name: p.name ?? 'sdk', + )); + if (!r.success) { + throw StateError('faucet failed: ${r.error ?? "unknown"}'); + } + if (r.channelId == null) { + throw StateError( + 'faucet dripped ${r.amountSat ?? capacitySat} sat (txid ${r.txid ?? "?"}) but did ' + 'not open a channel — requested capacity may exceed the LSP max_channel_sat ($maxCap)', + ); + } + return client.getChannel(r.channelId!); + } + + /// Open a channel directly (when you already hold funds — no faucet). + Future openChannel(OpenChannelParams p) async { + final r = await client.openChannel(OpenChannelReq( + initiatorPubKeyHex: p.pubKeyHex, + capacitySat: p.capacitySat, + initiatorName: p.name ?? 'sdk', + csvDelay: p.csvDelay ?? 288, + initiatorAddress: p.address, + )); + if (!r.accepted || r.channelId == null) { + throw StateError('open rejected: ${r.rejectReason ?? "unknown"}'); + } + return client.getChannel(r.channelId!); + } + + /// Move [amountSat] from the initiator (us) to the peer — one eLTOO state bump. + /// + /// Proposes the transition, then re-reads: the LSP is the source of truth for the + /// committed balances/index (a countersigning peer may meet-in-the-middle or bump the + /// index), so we never assume our proposal was stored verbatim. Returns the channel AS + /// COMMITTED by the peer. + Future pay(String channelId, int amountSat) async { + if (amountSat <= 0) throw ArgumentError('amount must be positive'); + final ch = await client.getChannel(channelId); + if (ch.state != 'open') throw StateError('channel not open (state=${ch.state})'); + if (amountSat > ch.initiatorBalanceSat) { + throw StateError('insufficient initiator balance'); + } + + final next = UpdateContext( + channel: ch, + nextStateIndex: ch.stateIndex + 1, + nextInitiatorBalanceSat: ch.initiatorBalanceSat - amountSat, + nextPeerBalanceSat: ch.peerBalanceSat + amountSat, + ); + final tx = await _builder.build(next); + final resp = await client.updateState( + channelId, + UpdateStateReq( + stateIndex: next.nextStateIndex, + initiatorBalanceSat: next.nextInitiatorBalanceSat, + peerBalanceSat: next.nextPeerBalanceSat, + updateTxHex: tx.updateTxHex, + settlementTxHex: tx.settlementTxHex, + ctvHash: tx.ctvHash, + ), + ); + if (!resp.accepted) { + throw StateError('update rejected: ${resp.rejectReason ?? "unknown"}'); + } + + // §1.6 persist→arm→ack: the LSP has now persisted+countersigned state i+1. Arm the + // watchtower with i+1 BEFORE we return success. If arming fails we throw — the payment + // is NOT safely locked, and surfacing that beats a silent theft window. + final wt = _watchtower; + if (wt != null) { + final f = await wt.fundingFor(channelId); + await wt.arm( + channelId: channelId, + fundingTxid: f.fundingTxid, + fundingVout: f.fundingVout, + stateIndex: next.nextStateIndex, + tx: tx, + ); + } + + final after = await client.getChannel(channelId); + // invariant true in BOTH echo_mode and accept-and-store: state advanced + funds conserved + if (after.stateIndex <= ch.stateIndex) { + throw StateError('state did not advance: ${ch.stateIndex} -> ${after.stateIndex}'); + } + if (after.initiatorBalanceSat + after.peerBalanceSat != after.capacitySat) { + throw StateError('balance not conserved after update'); + } + return after; + } + + /// Cooperative close → L1 settlement enqueued via the LSP. + /// + /// Resilient to the deployed binary's bug where a SUCCESSFUL close mutates state to + /// "closed" but drops the HTTP response: on a transport error we re-read the channel and + /// treat closed/closing as success. + Future close(String channelId) async { + try { + final r = await client.closeChannel(channelId); + if (!r.accepted) throw StateError('close rejected: ${r.rejectReason ?? "unknown"}'); + return r; + } catch (e) { + LnChannel? ch; + try { + ch = await client.getChannel(channelId); + } catch (_) { + ch = null; + } + if (ch != null && ch.isClosed) { + return const CloseResp( + accepted: true, rejectReason: '(response dropped; state confirms closed)'); + } + rethrow; + } + } + + /// LSP liveness — `{ status: "ok", ... }` when the peer + its node backend are healthy. + Future> health() => client.health(); + + /// LSP identity/capabilities — peer name, version, network, fee policy, etc. + Future> info() => client.info(); + + Future channel(String id) => client.getChannel(id); + Future> channels() => client.listChannels(); + + /// Watchtower health, proxied through the LSP. On stagenet the LSP arms the (firewalled, + /// dual) towers on the spoke's behalf, so the spoke verifies LIVENESS here — "a tower is + /// watching" — not per-state arming (that's mainnet Phase-1 signed receipts). + Future towerStatus() => client.towerStatus(); + + /// Throws unless at least [minTowers] watchtowers are reachable — call before relying on + /// offline safety. Default 2 reflects the dual-tower defense-in-depth deployment. + Future assertTowersHealthy([int minTowers = 2]) async { + final s = await client.towerStatus(); + final up = s.reachable; + if (!s.available || up < minTowers) { + throw StateError( + 'watchtower coverage degraded: $up/${s.towerCount} reachable (need $minTowers)'); + } + } +} diff --git a/lib/src/lightning/update_tx_builder.dart b/lib/src/lightning/update_tx_builder.dart new file mode 100644 index 0000000..031a63c --- /dev/null +++ b/lib/src/lightning/update_tx_builder.dart @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// update_tx_builder.dart — the swappable eLTOO transaction-construction seam. +// +// This is the single architectural seam that lets Opt2 and Opt3 be the SAME code path +// (Casey, 2026-06-24): the [SoqLightning] facade and the whole wallet UI talk only to the +// [UpdateTxBuilder] interface, never to a concrete signer. +// +// • Opt2 (ship sooner): [PlaceholderTxBuilder] — the LSP is accept-and-store on the happy +// path, so the spoke hands it opaque placeholders. Custody is LSP-trusted, honestly +// labelled. NO real on-chain dispute path. +// • Opt3 (flagship): a `DilithiumEltooBuilder` that ports the node-proven TS `channel.ts` +// (SIGHASH_ANYPREVOUTANYSCRIPT 0x42, CTV templates, CSFS 2-of-2, p2wsh v6) and signs +// with the existing `DilithiumNative.instance.sign(...)`. Swapping it in is the WHOLE +// change — zero edits to the facade or UI. +// +// ⚠️ Do NOT fake a real builder by returning plausible-looking hex from the placeholder. +// The honest boundary is the contract: placeholders are visibly placeholders so nothing +// downstream mistakes Opt2 for self-custody. The old in-app crypto (OP_CHECKMULTISIG + +// UTF-8 "state digest") that this replaces was a fund-loss trap precisely because it LOOKED +// real but signed nothing binding. + +import 'lsp_models.dart'; + +/// Everything a builder needs to construct the TXs for one state transition. +class UpdateContext { + /// Current on-LSP channel state (pre-update). + final LnChannel channel; + final int nextStateIndex; + final int nextInitiatorBalanceSat; + final int nextPeerBalanceSat; + + const UpdateContext({ + required this.channel, + required this.nextStateIndex, + required this.nextInitiatorBalanceSat, + required this.nextPeerBalanceSat, + }); +} + +/// The eLTOO update + settlement TX hex (plus CTV hash) for one state transition. +class UpdateTx { + final String updateTxHex; + final String settlementTxHex; + final String ctvHash; + + const UpdateTx({ + required this.updateTxHex, + required this.settlementTxHex, + required this.ctvHash, + }); +} + +/// Produces the eLTOO update/settlement TX hex for a state transition. +/// +/// Opt3's `DilithiumEltooBuilder` will implement this with real 0x42-signed transactions; +/// today [PlaceholderTxBuilder] is the default. The facade is agnostic to which is wired. +abstract class UpdateTxBuilder { + Future build(UpdateContext ctx); +} + +/// Default builder for the LSP-trusted (Opt2) happy path — opaque placeholders accepted by +/// the accept-and-store peer. Carries NO dispute guarantees; swap in the real signer (Opt3) +/// before relying on the unilateral close path. +class PlaceholderTxBuilder implements UpdateTxBuilder { + const PlaceholderTxBuilder(); + + @override + Future build(UpdateContext ctx) async => const UpdateTx( + updateTxHex: 'placeholder', + settlementTxHex: 'placeholder', + ctvHash: 'placeholder', + ); +} diff --git a/test/lightning_test.dart b/test/lightning_test.dart new file mode 100644 index 0000000..a7350fe --- /dev/null +++ b/test/lightning_test.dart @@ -0,0 +1,373 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// lightning_test.dart — unit tests for the Dart Lightning SDK port (Opt2 foundation). +// +// Drives the SoqLightning facade against a stateful in-memory fake LSP wired through a mock +// Dio adapter. Covers the full open→pay→close lifecycle plus every invariant branch the +// live stagenet smoke test (2026-06-24) proved: faucet-cap clamp, balance conservation, +// monotonic state advance, dropped-close resilience, watchtower liveness, LspError mapping, +// and the placeholder TX builder seam. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:soqushield_sdk/soqushield_sdk.dart'; +import 'package:test/test.dart'; + +// ─── Stateful fake LSP ─── + +/// A minimal in-memory model of the soq-lightning-peer REST API, enough to exercise the +/// facade end to end. Flags toggle the failure modes we want to assert on. +class FakeLsp { + final int maxChannelSat; + + /// When true, the faucet declines to open a channel for amount > [faucetHonorSat] but still + /// reports success with no channel_id — the live silent-decline behaviour. The point of a + /// separate honor cap is the real bug: the faucet honors LESS than info() advertises, so + /// clamping to the advertised cap isn't enough. + final bool faucetSilentDecline; + + /// The capacity the faucet will actually open a channel for (defaults to [maxChannelSat]). + final int faucetHonorSat; + + /// When true, `update` accepts but does not advance the state index. + bool freezeState; + + /// When true, `update` corrupts the peer balance so conservation breaks. + bool corruptConservation; + + /// When true, `close` marks the channel closed but throws a transport error (dropped + /// response) — exercises the close() resilience path. + bool closeDropsResponse; + + final Map> channels = {}; + int _seq = 0; + + /// The most recent update request body seen (for asserting the placeholder TX seam). + Map? lastUpdate; + + FakeLsp({ + this.maxChannelSat = 100000000, // 1 SOQ + this.faucetSilentDecline = false, + int? faucetHonorSat, + this.freezeState = false, + this.corruptConservation = false, + this.closeDropsResponse = false, + }) : faucetHonorSat = faucetHonorSat ?? maxChannelSat; + + Map _newChannel(int capacity, String pk, String addr) { + final id = 'ch_${++_seq}'; + final ch = { + 'channel_id': id, + 'initiator_pub_key_hex': pk, + 'peer_pub_key_hex': 'peerpk', + 'capacity_sat': capacity, + 'initiator_balance_sat': capacity, + 'peer_balance_sat': 0, + 'state_index': 0, + 'state': 'open', + 'csv_delay': 288, + 'created_at_unix': 1700000000, + }; + channels[id] = ch; + return ch; + } + + /// Route a request → (status, json-encoded body). Throws a DioException to simulate a + /// transport error where noted. + ({int status, String body}) route(RequestOptions o) { + final method = o.method.toUpperCase(); + final path = Uri.parse(o.path).path; + final body = o.data is String && (o.data as String).isNotEmpty + ? jsonDecode(o.data as String) as Map + : {}; + + String j(Object? v) => jsonEncode(v); + + if (method == 'GET' && path.endsWith('/v1/info')) { + return (status: 200, body: j({'peer_name': 'fake', 'max_channel_sat': maxChannelSat})); + } + if (method == 'GET' && path.endsWith('/v1/health')) { + return (status: 200, body: j({'status': 'ok'})); + } + if (method == 'POST' && path.endsWith('/v1/faucet')) { + final amount = (body['amount_sat'] as num).toInt(); + if (faucetSilentDecline && amount > faucetHonorSat) { + return (status: 200, body: j({'success': true, 'txid': 'tx_drip', 'amount_sat': amount})); + } + final ch = _newChannel(amount, body['pub_key_hex'] as String? ?? 'pk', + body['address'] as String? ?? 'addr'); + return ( + status: 200, + body: j({'success': true, 'txid': 'tx_drip', 'amount_sat': amount, 'channel_id': ch['channel_id']}) + ); + } + if (method == 'POST' && path.endsWith('/v1/channels')) { + final cap = (body['capacity_sat'] as num).toInt(); + if (cap > maxChannelSat) { + return (status: 200, body: j({'accepted': false, 'reject_reason': 'capacity exceeds max'})); + } + final ch = _newChannel(cap, body['initiator_pub_key_hex'] as String? ?? 'pk', + body['initiator_address'] as String? ?? 'addr'); + return (status: 200, body: j({'accepted': true, 'channel_id': ch['channel_id']})); + } + + // /v1/channels/{id}/... and /v1/channels/{id} + final segs = path.split('/').where((s) => s.isNotEmpty).toList(); + final chIdx = segs.indexOf('channels'); + if (chIdx >= 0 && segs.length > chIdx + 1) { + final id = segs[chIdx + 1]; + final ch = channels[id]; + if (ch == null) return (status: 404, body: 'not found'); + final action = segs.length > chIdx + 2 ? segs[chIdx + 2] : null; + + if (method == 'GET' && action == null) { + return (status: 200, body: j(ch)); + } + if (method == 'POST' && action == 'update') { + lastUpdate = body; + if (!freezeState) ch['state_index'] = (body['state_index'] as num).toInt(); + ch['initiator_balance_sat'] = (body['initiator_balance_sat'] as num).toInt(); + ch['peer_balance_sat'] = corruptConservation + ? (body['peer_balance_sat'] as num).toInt() + 1 // break conservation + : (body['peer_balance_sat'] as num).toInt(); + return (status: 200, body: j({'accepted': true, 'peer_signature_hex': 'sig'})); + } + if (method == 'POST' && action == 'close') { + ch['state'] = 'closed'; + if (closeDropsResponse) { + throw DioException(requestOptions: o, type: DioExceptionType.connectionError); + } + return (status: 200, body: j({'accepted': true, 'settlement_txid': 'tx_settle'})); + } + } + + if (method == 'GET' && path.endsWith('/v1/tower/status')) { + return ( + status: 200, + body: j({ + 'available': true, + 'tower_count': 2, + 'towers': [ + {'name': 'eLTOOwatchtower', 'available': true}, + {'name': 'monitoring-vps', 'available': true}, + ], + }) + ); + } + + return (status: 404, body: 'unhandled: $method $path'); + } +} + +/// Dio adapter that routes every request through a [FakeLsp]. +class FakeLspAdapter implements HttpClientAdapter { + final FakeLsp lsp; + FakeLspAdapter(this.lsp); + + @override + Future fetch( + RequestOptions options, Stream? requestStream, Future? cancelFuture) async { + final r = lsp.route(options); // may throw DioException (transport error) + return ResponseBody.fromString( + r.body, + r.status, + headers: { + Headers.contentTypeHeader: ['application/json'], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +SoqLightning ln(FakeLsp lsp, {WatchtowerArming? wt}) { + final dio = Dio()..httpClientAdapter = FakeLspAdapter(lsp); + return SoqLightning(baseUrl: 'https://lsp.test', dio: dio, watchtower: wt); +} + +const _params = OpenChannelParams( + pubKeyHex: 'aaaaaaaa', + address: 'soq1qtest', + capacitySat: 100000000, +); + +void main() { + group('SoqLightning facade — lifecycle', () { + test('open → pay → close, conservation + state advance hold', () async { + final lsp = FakeLsp(); + final sdk = ln(lsp); + + final ch = await sdk.openChannel(_params); + expect(ch.isOpen, isTrue); + expect(ch.initiatorBalanceSat, 100000000); + expect(ch.peerBalanceSat, 0); + expect(ch.stateIndex, 0); + + final after = await sdk.pay(ch.channelId, 20000000); // move 0.2 SOQ + expect(after.stateIndex, 1); + expect(after.initiatorBalanceSat, 80000000); + expect(after.peerBalanceSat, 20000000); + expect(after.initiatorBalanceSat + after.peerBalanceSat, after.capacitySat); + + final closed = await sdk.close(ch.channelId); + expect(closed.accepted, isTrue); + expect(closed.settlementTxid, 'tx_settle'); + }); + + test('multiple pays advance state monotonically and conserve', () async { + final lsp = FakeLsp(); + final sdk = ln(lsp); + final ch = await sdk.openChannel(_params); + var c = ch; + for (var i = 1; i <= 3; i++) { + c = await sdk.pay(ch.channelId, 10000000); + expect(c.stateIndex, i); + expect(c.initiatorBalanceSat + c.peerBalanceSat, c.capacitySat); + } + expect(c.peerBalanceSat, 30000000); + }); + }); + + group('fundAndOpen — faucet cap clamp', () { + test('clamps an over-cap request down to max_channel_sat and opens', () async { + final lsp = FakeLsp(maxChannelSat: 100000000); // 1 SOQ cap + final sdk = ln(lsp); + // Ask for 10 SOQ — must clamp to 1 SOQ and still open. + final ch = await sdk.fundAndOpen(const OpenChannelParams( + pubKeyHex: 'bb', + address: 'soq1qx', + capacitySat: 1000000000, + )); + expect(ch.isOpen, isTrue); + expect(ch.capacitySat, 100000000); + }); + + test('throws a descriptive error when the faucet silently declines to open', () async { + // info advertises a 10 SOQ cap, but the faucet only honours 1 SOQ → silent decline. + // The SDK clamps to the advertised 1B (a no-op here), the faucet still declines. + final lsp = FakeLsp( + maxChannelSat: 1000000000, faucetHonorSat: 100000000, faucetSilentDecline: true); + final sdk = ln(lsp); + expect( + () => sdk.fundAndOpen(const OpenChannelParams( + pubKeyHex: 'bb', + address: 'soq1qx', + capacitySat: 1000000000, + )), + throwsA(isA().having( + (e) => e.message, 'message', contains('did not open a channel'))), + ); + }); + }); + + group('invariant guards', () { + test('pay rejects a non-positive amount', () async { + final sdk = ln(FakeLsp()); + final ch = await sdk.openChannel(_params); + expect(() => sdk.pay(ch.channelId, 0), throwsA(isA())); + }); + + test('pay rejects an amount over the initiator balance', () async { + final sdk = ln(FakeLsp()); + final ch = await sdk.openChannel(_params); + expect(() => sdk.pay(ch.channelId, 200000000), + throwsA(isA().having((e) => e.message, 'm', contains('insufficient')))); + }); + + test('pay detects a broken conservation invariant', () async { + final lsp = FakeLsp(corruptConservation: true); + final sdk = ln(lsp); + final ch = await sdk.openChannel(_params); + expect(() => sdk.pay(ch.channelId, 10000000), + throwsA(isA().having((e) => e.message, 'm', contains('not conserved')))); + }); + + test('pay detects a non-advancing state index', () async { + final lsp = FakeLsp(freezeState: true); + final sdk = ln(lsp); + final ch = await sdk.openChannel(_params); + expect(() => sdk.pay(ch.channelId, 10000000), + throwsA(isA().having((e) => e.message, 'm', contains('did not advance')))); + }); + }); + + group('close resilience', () { + test('treats a dropped response as success when the channel reads closed', () async { + final lsp = FakeLsp(closeDropsResponse: true); + final sdk = ln(lsp); + final ch = await sdk.openChannel(_params); + final closed = await sdk.close(ch.channelId); + expect(closed.accepted, isTrue); + expect(closed.rejectReason, contains('response dropped')); + }); + }); + + group('watchtower liveness', () { + test('assertTowersHealthy passes with the dual-tower deployment', () async { + final sdk = ln(FakeLsp()); + await sdk.assertTowersHealthy(2); // does not throw + final status = await sdk.towerStatus(); + expect(status.reachable, 2); + }); + + test('assertTowersHealthy throws when coverage is degraded', () async { + final sdk = ln(FakeLsp()); + expect(() => sdk.assertTowersHealthy(3), + throwsA(isA().having((e) => e.message, 'm', contains('degraded')))); + }); + }); + + group('transport', () { + test('non-2xx maps to LspError with status + message', () async { + final sdk = ln(FakeLsp()); + // Unknown channel → fake returns 404 "not found". + expect( + () => sdk.channel('ch_does_not_exist'), + throwsA(isA().having((e) => e.status, 'status', 404)), + ); + }); + }); + + group('swappable TX builder seam', () { + test('default placeholder builder is sent in the update request', () async { + final lsp = FakeLsp(); + final sdk = ln(lsp); + final ch = await sdk.openChannel(_params); + await sdk.pay(ch.channelId, 10000000); + expect(lsp.lastUpdate, isNotNull); + expect(lsp.lastUpdate!['update_tx_hex'], 'placeholder'); + expect(lsp.lastUpdate!['settlement_tx_hex'], 'placeholder'); + expect(lsp.lastUpdate!['ctv_hash'], 'placeholder'); + }); + + test('a custom builder (Opt3 stand-in) flows through unchanged', () async { + final lsp = FakeLsp(); + final dio = Dio()..httpClientAdapter = FakeLspAdapter(lsp); + final sdk = SoqLightning( + baseUrl: 'https://lsp.test', + dio: dio, + txBuilder: _RealishBuilder(), + ); + final ch = await sdk.openChannel(_params); + await sdk.pay(ch.channelId, 10000000); + expect(lsp.lastUpdate!['update_tx_hex'], '0xUPDATE'); + expect(lsp.lastUpdate!['ctv_hash'], '0xCTV'); + }); + }); +} + +/// Stand-in for the Opt3 DilithiumEltooBuilder: proves a non-placeholder builder swaps in +/// with zero facade change. +class _RealishBuilder implements UpdateTxBuilder { + @override + Future build(UpdateContext ctx) async => const UpdateTx( + updateTxHex: '0xUPDATE', + settlementTxHex: '0xSETTLE', + ctvHash: '0xCTV', + ); +} From d71219e0197dd7b7bb0f15766dad468a96182dc9 Mon Sep 17 00:00:00 2001 From: Odenrider <54157783+odenrider@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:19:24 -0600 Subject: [PATCH 03/11] lightning: add read-only live probe example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proves the Dart LspClient/facade talks to the real stagenet LSP at lsp.soqu.org (info/health/towers). Live run confirms: peer L2SOQ-PEER, eltoo-v1, max_channel_sat 100000000, dual towers 2/2 reachable — matching the TS SDK's live findings. Read-only by design (no faucet: rate-limited + consumes test SOQ). Co-Authored-By: Claude Opus 4.8 (1M context) --- example/live_probe.dart | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 example/live_probe.dart diff --git a/example/live_probe.dart b/example/live_probe.dart new file mode 100644 index 0000000..7996280 --- /dev/null +++ b/example/live_probe.dart @@ -0,0 +1,22 @@ +// Read-only live probe: proves the Dart LspClient talks to the real stagenet LSP. +// No faucet/open (rate-limited + consumes test SOQ) — just info/health/towers. +import 'package:soqushield_sdk/soqushield_sdk.dart'; + +Future main() async { + final ln = SoqLightning(baseUrl: 'https://lsp.soqu.org'); + final info = await ln.info(); + print('info: peer=${info['peer_name']} net=${info['network']} ' + 'maxChannelSat=${info['max_channel_sat']} proto=${info['protocol_version']}'); + try { + final h = await ln.health(); + print('health: $h'); + } catch (e) { + print('health: $e'); + } + final t = await ln.towerStatus(); + print('towers: ${t.reachable}/${t.towerCount} reachable, available=${t.available}'); + for (final tw in t.towers) { + print(' - ${tw.name}: available=${tw.available} ' + 'watched=${tw.status?.watchedChannels}'); + } +} From a7ed6055ef7c2881e96c76927b13373ab8062474 Mon Sep 17 00:00:00 2001 From: Odenrider <54157783+odenrider@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:42:20 -0600 Subject: [PATCH 04/11] lightning: add focused pure-Dart lightning.dart entrypoint Lets consumers (the SoquShield app) depend on the Lightning layer via package:soqushield_sdk/lightning.dart without pulling the package's native Dilithium FFI / wallet / RPC surface. Lightning is pure Dart over dio. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/lightning.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 lib/lightning.dart diff --git a/lib/lightning.dart b/lib/lightning.dart new file mode 100644 index 0000000..97fbcfa --- /dev/null +++ b/lib/lightning.dart @@ -0,0 +1,14 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// lightning.dart — focused public entrypoint for the Soqucoin Lightning (eLTOO) layer. +// +// Import this (instead of the full `soqushield_sdk.dart` barrel) when you only need the +// Lightning SDK and want to avoid pulling the package's Dilithium FFI / wallet / RPC +// surface. The Lightning layer is pure Dart over `dio` — no native dependencies. +// +// import 'package:soqushield_sdk/lightning.dart'; +// final ln = SoqLightning(baseUrl: 'https://lsp.soqu.org'); +library; + +export 'src/lightning/lightning.dart'; From 53e6f02edc781ccc6e3a9d5eb96a9b07103eada1 Mon Sep 17 00:00:00 2001 From: Odenrider <54157783+odenrider@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:38:31 -0600 Subject: [PATCH 05/11] =?UTF-8?q?eltoo(opt3):=20P0+P1=20=E2=80=94=20consen?= =?UTF-8?q?sus=20serialization=20pinned=20to=20node=20vectors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Start of the Opt3 DilithiumEltooBuilder MVP sprint (per SOQUSHIELD_OPT3_ELTOO_PORT_SCOPE). P0: copied the node-proven golden vectors (onchain_vectors.json, emitted by soq-signer's txbuilder) into the Dart SDK test tree. P1: lib/src/lightning/eltoo/serialization.dart — byte-for-byte port of onchain.ts's byte layer: u8/le16/le32/le64(BigInt)/concat, compactSize, sha256d, hex, the Tx/TxIn/TxOut/OutPoint model, serTxOut (Phase-4 byte-less CTxOut — NO nVisibility/nAssetType), serializeTx (BIP144 witness), and txid. Pinned byte-exact to the node vectors: serializeTxHex == rawTxHex and txid == V.txid, plus varint/le64 boundary checks and a Phase-4 byte-less serTxOut assertion. 8/8 new tests; full SDK suite 25/25; dart analyze clean. Strategy followed: the #1-risk component (the byte-less CTxOut serializer — the recurring root cause) is pinned to a node vector on day one. Next: P2 sighash (APO 0x42/0x41 + BIP143; the vector's sighashHex is the pin). Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/lightning/eltoo/serialization.dart | 215 +++++++++++++++++++++ test/eltoo/serialization_test.dart | 93 +++++++++ test/fixtures/onchain_vectors.json | 25 +++ 3 files changed, 333 insertions(+) create mode 100644 lib/src/lightning/eltoo/serialization.dart create mode 100644 test/eltoo/serialization_test.dart create mode 100644 test/fixtures/onchain_vectors.json diff --git a/lib/src/lightning/eltoo/serialization.dart b/lib/src/lightning/eltoo/serialization.dart new file mode 100644 index 0000000..15e43ae --- /dev/null +++ b/lib/src/lightning/eltoo/serialization.dart @@ -0,0 +1,215 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// serialization.dart — Opt3 consensus transaction serialization (P1). +// +// Byte-for-byte port of the node-proven TypeScript SDK (soq-lightning-sdk/src/onchain.ts + +// the shared serializers in channel.ts). This is the foundation the whole eLTOO builder +// stands on — every sighash, txid, and CTV hash is computed over these bytes, so a single +// wrong byte here breaks everything above it. +// +// 🔴 PHASE-4 CTxOut (the #1 recurring root cause): the consensus output serialization is +// value(LE64) ‖ compactSize(spk) ‖ spk — with NO nVisibility/nAssetType extension bytes. +// The stale public Go SDK still emits those bytes; we MUST NOT. Pinned to node vectors in +// test/serialization_test.dart (onchain_vectors.json: rawTxHex + txid byte-equal). +// +// SOURCE OF TRUTH: soqucoind C++ consensus (primitives/transaction.h, interpreter.cpp) via +// the node-pinned TS vectors. Witness-v1 send format verified byte-exact in the TS suite. + +import 'dart:typed_data'; +import 'package:crypto/crypto.dart' as crypto; + +// ── byte writers (mirror onchain.ts:36-58) ── + +/// Single byte (n & 0xff). +Uint8List u8(int n) => Uint8List.fromList([n & 0xff]); + +/// 2-byte little-endian. +Uint8List le16(int n) => Uint8List.fromList([n & 0xff, (n >> 8) & 0xff]); + +/// 4-byte little-endian (unsigned 32-bit; handles 0xffffffff). +Uint8List le32(int n) => Uint8List.fromList([ + n & 0xff, + (n >> 8) & 0xff, + (n >> 16) & 0xff, + (n >> 24) & 0xff, + ]); + +/// 8-byte little-endian from a BigInt (wraps to uint64). +Uint8List le64(BigInt v) { + final out = Uint8List(8); + var x = v.toUnsigned(64); + final mask = BigInt.from(0xff); + for (var i = 0; i < 8; i++) { + out[i] = (x & mask).toInt(); + x = x >> 8; + } + return out; +} + +/// Concatenate byte arrays. +Uint8List concatBytes(List parts) { + var n = 0; + for (final p in parts) { + n += p.length; + } + final out = Uint8List(n); + var o = 0; + for (final p in parts) { + out.setAll(o, p); + o += p.length; + } + return out; +} + +/// Bitcoin CompactSize varint: <0xfd → 1 byte; ≤0xffff → 0xfd+le16; ≤0xffffffff → 0xfe+le32. +Uint8List compactSize(int n) { + if (n < 0xfd) return u8(n); + if (n <= 0xffff) return concatBytes([u8(0xfd), le16(n)]); + if (n <= 0xffffffff) return concatBytes([u8(0xfe), le32(n)]); + throw ArgumentError('compactSize too large: $n'); +} + +/// SHA256d — double SHA256. +Uint8List sha256d(Uint8List b) => + Uint8List.fromList(crypto.sha256.convert(crypto.sha256.convert(b).bytes).bytes); + +/// Single SHA256. +Uint8List sha256Once(Uint8List b) => Uint8List.fromList(crypto.sha256.convert(b).bytes); + +// ── hex helpers ── + +const _hexDigits = '0123456789abcdef'; + +String toHex(Uint8List b) { + final sb = StringBuffer(); + for (final x in b) { + sb.write(_hexDigits[(x >> 4) & 0xf]); + sb.write(_hexDigits[x & 0xf]); + } + return sb.toString(); +} + +Uint8List fromHex(String h) { + if (h.length.isOdd) throw ArgumentError('hex length must be even'); + final out = Uint8List(h.length ~/ 2); + for (var i = 0; i < out.length; i++) { + out[i] = int.parse(h.substring(i * 2, i * 2 + 2), radix: 16); + } + return out; +} + +Uint8List reversed(Uint8List b) => Uint8List.fromList(b.reversed.toList()); + +// ── transaction model (mirror onchain.ts:104-108 / channel.ts OutPoint) ── + +/// A transaction outpoint. [txid] is the 32-byte INTERNAL byte order (not display-reversed). +class OutPoint { + final Uint8List txid; + final int n; + const OutPoint(this.txid, this.n); + + /// Build from a display-order txid hex (reverses to internal order). + factory OutPoint.fromTxidHex(String txidHex, int n) { + final b = fromHex(txidHex); + if (b.length != 32) throw ArgumentError('txid must be 32 bytes, got ${b.length}'); + return OutPoint(reversed(b), n); + } +} + +/// A transaction input. [scriptPubKey] is the input's scriptPubKey (the BIP143 scriptCode); +/// the on-wire scriptSig is always empty for witness inputs. [witness] is the witness stack. +class TxIn { + final OutPoint prevout; + final int sequence; + final Uint8List scriptPubKey; + final List? witness; + const TxIn({ + required this.prevout, + required this.sequence, + required this.scriptPubKey, + this.witness, + }); +} + +/// A transaction output. Phase-4 byte-less: only value + scriptPubKey are serialized. +class TxOut { + final BigInt value; // satoshis + final Uint8List scriptPubKey; + const TxOut(this.value, this.scriptPubKey); +} + +/// A transaction. +class Tx { + final int version; + final int locktime; + final List vin; + final List vout; + const Tx({ + required this.version, + required this.locktime, + required this.vin, + required this.vout, + }); +} + +// ── consensus serializers (mirror onchain.ts:110-219) ── + +/// CTxOut consensus serializer — Phase-4 byte-less: value(LE64) ‖ compactSize(spk) ‖ spk. +/// 🔴 NO nVisibility/nAssetType bytes. +Uint8List serTxOut(TxOut o) => + concatBytes([le64(o.value), compactSize(o.scriptPubKey.length), o.scriptPubKey]); + +/// Outpoint serialization: txid(32) ‖ le32(n). +Uint8List serOutpoint(TxIn i) => concatBytes([i.prevout.txid, le32(i.prevout.n)]); + +/// Full transaction serialization (BIP144 witness). Marker/flag + witness stacks are emitted +/// only when at least one input carries a witness; scriptSig is always empty (compactSize(0)). +Uint8List serializeTx(Tx tx) { + final hasWitness = tx.vin.any((i) => (i.witness?.isNotEmpty ?? false)); + final parts = [le32(tx.version)]; + if (hasWitness) { + parts.add(u8(0x00)); // marker + parts.add(u8(0x01)); // flag + } + parts.add(compactSize(tx.vin.length)); + for (final i in tx.vin) { + parts.add(serOutpoint(i)); + parts.add(compactSize(0)); // empty scriptSig + parts.add(le32(i.sequence)); + } + parts.add(compactSize(tx.vout.length)); + for (final o in tx.vout) { + parts.add(serTxOut(o)); + } + if (hasWitness) { + for (final i in tx.vin) { + final w = i.witness ?? const []; + parts.add(compactSize(w.length)); + for (final item in w) { + parts.add(compactSize(item.length)); + parts.add(item); + } + } + } + parts.add(le32(tx.locktime)); + return concatBytes(parts); +} + +String serializeTxHex(Tx tx) => toHex(serializeTx(tx)); + +/// txid: SHA256d of the NON-witness serialization, reversed to display order. +String txid(Tx tx) { + final parts = [le32(tx.version), compactSize(tx.vin.length)]; + for (final i in tx.vin) { + parts.add(serOutpoint(i)); + parts.add(compactSize(0)); + parts.add(le32(i.sequence)); + } + parts.add(compactSize(tx.vout.length)); + for (final o in tx.vout) { + parts.add(serTxOut(o)); + } + parts.add(le32(tx.locktime)); + return toHex(reversed(sha256d(concatBytes(parts)))); +} diff --git a/test/eltoo/serialization_test.dart b/test/eltoo/serialization_test.dart new file mode 100644 index 0000000..2f343bb --- /dev/null +++ b/test/eltoo/serialization_test.dart @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// serialization_test.dart — Opt3 P1: pins the Dart consensus serializer to the SAME +// node-proven golden vectors (test/fixtures/onchain_vectors.json) the TS port is pinned to. +// These vectors were emitted by soq-signer's node-proven txbuilder (Phase-4 byte-less CTxOut). +// If rawTxHex + txid are byte-equal here, the serialization foundation is correct. + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:soqushield_sdk/src/lightning/eltoo/serialization.dart'; +import 'package:test/test.dart'; + +void main() { + final v = jsonDecode( + File('test/fixtures/onchain_vectors.json').readAsStringSync()) as Map; + + // Deterministic fixture inputs (onchain.test.mjs:23-25). + final senderPub = Uint8List.fromList(List.generate(1312, (i) => i % 256)); + final fillerSig = Uint8List.fromList(List.filled(2420, 0xab)); + + // Build the identical tx the node generator built (onchain.test.mjs buildFixtureTx + + // the witness assembly), using the vector's scriptPubKeys directly so this test isolates + // the serializer (address/script derivation is a later phase). + Tx buildFixtureTx() { + final witness = [ + concatBytes([fillerSig, u8(0x01)]), // sig ‖ SIGHASH_ALL → 2421 + concatBytes([u8(0x00), senderPub]), // 0x00 ‖ pubkey → 1313 + ]; + return Tx( + version: v['txVersion'] as int, + locktime: 0, + vin: [ + TxIn( + prevout: OutPoint.fromTxidHex(v['utxoTxID'] as String, v['utxoVout'] as int), + sequence: 0xffffffff, + scriptPubKey: fromHex(v['senderSPKHex'] as String), + witness: witness, + ), + ], + vout: [ + TxOut(BigInt.from(v['sendAmount'] as int), fromHex(v['recipSPKHex'] as String)), + TxOut(BigInt.from(v['out1Value'] as int), fromHex(v['senderSPKHex'] as String)), + ], + ); + } + + group('Opt3 P1 — consensus serialization (node-pinned)', () { + test('fixture witness lengths match (2421 / 1313)', () { + final tx = buildFixtureTx(); + final w = tx.vin[0].witness!; + expect(w[0].length, v['witness0Len']); + expect(w[1].length, v['witness1Len']); + }); + + test('full BIP144 serialization is byte-exact (rawTxHex)', () { + expect(serializeTxHex(buildFixtureTx()), v['rawTxHex']); + }); + + test('txid is byte-exact (non-witness sha256d, display order)', () { + expect(txid(buildFixtureTx()), v['txid']); + }); + }); + + group('Opt3 P1 — byte-writer + varint primitives', () { + test('compactSize boundaries', () { + expect(toHex(compactSize(0x00)), '00'); + expect(toHex(compactSize(0xfc)), 'fc'); + expect(toHex(compactSize(0xfd)), 'fdfd00'); + expect(toHex(compactSize(0xffff)), 'fdffff'); + expect(toHex(compactSize(0x10000)), 'fe00000100'); + }); + + test('le64 wraps to uint64 little-endian', () { + expect(toHex(le64(BigInt.from(0x0102030405060708))), '0807060504030201'); + expect(toHex(le64(BigInt.zero)), '0000000000000000'); + }); + + test('le32 handles 0xffffffff (default sequence)', () { + expect(toHex(le32(0xffffffff)), 'ffffffff'); + }); + + test('serTxOut is Phase-4 byte-less: value ‖ compactSize(spk) ‖ spk', () { + final spk = fromHex(v['recipSPKHex'] as String); // 34 bytes (0x22) + final out = serTxOut(TxOut(BigInt.from(v['sendAmount'] as int), spk)); + // 8 (value) + 1 (compactSize 0x22) + 34 (spk) = 43; NO extension bytes. + expect(out.length, 8 + 1 + spk.length); + expect(out[8], spk.length); // single-byte compactSize, no nAssetType after value + }); + }); +} diff --git a/test/fixtures/onchain_vectors.json b/test/fixtures/onchain_vectors.json new file mode 100644 index 0000000..f48d241 --- /dev/null +++ b/test/fixtures/onchain_vectors.json @@ -0,0 +1,25 @@ +{ + "estimatedFee": 10720, + "feeRate": 10, + "fillerSigHex": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "hrp": "ssq", + "numOutputs": 2, + "out0Value": 500000000, + "out1Value": 499989280, + "rawTxHex": "02000000000101b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a10000000000ffffffff020065cd1d00000000225120cb61cd85733e4a96eedc9e8b2a2602e067c5428d900fa045f3273f5788d6c081203bcd1d000000002251200305c2e8771f9da8bdea86947336b21ff9475fa343831b72edcc36c8e6396a8a02fd7509abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab01fd210500000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f00000000", + "recipAddr": "ssq1pedsumptn8e9fdmkun69j5fszupnu2s5djq86q30nyul40zxkczqsvdwcuh", + "recipPubHex": "0708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223242526", + "recipSPKHex": "5120cb61cd85733e4a96eedc9e8b2a2602e067c5428d900fa045f3273f5788d6c081", + "sendAmount": 500000000, + "senderAddr": "ssq1pqvzu96rhr7w63002s628xd4jrlu5whargwp3kuhdesmv3e3ed29qwgqrfv", + "senderPubHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "senderSPKHex": "51200305c2e8771f9da8bdea86947336b21ff9475fa343831b72edcc36c8e6396a8a", + "sighashHex": "ffe02684b91221accb9340eaa6aceb828846e24476e95acd8ac3484a19a1eb5d", + "txVersion": 2, + "txid": "8675c44620f544462eac3b55c6aa68df199f2bdb700de584f067ddfe42a525a9", + "utxoTxID": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "utxoValue": 1000000000, + "utxoVout": 0, + "witness0Len": 2421, + "witness1Len": 1313 +} From b3764e9379abde77386988e79c79686e5c905d3f Mon Sep 17 00:00:00 2001 From: Odenrider <54157783+odenrider@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:57:49 -0600 Subject: [PATCH 06/11] =?UTF-8?q?eltoo(opt3):=20P2=20=E2=80=94=20APO=20+?= =?UTF-8?q?=20BIP143=20sighashing,=20node-pinned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/src/lightning/eltoo/sighash.dart — byte-for-byte port of channel.ts apoSighash + sighashAll: APO 0x42 (ANYPREVOUTANYSCRIPT, empty scriptField) + 0x41 (ANYPREVOUT, commits scriptCode), EMPTY_OUTPOINT/zeroed prevout-sequence-hashPrevouts-hashSequence, BIP143 SIGHASH_ALL, plus signApoWitness/signAllWitness (sig ‖ hashType) over an injected ML-DSA signer. Pinned byte-exact to node vectors: • APO 0x42 / 0x41 digests ← apo_ctv_vectors.json (b1_dump_vectors, Phase-4 byte-less) • BIP143 SIGHASH_ALL digest ← onchain_vectors.json sighashHex Invariance (channel.test.mjs): 0x42 ignores prevout (rebindable), 0x42≠0x41 (scriptCode commitment), 0x42 commits outputs. 7/7 new tests; full SDK suite green; analyze clean. This is the eLTOO core — the APO-0x42 digest the update TX is signed over. Next: P3 CTV (ctv_hash vector already in apo_ctv_vectors.json). Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/lightning/eltoo/sighash.dart | 126 +++++++++++++++++++++++++++ test/eltoo/sighash_test.dart | 110 +++++++++++++++++++++++ test/fixtures/apo_ctv_vectors.json | 15 ++++ 3 files changed, 251 insertions(+) create mode 100644 lib/src/lightning/eltoo/sighash.dart create mode 100644 test/eltoo/sighash_test.dart create mode 100644 test/fixtures/apo_ctv_vectors.json diff --git a/lib/src/lightning/eltoo/sighash.dart b/lib/src/lightning/eltoo/sighash.dart new file mode 100644 index 0000000..4d44957 --- /dev/null +++ b/lib/src/lightning/eltoo/sighash.dart @@ -0,0 +1,126 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// sighash.dart — Opt3 P2: BIP118 APO + BIP143 sighashing. +// +// Byte-for-byte port of soq-lightning-sdk/src/channel.ts (apoSighash, sighashAll) — the eLTOO +// core. The APO-0x42 digest is what the update TX is signed over; getting the preimage +// field-exact is the difference between a channel that disputes correctly and one that doesn't. +// +// Pinned to node vectors in test/eltoo/sighash_test.dart: +// • APO 0x42 / 0x41 digests ← apo_ctv_vectors.json (b1_dump_vectors, Phase-4 byte-less) +// • BIP143 SIGHASH_ALL digest ← onchain_vectors.json (sighashHex) + +import 'dart:typed_data'; + +import 'serialization.dart'; + +/// BIP118 hash-type bytes. +const int sighashAnyprevout = 0x41; // ANYPREVOUT — commits scriptCode +const int sighashAnyprevoutAnyscript = 0x42; // ANYPREVOUTANYSCRIPT — ignores scriptCode +const int sighashAll = 0x01; // standard BIP143 + +/// COutPoint::SetNull → 32 zero bytes ‖ n = 0xFFFFFFFF (channel.ts:220). +final Uint8List _emptyOutpoint = + concatBytes([Uint8List(32), Uint8List.fromList([0xff, 0xff, 0xff, 0xff])]); + +/// Signs a 32-byte message with ML-DSA-44 and returns the 2420-byte signature. +/// (Inject `DilithiumNative.instance.sign` here; kept abstract so this layer stays pure.) +typedef MlDsaSign = Uint8List Function(Uint8List message, Uint8List secretKey); + +/// BIP118 APO sighash (channel.ts:241-267). For 0x42 the scriptCode is serialized EMPTY (the +/// signature rebinds across states/scripts); for 0x41 the real [scriptCode] is committed. +/// hashPrevouts / hashSequence / the outpoint / nSequence are all zeroed (rebindable). Returns +/// the 32-byte SHA256d digest ML-DSA signs. +Uint8List apoSighash( + Uint8List scriptCode, + Tx tx, + int nIn, + int hashType, + BigInt amountSat, +) { + final base = hashType & 0x7f; + if (base != sighashAnyprevout && base != sighashAnyprevoutAnyscript) { + throw ArgumentError('apoSighash: hashType must be 0x41 or 0x42'); + } + if (hashType & 0x80 != 0) { + throw ArgumentError('ANYONECANPAY + APO is invalid (SOQ-COV-003)'); + } + + final zero32 = Uint8List(32); + final hashOutputs = sha256d(concatBytes(tx.vout.map(serTxOut).toList())); + // ANYPREVOUT commits scriptCode; ANYPREVOUTANYSCRIPT serializes an empty script. + final scriptField = base == sighashAnyprevout + ? concatBytes([compactSize(scriptCode.length), scriptCode]) + : u8(0x00); // empty script → compactSize(0) + + final preimage = concatBytes([ + le32(tx.version), + zero32, // hashPrevouts = 0 + zero32, // hashSequence = 0 + _emptyOutpoint, // outpoint zeroed + scriptField, + le64(amountSat), + le32(0), // nSequence = 0 + hashOutputs, + le32(tx.locktime), + le32(hashType), // nHashType as a 4-byte int + ]); + return sha256d(preimage); +} + +/// Witness signature element for an APO spend: ML-DSA-44 sig (2420) ‖ hashType byte (channel.ts:271). +Uint8List signApoWitness( + Uint8List scriptCode, + Tx tx, + int nIn, + int hashType, + BigInt amountSat, + Uint8List secretKey, + MlDsaSign sign, +) { + final digest = apoSighash(scriptCode, tx, nIn, hashType, amountSat); + final sig = sign(digest, secretKey); + if (sig.length != 2420) { + throw StateError('expected 2420-byte ML-DSA sig, got ${sig.length}'); + } + return concatBytes([sig, u8(hashType)]); +} + +/// BIP143 witness-v0 sighash for SIGHASH_ALL (channel.ts:288-306). Unlike the eLTOO update this +/// commits to the real prevout/sequence (a fixed-output spend, no rebinding) — used by HTLC +/// SUCCESS/TIMEOUT claims and the simple-send path. [scriptCode] is the input's scriptPubKey. +Uint8List bip143SighashAll(Uint8List scriptCode, Tx tx, int nIn, BigInt amountSat) { + final hashPrevouts = sha256d(concatBytes(tx.vin.map(serOutpoint).toList())); + final hashSequence = sha256d(concatBytes(tx.vin.map((i) => le32(i.sequence)).toList())); + final hashOutputs = sha256d(concatBytes(tx.vout.map(serTxOut).toList())); + final preimage = concatBytes([ + le32(tx.version), + hashPrevouts, + hashSequence, + serOutpoint(tx.vin[nIn]), + compactSize(scriptCode.length), scriptCode, + le64(amountSat), + le32(tx.vin[nIn].sequence), + hashOutputs, + le32(tx.locktime), + le32(sighashAll), + ]); + return sha256d(preimage); +} + +/// Witness signature element for a SIGHASH_ALL claim: ML-DSA-44 sig (2420) ‖ 0x01. +Uint8List signAllWitness( + Uint8List scriptCode, + Tx tx, + int nIn, + BigInt amountSat, + Uint8List secretKey, + MlDsaSign sign, +) { + final sig = sign(bip143SighashAll(scriptCode, tx, nIn, amountSat), secretKey); + if (sig.length != 2420) { + throw StateError('expected 2420-byte ML-DSA sig, got ${sig.length}'); + } + return concatBytes([sig, u8(sighashAll)]); +} diff --git a/test/eltoo/sighash_test.dart b/test/eltoo/sighash_test.dart new file mode 100644 index 0000000..efbb542 --- /dev/null +++ b/test/eltoo/sighash_test.dart @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// sighash_test.dart — Opt3 P2: pins APO (0x41/0x42) + BIP143 sighashing to node vectors and +// asserts the eLTOO invariance properties (channel.test.mjs). + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:soqushield_sdk/src/lightning/eltoo/serialization.dart'; +import 'package:soqushield_sdk/src/lightning/eltoo/sighash.dart'; +import 'package:test/test.dart'; + +void main() { + final v = jsonDecode( + File('test/fixtures/apo_ctv_vectors.json').readAsStringSync()) as Map; + final oc = jsonDecode( + File('test/fixtures/onchain_vectors.json').readAsStringSync()) as Map; + + // The APO vector tx: version 2, locktime 1, one input (prevout in internal byte order), + // one output. apoSighash ignores the input's scriptPubKey, so it's empty here. + Tx buildApoTx({OutPoint? prevout, int? vout0Value}) { + return Tx( + version: v['version'] as int, + locktime: v['locktime'] as int, + vin: [ + TxIn( + prevout: prevout ?? + OutPoint(fromHex(v['vin0_prevout_hash'] as String), v['vin0_prevout_n'] as int), + sequence: v['vin0_sequence'] as int, + scriptPubKey: Uint8List(0), + ), + ], + vout: [ + TxOut(BigInt.from(vout0Value ?? v['vout0_value'] as int), + fromHex(v['vout0_scriptpubkey_hex'] as String)), + ], + ); + } + + final amount = BigInt.from(v['amount_sat'] as int); + + group('Opt3 P2 — APO sighash (node-pinned)', () { + test('APO 0x42 digest matches node (scriptCode ignored → empty)', () { + final got = toHex( + apoSighash(Uint8List(0), buildApoTx(), 0, sighashAnyprevoutAnyscript, amount)); + expect(got, v['digest_apo_0x42']); + }); + + test('APO 0x41 digest matches node (commits scriptCode)', () { + final got = toHex(apoSighash( + fromHex(v['scriptcode_hex'] as String), buildApoTx(), 0, sighashAnyprevout, amount)); + expect(got, v['digest_apo_0x41']); + }); + + test('rejects ANYONECANPAY + APO and non-APO hash types', () { + expect(() => apoSighash(Uint8List(0), buildApoTx(), 0, 0x42 | 0x80, amount), + throwsArgumentError); + expect(() => apoSighash(Uint8List(0), buildApoTx(), 0, 0x01, amount), throwsArgumentError); + }); + }); + + group('Opt3 P2 — BIP143 SIGHASH_ALL (node-pinned, onchain_vectors)', () { + test('digest matches node sighashHex (scriptCode = input scriptPubKey)', () { + final senderSpk = fromHex(oc['senderSPKHex'] as String); + final tx = Tx( + version: oc['txVersion'] as int, + locktime: 0, + vin: [ + TxIn( + prevout: OutPoint.fromTxidHex(oc['utxoTxID'] as String, oc['utxoVout'] as int), + sequence: 0xffffffff, + scriptPubKey: senderSpk, + ), + ], + vout: [ + TxOut(BigInt.from(oc['sendAmount'] as int), fromHex(oc['recipSPKHex'] as String)), + TxOut(BigInt.from(oc['out1Value'] as int), senderSpk), + ], + ); + expect(toHex(bip143SighashAll(senderSpk, tx, 0, BigInt.from(oc['utxoValue'] as int))), + oc['sighashHex']); + }); + }); + + group('Opt3 P2 — eLTOO invariance (channel.test.mjs)', () { + test('APO 0x42 IGNORES the prevout (rebindable across states)', () { + final a = apoSighash(Uint8List(0), buildApoTx(), 0, sighashAnyprevoutAnyscript, amount); + final differentPrevout = OutPoint(Uint8List.fromList(List.filled(32, 0x99)), 7); + final b = apoSighash(Uint8List(0), buildApoTx(prevout: differentPrevout), 0, + sighashAnyprevoutAnyscript, amount); + expect(toHex(a), toHex(b)); // prevout change must NOT move the digest + }); + + test('0x42 (ignores script) and 0x41 (commits script) differ', () { + final d42 = apoSighash(Uint8List(0), buildApoTx(), 0, sighashAnyprevoutAnyscript, amount); + final d41 = apoSighash( + fromHex(v['scriptcode_hex'] as String), buildApoTx(), 0, sighashAnyprevout, amount); + expect(toHex(d42), isNot(toHex(d41))); + }); + + test('APO 0x42 COMMITS to outputs (amount change moves the digest)', () { + final a = apoSighash(Uint8List(0), buildApoTx(), 0, sighashAnyprevoutAnyscript, amount); + final b = apoSighash(Uint8List(0), buildApoTx(vout0Value: 9999999999), 0, + sighashAnyprevoutAnyscript, amount); + expect(toHex(a), isNot(toHex(b))); + }); + }); +} diff --git a/test/fixtures/apo_ctv_vectors.json b/test/fixtures/apo_ctv_vectors.json new file mode 100644 index 0000000..5e264be --- /dev/null +++ b/test/fixtures/apo_ctv_vectors.json @@ -0,0 +1,15 @@ +{ + "_source": "soq-lightning-sdk/test/vector.test.mjs — node-pinned (b1_dump_vectors, Phase-4 byte-less, cross-checked DL-PHASE4-VECTOR-REGEN.md)", + "version": 2, + "locktime": 1, + "vin0_prevout_hash": "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100", + "vin0_prevout_n": 3, + "vin0_sequence": 0, + "vout0_value": 10000000000, + "vout0_scriptpubkey_hex": "51", + "scriptcode_hex": "76a914aabbccddeeff00112233445566778899aabbccdd88ac", + "amount_sat": 10000000000, + "digest_apo_0x42": "4c8c1f5ee5be7a8d654f8bbe281b469423d3cf9b180319baf638b288579084ce", + "digest_apo_0x41": "3e5752e00dd14ef60bf41fce71ab27ea96be2d3ec9e6d331040e815a29d51edb", + "ctv_hash": "4a0575860c4e73b17c3e5ba537b094cdf9b2c9b9d866032c59b7ff0a2db8d396" +} From 6ae4018b25c5662d6125034e25d0eb7108fd366e Mon Sep 17 00:00:00 2001 From: Odenrider <54157783+odenrider@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:59:18 -0600 Subject: [PATCH 07/11] =?UTF-8?q?eltoo(opt3):=20P3=20=E2=80=94=20BIP119=20?= =?UTF-8?q?CTV=20template=20hash,=20node-pinned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/src/lightning/eltoo/ctv.dart — byte-for-byte port of channel.ts ctvHash / serTxOutCtv. Two pinned subtleties: serTxOutCtv uses an LE32 script-length prefix (NOT consensus compactSize), and ctvHash is a SINGLE SHA256 (NOT sha256d); scriptSigsHash omitted for segwit. Pinned to apo_ctv_vectors.json ctv_hash; tamper-sensitivity + LE32-length assertions. 3/3 new tests; full SDK suite green; analyze clean. P0-P3 complete: the full consensus-hashing foundation (serialization, APO/BIP143 sighash, CTV) is node-pinned. Next: P4 keyhash 2-of-2 funding (keyhash.test.mjs vectors). Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/lightning/eltoo/ctv.dart | 37 ++++++++++++++++++++++ test/eltoo/ctv_test.dart | 53 ++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 lib/src/lightning/eltoo/ctv.dart create mode 100644 test/eltoo/ctv_test.dart diff --git a/lib/src/lightning/eltoo/ctv.dart b/lib/src/lightning/eltoo/ctv.dart new file mode 100644 index 0000000..70d6400 --- /dev/null +++ b/lib/src/lightning/eltoo/ctv.dart @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// ctv.dart — Opt3 P3: BIP119 CheckTemplateVerify template hash. +// +// Byte-for-byte port of channel.ts ctvHash / serTxOutCtv. The eLTOO settlement TX is committed +// by this hash, so it must agree with the node's byte-less C++ CTV hashing exactly. +// +// Two easy-to-conflate subtleties, both pinned to node vectors (test/eltoo/ctv_test.dart): +// • serTxOutCtv uses an **LE32** script-length prefix — NOT the consensus compactSize. +// • ctvHash is a **single** SHA256 — NOT sha256d. +// • scriptSigsHash is included ONLY if some input has a non-empty scriptSig; segwit eLTOO +// spends have empty scriptSigs (our TxIn carries none), so it is omitted. + +import 'dart:typed_data'; + +import 'serialization.dart'; + +/// CTV output serializer (Phase-4 byte-less): value(LE64) ‖ scriptlen(LE32) ‖ script. +/// Distinct from [serTxOut] (consensus), which uses compactSize for the length. +Uint8List serTxOutCtv(TxOut o) => + concatBytes([le64(o.value), le32(o.scriptPubKey.length), o.scriptPubKey]); + +/// BIP119 DefaultCheckTemplateVerifyHash (single SHA256). [nIn] is the input index this +/// template is bound to. scriptSigsHash is omitted (segwit spends have empty scriptSigs). +Uint8List ctvHash(Tx tx, int nIn) { + final parts = [le32(tx.version), le32(tx.locktime)]; + + // scriptSig is always empty for segwit eLTOO spends → scriptSigsHash branch omitted. + parts.add(le32(tx.vin.length)); + parts.add(sha256Once(concatBytes(tx.vin.map((i) => le32(i.sequence)).toList()))); + parts.add(le32(tx.vout.length)); + parts.add(sha256Once(concatBytes(tx.vout.map(serTxOutCtv).toList()))); + parts.add(le32(nIn)); + + return sha256Once(concatBytes(parts)); // single SHA256, NOT double +} diff --git a/test/eltoo/ctv_test.dart b/test/eltoo/ctv_test.dart new file mode 100644 index 0000000..7b0c31b --- /dev/null +++ b/test/eltoo/ctv_test.dart @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// ctv_test.dart — Opt3 P3: pins the BIP119 CTV template hash to the node vector and asserts +// tamper-sensitivity + the LE32-vs-compactSize length distinction. + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:soqushield_sdk/src/lightning/eltoo/serialization.dart'; +import 'package:soqushield_sdk/src/lightning/eltoo/ctv.dart'; +import 'package:test/test.dart'; + +void main() { + final v = jsonDecode( + File('test/fixtures/apo_ctv_vectors.json').readAsStringSync()) as Map; + + Tx buildTx({int? vout0Value}) => Tx( + version: v['version'] as int, + locktime: v['locktime'] as int, + vin: [ + TxIn( + prevout: + OutPoint(fromHex(v['vin0_prevout_hash'] as String), v['vin0_prevout_n'] as int), + sequence: v['vin0_sequence'] as int, + scriptPubKey: Uint8List(0), + ), + ], + vout: [ + TxOut(BigInt.from(vout0Value ?? v['vout0_value'] as int), + fromHex(v['vout0_scriptpubkey_hex'] as String)), + ], + ); + + group('Opt3 P3 — CTV template hash (node-pinned)', () { + test('ctvHash matches node ctv_hash', () { + expect(toHex(ctvHash(buildTx(), 0)), v['ctv_hash']); + }); + + test('tamper-sensitive: changing an output value moves the hash', () { + expect(toHex(ctvHash(buildTx(), 0)), isNot(toHex(ctvHash(buildTx(vout0Value: 9999999999), 0)))); + }); + + test('serTxOutCtv uses an LE32 length prefix (distinct from consensus compactSize)', () { + final spk = fromHex(v['vout0_scriptpubkey_hex'] as String); // "51" → 1 byte + final out = serTxOutCtv(TxOut(BigInt.from(v['vout0_value'] as int), spk)); + // 8 (value) + 4 (LE32 len) + 1 (script) = 13; the length is 4 bytes, not 1. + expect(out.length, 8 + 4 + spk.length); + expect(out.sublist(8, 12), [spk.length, 0, 0, 0]); // LE32(1) + }); + }); +} From b4ff7f520e5316278ab866b071bcfbba512baa95 Mon Sep 17 00:00:00 2001 From: Odenrider <54157783+odenrider@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:43:53 -0600 Subject: [PATCH 08/11] =?UTF-8?q?eltoo(opt3):=20P4=20=E2=80=94=20key-commi?= =?UTF-8?q?tted=202-of-2=20funding=20(OP=5FCHECKDILITHIUMKEYHASH)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/src/lightning/eltoo/script.dart — OP constants, pushData, scriptNum, p2wsh/p2wshV6, dilithiumWitnessPubKey, p2wshV6Witness (port of channel.ts:52-142). lib/src/lightning/eltoo/keyhash.dart — the SOQ-COV-013 funding suite (port of channel.ts:349-504): dilithiumKeyHash (single SHA256 of the raw 1312-byte key), 1/2-of-2 committed scripts, v6 witnesses, signForKeyhash dispatch, keyhashFunding2of2, partial sign + ORDER-ROBUST combine, the single-operator convenience. Byte-exact construction pins (keyhash = single SHA256, 69-byte witnessScript offsets, v6 funding scriptPubKey, [sigA,pubA,sigB,pubB,ws,trailing] layout, order-invariant combine, wrong-party + mixed-hashType + substitution rejection). 9/9 new tests with deterministic pubkey patterns + a deterministic mock signer; full SDK suite green; analyze clean. NOT pinned here (separate workstream): the node committed_sdk_crossvector byte-match for keys from seeds 0x11/0x22 — needs @noble↔pqcrystals ML-DSA keygen interop + the native FFI (not loadable in pure dart test). Real sig verification rides on that. P0-P4 complete: serialization + APO/BIP143 sighash + CTV + keyhash funding, all node-pinned. Next: P5 eltoo update/settlement scripts + DilithiumEltooBuilder, then P6 stagenet e2e. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/lightning/eltoo/keyhash.dart | 217 +++++++++++++++++++++++++++ lib/src/lightning/eltoo/script.dart | 90 +++++++++++ test/eltoo/keyhash_test.dart | 150 ++++++++++++++++++ 3 files changed, 457 insertions(+) create mode 100644 lib/src/lightning/eltoo/keyhash.dart create mode 100644 lib/src/lightning/eltoo/script.dart create mode 100644 test/eltoo/keyhash_test.dart diff --git a/lib/src/lightning/eltoo/keyhash.dart b/lib/src/lightning/eltoo/keyhash.dart new file mode 100644 index 0000000..ee09e3b --- /dev/null +++ b/lib/src/lightning/eltoo/keyhash.dart @@ -0,0 +1,217 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// keyhash.dart — Opt3 P4: OP_CHECKDILITHIUMKEYHASH (SOQ-COV-013) key-committed 2-of-2 funding. +// +// Byte-for-byte port of channel.ts:349-504. This is the REAL key-binding primitive for eLTOO +// funding: the funding output commits SHA256(pubkey) for BOTH parties as script literals, so +// each party must co-sign with their SPECIFIC Dilithium key (CSFS authorizes a witness-supplied +// key and does not bind — a thief could substitute their own). Node-pinned against +// dilithium_keyhash_committed_tests.cpp (committed_sdk_crossvector). +// +// Pinned in test/eltoo/keyhash_test.dart: the keyhash (single SHA256), the 69-byte witnessScript +// layout + offsets, the v6 funding scriptPubKey, the 2-of-2 witness ordering (B checked first), +// order-robust combine, substitution + mixed-hashType rejection. (Real ML-DSA verification of +// the sigs is the separately-gated @noble↔pqcrystals interop workstream — see vector_mldsa.) + +import 'dart:typed_data'; + +import 'script.dart'; +import 'serialization.dart'; +import 'sighash.dart'; + +/// SHA256(rawPubKey) — the 32-byte committed keyhash. SINGLE SHA256 (matches the handler's +/// CSHA256), NOT sha256d. [rawPubKey] is the 1312-byte ML-DSA-44 key with NO 0x00 prefix. +Uint8List dilithiumKeyHash(Uint8List rawPubKey) { + if (rawPubKey.length != 1312) { + throw ArgumentError('expected 1312-byte ML-DSA pubkey, got ${rawPubKey.length}'); + } + return sha256Once(rawPubKey); +} + +/// Single-key committed script: ` OP_CHECKDILITHIUMKEYHASH OP_1`. +Uint8List dilithiumKeyhashScript(Uint8List rawPubKey) => concatBytes([ + pushData(dilithiumKeyHash(rawPubKey)), + u8(Op.checkdilithiumkeyhash), + u8(Op.opOne), + ]); + +/// Key-committed 2-of-2 script: ` OP_CDKH OP_CDKH OP_1`. pubB is committed +/// FIRST because the first opcode checks the TOP eval item, and the witness puts pubB on top. +Uint8List dilithiumKeyhash2of2Script(Uint8List pubA, Uint8List pubB) => concatBytes([ + pushData(dilithiumKeyHash(pubB)), u8(Op.checkdilithiumkeyhash), + pushData(dilithiumKeyHash(pubA)), u8(Op.checkdilithiumkeyhash), + u8(Op.opOne), + ]); + +/// v6 witness for a single-key committed spend. Eval items: [sig, pub] (pub on top). +List dilithiumKeyhashWitness( + Uint8List sig, + Uint8List rawPubKey, + Uint8List trailingPubKey, { + Uint8List? witnessScript, +}) => + p2wshV6Witness( + [sig, rawPubKey], witnessScript ?? dilithiumKeyhashScript(rawPubKey), trailingPubKey); + +/// v6 witness for the key-committed 2-of-2. Eval order: the first OP_CDKH consumes the TOP +/// triple, so satisfaction = [sigA, pubA, sigB, pubB] (B checked first, A second). +List dilithiumKeyhash2of2Witness( + Uint8List sigA, + Uint8List pubA, + Uint8List sigB, + Uint8List pubB, + Uint8List trailingPubKey, { + Uint8List? witnessScript, +}) => + p2wshV6Witness([sigA, pubA, sigB, pubB], + witnessScript ?? dilithiumKeyhash2of2Script(pubA, pubB), trailingPubKey); + +/// Sign an OP_CHECKDILITHIUMKEYHASH input. The opcode delegates to CheckSig, which signs the +/// sighash DIRECTLY (reads the trailing hashType byte). scriptCode = the FULL witnessScript. +/// hashType: 0x01 (close) or 0x41/0x42 (eLTOO update rebinding). +Uint8List signForKeyhash( + Uint8List witnessScript, + Tx tx, + int nIn, + int hashType, + BigInt amountSat, + Uint8List secretKey, + MlDsaSign sign, +) { + if (hashType == sighashAll) { + return signAllWitness(witnessScript, tx, nIn, amountSat, secretKey, sign); + } + if (hashType == sighashAnyprevout || hashType == sighashAnyprevoutAnyscript) { + return signApoWitness(witnessScript, tx, nIn, hashType, amountSat, secretKey, sign); + } + throw ArgumentError('signForKeyhash: unsupported hashType 0x${hashType.toRadixString(16)}'); +} + +// ── funding helper ── + +/// A key-committed eLTOO funding output: the 2-of-2 both parties must co-sign with their +/// specific Dilithium keys. [pubA]/[pubB] are raw 1312-byte ML-DSA-44 keys. +class KeyhashFunding { + final Uint8List witnessScript; // OP_CDKH OP_CDKH OP_1 + final Uint8List scriptPubKey; // p2wshV6(witnessScript) + const KeyhashFunding(this.witnessScript, this.scriptPubKey); +} + +KeyhashFunding keyhashFunding2of2(Uint8List pubA, Uint8List pubB) { + final ws = dilithiumKeyhash2of2Script(pubA, pubB); + return KeyhashFunding(ws, p2wshV6(ws)); +} + +bool _bytesEqual(Uint8List a, Uint8List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} + +/// Validate + extract the two committed keyhashes from a keyhash-2-of-2 witnessScript. +/// Shape: 0x20 0xb6 0x20 0xb6 0x51 (69 bytes). khFirst is checked +/// FIRST by the script (so its key must end up on TOP of the eval stack). +({Uint8List khFirst, Uint8List khSecond}) _parseKeyhash2of2Script(Uint8List ws) { + if (ws.length != 69 || + ws[0] != 0x20 || + ws[33] != Op.checkdilithiumkeyhash || + ws[34] != 0x20 || + ws[67] != Op.checkdilithiumkeyhash || + ws[68] != Op.opOne) { + throw ArgumentError('not a keyhash-2-of-2 witnessScript'); + } + return (khFirst: ws.sublist(1, 33), khSecond: ws.sublist(35, 67)); +} + +/// One party's contribution to a keyhash-2-of-2 spend: their pubkey + their 2421-byte signature. +class KeyhashPartial { + final Uint8List pubKey; + final Uint8List sig; // 2420-byte ML-DSA sig ‖ hashType + const KeyhashPartial(this.pubKey, this.sig); +} + +/// Produce ONE party's partial signature. The party needs only their own secret key. +/// [hashType] = 0x42 (eLTOO update) or 0x01 (close); both signers must use the same one. +KeyhashPartial partialSignKeyhash2of2( + Uint8List witnessScript, + Tx tx, + int nIn, + int hashType, + BigInt amountSat, + Uint8List secretKey, + Uint8List pubKey, + MlDsaSign sign, +) { + if (pubKey.length != 1312) { + throw ArgumentError('expected 1312-byte pubkey, got ${pubKey.length}'); + } + return KeyhashPartial( + pubKey, signForKeyhash(witnessScript, tx, nIn, hashType, amountSat, secretKey, sign)); +} + +/// Assemble the v6 witness from two independently-produced partials. ORDER-ROBUST: each partial +/// is matched to its committed keyhash slot, so [pa,pb] and [pb,pa] yield the identical witness. +/// Throws on a partial matching no slot (wrong key), both mapping to one slot, or mixed hashTypes. +List combineKeyhash2of2Witness( + Uint8List witnessScript, + List partials, { + Uint8List? trailingPubKey, +}) { + if (partials.length != 2) throw ArgumentError('exactly two partials required'); + final parsed = _parseKeyhash2of2Script(witnessScript); + + KeyhashPartial match(Uint8List target) { + final found = + partials.where((p) => _bytesEqual(dilithiumKeyHash(p.pubKey), target)).toList(); + if (found.length != 1) { + throw ArgumentError( + 'combineKeyhash2of2Witness: exactly one partial must match each committed keyhash'); + } + return found.first; + } + + final pFirst = match(parsed.khFirst); // committed first → checked first → TOP of eval stack + final pSecond = match(parsed.khSecond); + if (identical(pFirst, pSecond)) { + throw ArgumentError('both partials map to the same committed key'); + } + for (final p in [pFirst, pSecond]) { + if (p.sig.length != 2421) { + throw ArgumentError('partial sig must be 2421 bytes (2420 + hashType), got ${p.sig.length}'); + } + } + if (pFirst.sig[2420] != pSecond.sig[2420]) { + throw ArgumentError('partials signed different hashTypes'); + } + // Trailing pubkey derived from the SCRIPT-matched ordering (pFirst), NOT caller arg order, + // so the assembled witness is identical for [pa,pb] and [pb,pa]. + final trailing = trailingPubKey ?? dilithiumWitnessPubKey(pFirst.pubKey); + // eval items bottom→top: [sigSecond, pubSecond, sigFirst, pubFirst] (First on top) + return p2wshV6Witness( + [pSecond.sig, pSecond.pubKey, pFirst.sig, pFirst.pubKey], witnessScript, trailing); +} + +/// Single-operator convenience: sign both sides locally, then combine. Same code path as two +/// parties signing independently and combining. +List signKeyhashFunding2of2( + KeyhashFunding funding, + Tx tx, + int nIn, + BigInt amountSat, + int hashType, + Uint8List skA, + Uint8List pubA, + Uint8List skB, + Uint8List pubB, + MlDsaSign sign, +) { + final pa = + partialSignKeyhash2of2(funding.witnessScript, tx, nIn, hashType, amountSat, skA, pubA, sign); + final pb = + partialSignKeyhash2of2(funding.witnessScript, tx, nIn, hashType, amountSat, skB, pubB, sign); + return combineKeyhash2of2Witness(funding.witnessScript, [pa, pb], + trailingPubKey: dilithiumWitnessPubKey(pubA)); +} diff --git a/lib/src/lightning/eltoo/script.dart b/lib/src/lightning/eltoo/script.dart new file mode 100644 index 0000000..de53253 --- /dev/null +++ b/lib/src/lightning/eltoo/script.dart @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// script.dart — Opt3 script primitives (port of channel.ts:52-142). +// +// Opcodes, CScript data pushes, CScriptNum encoding, and the Soqucoin witness-v6 wrappers +// (p2wshV6, the trailing Dilithium pubkey, and the v6 witness-stack assembler). Byte-exact +// with the consensus interpreter; pinned via the keyhash/eltoo vectors that consume them. + +import 'dart:typed_data'; + +import 'serialization.dart'; + +/// Script opcodes (channel.ts:52-58). +class Op { + static const int op0 = 0x00; + static const int opFalse = 0x00; + static const int op1 = 0x51; + static const int opTrue = 0x51; + static const int opOne = 0x51; + static const int witnessV6 = 0x56; + static const int opIf = 0x63; + static const int opElse = 0x67; + static const int opEndif = 0x68; + static const int opDrop = 0x75; + static const int equalverify = 0x88; + static const int opSha256 = 0xa8; + static const int checksig = 0xac; + static const int checksigverify = 0xad; + static const int cltv = 0xb1; + static const int csv = 0xb2; + static const int nop4Ctv = 0xb3; + static const int nop5Csfs = 0xb4; + // 0xb5 (OP_NOP6) RESERVED for OP_TXHASH / BIP 346 — do not repurpose. + static const int checkdilithiumkeyhash = 0xb6; // OP_NOP7 (SOQ-COV-013) +} + +/// CScript data push: length-opcode selection (channel.ts:69-76). +Uint8List pushData(Uint8List data) { + final n = data.length; + if (n < 0x4c) return concatBytes([u8(n), data]); + if (n <= 0xff) return concatBytes([u8(0x4c), u8(n), data]); + if (n <= 0xffff) return concatBytes([u8(0x4d), le16(n), data]); + return concatBytes([u8(0x4e), le32(n), data]); +} + +/// CScriptNum minimal encoding, then pushed as data (channel.ts:78-89). +Uint8List scriptNum(int value) { + if (value == 0) return u8(0x00); // CScriptNum(0) → empty push → OP_0 + final neg = value < 0; + var abs = neg ? -value : value; + final bytes = []; + while (abs > 0) { + bytes.add(abs & 0xff); + abs >>= 8; + } + if (bytes.last & 0x80 != 0) { + bytes.add(neg ? 0x80 : 0x00); + } else if (neg) { + bytes[bytes.length - 1] |= 0x80; + } + return pushData(Uint8List.fromList(bytes)); +} + +/// BIP141 v0 P2WSH scriptPubKey: OP_0 ‖ push(sha256(script)). Kept for interop; Soqucoin +/// channels use v6 (see [p2wshV6]). +Uint8List p2wsh(Uint8List witnessScript) => + concatBytes([u8(Op.opFalse), pushData(sha256Once(witnessScript))]); + +/// Soqucoin P2WSH-Dilithium scriptPubKey (witness v6): OP_6 ‖ push(sha256(script)). +Uint8List p2wshV6(Uint8List witnessScript) => + concatBytes([u8(Op.witnessV6), pushData(sha256Once(witnessScript))]); + +/// The trailing v6 witness item: the ML-DSA-44 pubkey 0x00-prefixed (1313 bytes). Consumed by +/// the HasDilithiumSignatures standardness check; not on the eval stack. +Uint8List dilithiumWitnessPubKey(Uint8List mldsaPubKey) => + concatBytes([u8(0x00), mldsaPubKey]); + +/// Assemble a v6 witness stack: [...satisfaction, witnessScript, trailingPubKey]. +/// [trailingPubKey] must already be 0x00-prefixed (use [dilithiumWitnessPubKey]). +List p2wshV6Witness( + List satisfaction, + Uint8List witnessScript, + Uint8List trailingPubKey, +) { + if (trailingPubKey.isEmpty || trailingPubKey[0] != 0x00) { + throw ArgumentError('trailing witness pubkey must be 0x00-prefixed (HasDilithiumSignatures)'); + } + return [...satisfaction, witnessScript, trailingPubKey]; +} diff --git a/test/eltoo/keyhash_test.dart b/test/eltoo/keyhash_test.dart new file mode 100644 index 0000000..2bee54f --- /dev/null +++ b/test/eltoo/keyhash_test.dart @@ -0,0 +1,150 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// keyhash_test.dart — Opt3 P4: byte-exact construction pins for the key-committed 2-of-2 +// funding (OP_CHECKDILITHIUMKEYHASH). The keyhash is SHA256(pubkey), so these layout/ordering +// properties are key-independent and pinned with deterministic pubkey patterns + a deterministic +// MOCK ML-DSA signer (structure, not cryptographic validity). +// +// NOT covered here (separate workstream): the node `committed_sdk_crossvector` byte-match of +// kh_A/kh_B/witnessScript/scriptPubKey/apo42_sighash for keys derived from seeds 0x11×32 / 0x22×32 +// — that needs the @noble↔pqcrystals ML-DSA keygen interop (vector_mldsa) and the native +// DilithiumNative FFI (not loadable in a pure `dart test`). Real sig verification rides on that. + +import 'dart:typed_data'; + +import 'package:soqushield_sdk/src/lightning/eltoo/serialization.dart'; +import 'package:soqushield_sdk/src/lightning/eltoo/script.dart'; +import 'package:soqushield_sdk/src/lightning/eltoo/sighash.dart'; +import 'package:soqushield_sdk/src/lightning/eltoo/keyhash.dart'; +import 'package:test/test.dart'; + +void main() { + // Deterministic 1312-byte pubkey patterns (distinct per party). + final pubA = Uint8List.fromList(List.generate(1312, (i) => i % 256)); + final pubB = Uint8List.fromList(List.generate(1312, (i) => (i + 7) % 256)); + final pubM = Uint8List.fromList(List.generate(1312, (i) => (i + 13) % 256)); // Mallory + final skA = Uint8List.fromList(List.filled(32, 0x11)); + final skB = Uint8List.fromList(List.filled(32, 0x22)); + final skM = Uint8List.fromList(List.filled(32, 0x33)); + + // Deterministic mock ML-DSA signer: 2420 bytes keyed by (secretKey, message) so a given + // (sk, msg) always yields identical bytes (required for order-robustness assertions). + Uint8List mockSign(Uint8List message, Uint8List secretKey) { + final out = Uint8List(2420); + for (var i = 0; i < 2420; i++) { + out[i] = (secretKey[0] + message[i % message.length] + i) & 0xff; + } + return out; + } + + final amount = BigInt.from(100000000); + Tx updateTx() => Tx( + version: 2, + locktime: 101, + vin: [ + TxIn( + prevout: OutPoint(Uint8List.fromList(List.filled(32, 0xfd)), 0), + sequence: 0xfffffffe, + scriptPubKey: Uint8List(0), + ), + ], + vout: [TxOut(amount, u8(Op.opTrue))], + ); + + group('Opt3 P4 — keyhash + funding construction', () { + test('dilithiumKeyHash = single SHA256 of the raw 1312-byte key; rejects 1313', () { + final kh = dilithiumKeyHash(pubA); + expect(kh.length, 32); + expect(toHex(kh), toHex(sha256Once(pubA))); // single SHA256, not sha256d + expect(() => dilithiumKeyHash(dilithiumWitnessPubKey(pubA)), throwsArgumentError); // 1313 + }); + + test('funding scriptPubKey is v6 (OP_6 + push32), 34 bytes', () { + final f = keyhashFunding2of2(pubA, pubB); + expect(f.scriptPubKey.length, 34); + expect(f.scriptPubKey[0], Op.witnessV6); // 0x56 + expect(f.scriptPubKey[1], 0x20); + expect(toHex(f.scriptPubKey), toHex(p2wshV6(f.witnessScript))); + }); + + test('witnessScript commits kh(pubB) FIRST then kh(pubA), ends OP_1 (69 bytes)', () { + final ws = dilithiumKeyhash2of2Script(pubA, pubB); + final expected = concatBytes([ + u8(0x20), dilithiumKeyHash(pubB), u8(Op.checkdilithiumkeyhash), + u8(0x20), dilithiumKeyHash(pubA), u8(Op.checkdilithiumkeyhash), + u8(Op.opOne), + ]); + expect(toHex(ws), toHex(expected)); + expect(ws.length, 1 + 32 + 1 + 1 + 32 + 1 + 1); // 69 + }); + }); + + group('Opt3 P4 — 2-of-2 witness layout', () { + test('signKeyhashFunding2of2 (0x42): [sigA,pubA,sigB,pubB,ws,trailing] with correct shapes', () { + final f = keyhashFunding2of2(pubA, pubB); + final w = signKeyhashFunding2of2( + f, updateTx(), 0, amount, sighashAnyprevoutAnyscript, skA, pubA, skB, pubB, mockSign); + expect(w.length, 6); + expect(w[0].length, 2421); // sigA ‖ hashType + expect(w[0][2420], 0x42); + expect(w[2][2420], 0x42); // sigB hashType + expect(toHex(w[1]), toHex(pubA)); + expect(toHex(w[3]), toHex(pubB)); + expect(toHex(w[4]), toHex(f.witnessScript)); + expect(w[5].length, 1313); + expect(w[5][0], 0x00); + }); + + test('cooperative close (0x01) sets hashType byte 0x01', () { + final f = keyhashFunding2of2(pubA, pubB); + final w = signKeyhashFunding2of2( + f, updateTx(), 0, amount, sighashAll, skA, pubA, skB, pubB, mockSign); + expect(w[0][2420], 0x01); + }); + }); + + group('Opt3 P4 — order-robust combine + binding rejections', () { + test('combine is order-invariant: [pa,pb] == [pb,pa] byte-for-byte', () { + final f = keyhashFunding2of2(pubA, pubB); + final tx = updateTx(); + final pa = partialSignKeyhash2of2( + f.witnessScript, tx, 0, sighashAnyprevoutAnyscript, amount, skA, pubA, mockSign); + final pb = partialSignKeyhash2of2( + f.witnessScript, tx, 0, sighashAnyprevoutAnyscript, amount, skB, pubB, mockSign); + final w1 = combineKeyhash2of2Witness(f.witnessScript, [pa, pb]); + final w2 = combineKeyhash2of2Witness(f.witnessScript, [pb, pa]); + expect(w1.length, 6); + for (var i = 0; i < 6; i++) { + expect(toHex(w1[i]), toHex(w2[i]), reason: 'witness item $i must be order-invariant'); + } + // eval order [sigA, pubA, sigB, pubB, ...] — B committed first → on top (last pair). + expect(toHex(w1[1]), toHex(pubA)); + expect(toHex(w1[3]), toHex(pubB)); + }); + + test('combine REJECTS a partial matching no committed keyhash (wrong party)', () { + final f = keyhashFunding2of2(pubA, pubB); + final tx = updateTx(); + final pa = partialSignKeyhash2of2( + f.witnessScript, tx, 0, sighashAnyprevoutAnyscript, amount, skA, pubA, mockSign); + final pm = partialSignKeyhash2of2( + f.witnessScript, tx, 0, sighashAnyprevoutAnyscript, amount, skM, pubM, mockSign); + expect(() => combineKeyhash2of2Witness(f.witnessScript, [pa, pm]), throwsArgumentError); + }); + + test('combine REJECTS partials that signed different hashTypes', () { + final f = keyhashFunding2of2(pubA, pubB); + final tx = updateTx(); + final pa = partialSignKeyhash2of2( + f.witnessScript, tx, 0, sighashAnyprevoutAnyscript, amount, skA, pubA, mockSign); + final pbAll = partialSignKeyhash2of2( + f.witnessScript, tx, 0, sighashAll, amount, skB, pubB, mockSign); + expect(() => combineKeyhash2of2Witness(f.witnessScript, [pa, pbAll]), throwsArgumentError); + }); + + test('SUBSTITUTION: Mallory keyhash differs from the committed (Alice) keyhash', () { + expect(toHex(dilithiumKeyHash(pubM)), isNot(toHex(dilithiumKeyHash(pubA)))); + }); + }); +} From 5a9ef194b29410395abcc8c10a9775672afb8037 Mon Sep 17 00:00:00 2001 From: Odenrider <54157783+odenrider@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:10:38 -0600 Subject: [PATCH 09/11] =?UTF-8?q?eltoo(opt3):=20P5=20=E2=80=94=20Dilithium?= =?UTF-8?q?EltooBuilder=20(the=20Opt2=E2=86=92Opt3=20swap)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/src/lightning/eltoo/eltoo_builder.dart — byte-for-byte port of channel.ts's DilithiumEltooBuilder. Implements the same UpdateTxBuilder seam PlaceholderTxBuilder satisfies, so dropping it into SoqLightning(txBuilder: ...) makes payments self-custodial with ZERO facade/UI change. Per state: build the canonical eLTOO graph (update output re-commits the state-N script; settlement spends it via the CSV branch paying both balances), sign the update input with SIGHASH_ANYPREVOUTANYSCRIPT (0x42), return opaque transport hex + the settlement CTV hash. Supporting: - serialization.dart: TxIn.scriptSig + serializeTxLegacy (non-witness, includes scriptSig — the LSP's opaque update_tx_hex) + txidInternal (the prevout the settlement spends). - script.dart: eltooUpdateScript (spec-ref; V6 reality = keyhash+CSFS/CTV, flagged). - lib/eltoo.dart: public entrypoint (import alongside lightning.dart to activate Opt3), kept separate so the Opt2 facade namespace stays free of the low-level consensus types. 6/6 P5 tests: graph construction (locktime=state+1, v6 output, CSV settlement, 2 balances), drop-in UpdateTxBuilder, build() emits real hex + a ctv_hash consistent with the derived settlement, deterministic + balance-sensitive. Full SDK suite green; analyze clean. ⚠️ NOT YET NODE-VALIDATED: the composed primitives are node-pinned (P1–P4), but the tx-graph assembly is validated on stagenet (P6), not against an offline vector. Do not broadcast builder output to mainnet until P6 passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/eltoo.dart | 21 ++++ lib/src/lightning/eltoo/eltoo.dart | 17 +++ lib/src/lightning/eltoo/eltoo_builder.dart | 128 +++++++++++++++++++++ lib/src/lightning/eltoo/script.dart | 20 ++++ lib/src/lightning/eltoo/serialization.dart | 42 ++++++- test/eltoo/eltoo_builder_test.dart | 124 ++++++++++++++++++++ 6 files changed, 347 insertions(+), 5 deletions(-) create mode 100644 lib/eltoo.dart create mode 100644 lib/src/lightning/eltoo/eltoo.dart create mode 100644 lib/src/lightning/eltoo/eltoo_builder.dart create mode 100644 test/eltoo/eltoo_builder_test.dart diff --git a/lib/eltoo.dart b/lib/eltoo.dart new file mode 100644 index 0000000..a366c35 --- /dev/null +++ b/lib/eltoo.dart @@ -0,0 +1,21 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// eltoo.dart — public entrypoint for the Opt3 self-custodial eLTOO crypto layer. +// +// Import alongside `package:soqushield_sdk/lightning.dart` to activate Opt3: +// +// import 'package:soqushield_sdk/lightning.dart'; +// import 'package:soqushield_sdk/eltoo.dart'; +// +// final ln = SoqLightning( +// baseUrl: 'https://lsp.soqu.org', +// txBuilder: DilithiumEltooBuilder(EltooBuilderOpts(...)), // self-custodial — same facade +// ); +// +// Kept separate from lightning.dart so the Opt2 facade namespace stays free of the low-level +// consensus types (Tx/serializeTx/toHex/…). The builder implements the same UpdateTxBuilder +// seam, so the two entrypoints compose without change. +library; + +export 'src/lightning/eltoo/eltoo.dart'; diff --git a/lib/src/lightning/eltoo/eltoo.dart b/lib/src/lightning/eltoo/eltoo.dart new file mode 100644 index 0000000..85f835e --- /dev/null +++ b/lib/src/lightning/eltoo/eltoo.dart @@ -0,0 +1,17 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// eltoo.dart — barrel for the Opt3 self-custodial eLTOO crypto layer. +// +// The real `DilithiumEltooBuilder` (and the primitives it composes) that swaps into the +// SoqLightning facade via the UpdateTxBuilder seam to make payments self-custodial. The crypto +// primitives (serialization, APO/BIP143 sighash, CTV, keyhash funding) are individually +// node-pinned (P1–P4); the builder's tx-graph assembly is pending stagenet validation (P6). +library; + +export 'serialization.dart'; +export 'script.dart'; +export 'sighash.dart'; +export 'ctv.dart'; +export 'keyhash.dart'; +export 'eltoo_builder.dart'; diff --git a/lib/src/lightning/eltoo/eltoo_builder.dart b/lib/src/lightning/eltoo/eltoo_builder.dart new file mode 100644 index 0000000..9aa2148 --- /dev/null +++ b/lib/src/lightning/eltoo/eltoo_builder.dart @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// eltoo_builder.dart — Opt3 P5: the real DilithiumEltooBuilder. +// +// Byte-for-byte port of channel.ts:570-643. This is the piece that flips Opt2 → Opt3: it +// implements the same UpdateTxBuilder seam the PlaceholderTxBuilder satisfies, so dropping it +// into SoqLightning(txBuilder: ...) makes payments self-custodial with ZERO facade/UI change. +// +// For each state transition it builds the canonical eLTOO tx graph (update output re-commits the +// state-N script; settlement spends it via the CSV branch paying both balances), signs the update +// input with SIGHASH_ANYPREVOUTANYSCRIPT (0x42, rebindable), and returns the opaque transport hex +// + the CTV hash committing the settlement payout. +// +// ⚠️ NOT YET NODE-VALIDATED. Like the TS source, the exact tx graph must be byte-checked against a +// node vector + accepted on stagenet (P6) before any real broadcast. The crypto PRIMITIVES it +// composes (serialization, APO-0x42 sighash, CTV, keyhash funding) are individually node-pinned +// (P1–P4); the GRAPH assembly is not. Do not broadcast builder output to mainnet until P6 passes. + +import 'dart:typed_data'; + +import '../update_tx_builder.dart'; +import 'ctv.dart'; +import 'script.dart'; +import 'serialization.dart'; +import 'sighash.dart'; + +/// Inputs for the eLTOO builder. [funding] is the channel funding (or genesis) outpoint; +/// [fundingAmountSat] is the capacity (the input amount the update spends). [initiatorPub]/ +/// [peerPub] are the two ML-DSA-44 pubkeys; [initiatorScriptPubKey]/[peerScriptPubKey] are the +/// settlement payout scripts. [secretKey] is OUR ML-DSA-44 key (signs the update with 0x42). +/// [sign] injects the ML-DSA signer (e.g. DilithiumNative.instance.sign). +class EltooBuilderOpts { + final OutPoint funding; + final BigInt fundingAmountSat; + final Uint8List initiatorPub; + final Uint8List peerPub; + final Uint8List initiatorScriptPubKey; + final Uint8List peerScriptPubKey; + final Uint8List secretKey; + final MlDsaSign sign; + final int settlementCsv; + + const EltooBuilderOpts({ + required this.funding, + required this.fundingAmountSat, + required this.initiatorPub, + required this.peerPub, + required this.initiatorScriptPubKey, + required this.peerScriptPubKey, + required this.secretKey, + required this.sign, + this.settlementCsv = 288, + }); +} + +/// Real self-custodial eLTOO TX builder (Opt3). Plugs into [SoqLightning] via [UpdateTxBuilder]. +class DilithiumEltooBuilder implements UpdateTxBuilder { + final EltooBuilderOpts o; + const DilithiumEltooBuilder(this.o); + + /// The update TX for state [stateNum]: spends the funding output into a v6 output that + /// re-commits the state-N eLTOO script. locktime = stateNum+1 satisfies the prior script's + /// IF-branch CLTV (newer state supersedes older). + Tx buildUpdateTx(int stateNum) { + final ws = eltooUpdateScript(stateNum, o.initiatorPub, o.peerPub, settlementCsv: o.settlementCsv); + return Tx( + version: 2, + locktime: stateNum + 1, + vin: [TxIn(prevout: o.funding, sequence: 0, scriptPubKey: Uint8List(0))], + vout: [TxOut(o.fundingAmountSat, p2wshV6(ws))], + ); + } + + /// The settlement TX spending the update output (n=0) via the relative-CSV ELSE branch, + /// paying both balances out to their settlement scripts. + Tx buildSettlementTx( + Uint8List updateTxid, BigInt initiatorBalanceSat, BigInt peerBalanceSat) { + return Tx( + version: 2, + locktime: 0, + vin: [TxIn(prevout: OutPoint(updateTxid, 0), sequence: o.settlementCsv, scriptPubKey: Uint8List(0))], + vout: [ + TxOut(initiatorBalanceSat, o.initiatorScriptPubKey), + TxOut(peerBalanceSat, o.peerScriptPubKey), + ], + ); + } + + @override + Future build(UpdateContext ctx) async { + final stateNum = ctx.nextStateIndex; + final updateTx = buildUpdateTx(stateNum); + + // Sign the update input with 0x42 (empty scriptCode — rebindable across states). + final witnessSig = signApoWitness( + Uint8List(0), updateTx, 0, sighashAnyprevoutAnyscript, o.fundingAmountSat, o.secretKey, o.sign); + + // Attach the sig as the input's scriptSig surrogate for opaque transport (broadcast uses + // the witness). The txid commits the UNSIGNED form, so compute it before attaching. + final updateTxid = txidInternal(updateTx); + final signedUpdate = Tx( + version: updateTx.version, + locktime: updateTx.locktime, + vin: [ + TxIn( + prevout: updateTx.vin[0].prevout, + sequence: updateTx.vin[0].sequence, + scriptPubKey: Uint8List(0), + scriptSig: witnessSig, + ), + ], + vout: updateTx.vout, + ); + + final settlementTx = buildSettlementTx( + updateTxid, + BigInt.from(ctx.nextInitiatorBalanceSat), + BigInt.from(ctx.nextPeerBalanceSat), + ); + + return UpdateTx( + updateTxHex: toHex(serializeTxLegacy(signedUpdate)), + settlementTxHex: toHex(serializeTxLegacy(settlementTx)), + ctvHash: toHex(ctvHash(settlementTx, 0)), + ); + } +} diff --git a/lib/src/lightning/eltoo/script.dart b/lib/src/lightning/eltoo/script.dart index de53253..52cebc1 100644 --- a/lib/src/lightning/eltoo/script.dart +++ b/lib/src/lightning/eltoo/script.dart @@ -76,6 +76,26 @@ Uint8List p2wshV6(Uint8List witnessScript) => Uint8List dilithiumWitnessPubKey(Uint8List mldsaPubKey) => concatBytes([u8(0x00), mldsaPubKey]); +/// eLTOO update output witness script (channel.ts:96-106, spec §2.2): +/// IF CLTV DROP CHECKSIGVERIFY CHECKSIG (supersession path) +/// ELSE CSV DROP CHECKSIGVERIFY CHECKSIG (settlement path) +/// ENDIF +/// ⚠️ V6 reality: Soqucoin's EvalScript implements no standard opcodes, so this IF/CLTV/CHECKSIG +/// form is a SPEC REFERENCE that does not execute on-chain — the real authorization is keyhash +/// 2-of-2 + CSFS/CTV. It is committed as the update output's script for the canonical tx graph; +/// the graph must be byte-validated against a node vector before mainnet (P6 / B1 §3.5). +Uint8List eltooUpdateScript(int stateNum, Uint8List aPub, Uint8List bPub, {int settlementCsv = 288}) { + return concatBytes([ + u8(Op.opIf), + scriptNum(stateNum + 1), u8(Op.cltv), u8(Op.opDrop), + pushData(aPub), u8(Op.checksigverify), pushData(bPub), u8(Op.checksig), + u8(Op.opElse), + scriptNum(settlementCsv), u8(Op.csv), u8(Op.opDrop), + pushData(aPub), u8(Op.checksigverify), pushData(bPub), u8(Op.checksig), + u8(Op.opEndif), + ]); +} + /// Assemble a v6 witness stack: [...satisfaction, witnessScript, trailingPubKey]. /// [trailingPubKey] must already be 0x00-prefixed (use [dilithiumWitnessPubKey]). List p2wshV6Witness( diff --git a/lib/src/lightning/eltoo/serialization.dart b/lib/src/lightning/eltoo/serialization.dart index 15e43ae..60180b9 100644 --- a/lib/src/lightning/eltoo/serialization.dart +++ b/lib/src/lightning/eltoo/serialization.dart @@ -117,18 +117,23 @@ class OutPoint { } } -/// A transaction input. [scriptPubKey] is the input's scriptPubKey (the BIP143 scriptCode); -/// the on-wire scriptSig is always empty for witness inputs. [witness] is the witness stack. +/// A transaction input. [scriptPubKey] is the input's scriptPubKey (the BIP143 scriptCode). +/// For segwit broadcast ([serializeTx]) the on-wire scriptSig is always empty and [witness] +/// carries the data. [scriptSig] is used only by the legacy/opaque transport serializer +/// ([serializeTxLegacy]) — the eLTOO builder stuffs its signature there for the LSP's opaque +/// update_tx_hex (real broadcast uses the witness). class TxIn { final OutPoint prevout; final int sequence; final Uint8List scriptPubKey; final List? witness; + final Uint8List? scriptSig; const TxIn({ required this.prevout, required this.sequence, required this.scriptPubKey, this.witness, + this.scriptSig, }); } @@ -199,11 +204,38 @@ Uint8List serializeTx(Tx tx) { String serializeTxHex(Tx tx) => toHex(serializeTx(tx)); /// txid: SHA256d of the NON-witness serialization, reversed to display order. -String txid(Tx tx) { +String txid(Tx tx) => toHex(reversed(_txidInternal(tx))); + +/// txid in INTERNAL byte order (not display-reversed) — what an [OutPoint] consumes as the +/// prevout of a spending tx (e.g. the eLTOO settlement spending the update). scriptSig is +/// cleared (the txid commits to the unsigned form). +Uint8List txidInternal(Tx tx) => _txidInternal(tx); + +Uint8List _txidInternal(Tx tx) { + final parts = [le32(tx.version), compactSize(tx.vin.length)]; + for (final i in tx.vin) { + parts.add(serOutpoint(i)); + parts.add(compactSize(0)); // empty scriptSig + parts.add(le32(i.sequence)); + } + parts.add(compactSize(tx.vout.length)); + for (final o in tx.vout) { + parts.add(serTxOut(o)); + } + parts.add(le32(tx.locktime)); + return sha256d(concatBytes(parts)); +} + +/// Legacy (non-witness) serialization that INCLUDES each input's [TxIn.scriptSig] (channel.ts +/// serializeTx). Used for the LSP's opaque update/settlement transport hex, where the eLTOO +/// builder stuffs the 0x42 signature into scriptSig. Real broadcast uses [serializeTx] (witness). +Uint8List serializeTxLegacy(Tx tx) { final parts = [le32(tx.version), compactSize(tx.vin.length)]; for (final i in tx.vin) { + final ss = i.scriptSig ?? Uint8List(0); parts.add(serOutpoint(i)); - parts.add(compactSize(0)); + parts.add(compactSize(ss.length)); + parts.add(ss); parts.add(le32(i.sequence)); } parts.add(compactSize(tx.vout.length)); @@ -211,5 +243,5 @@ String txid(Tx tx) { parts.add(serTxOut(o)); } parts.add(le32(tx.locktime)); - return toHex(reversed(sha256d(concatBytes(parts)))); + return concatBytes(parts); } diff --git a/test/eltoo/eltoo_builder_test.dart b/test/eltoo/eltoo_builder_test.dart new file mode 100644 index 0000000..8039921 --- /dev/null +++ b/test/eltoo/eltoo_builder_test.dart @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// eltoo_builder_test.dart — Opt3 P5: the DilithiumEltooBuilder assembles the eLTOO tx graph +// correctly and plugs into the UpdateTxBuilder seam. Verification here is STRUCTURAL + +// self-consistency (the composed primitives are individually node-pinned in P1–P4; the graph +// itself is validated on stagenet in P6, not against an offline vector). + +import 'dart:typed_data'; + +import 'package:soqushield_sdk/eltoo.dart'; +import 'package:soqushield_sdk/lightning.dart'; +import 'package:test/test.dart'; + +void main() { + final initiatorPub = Uint8List.fromList(List.generate(1312, (i) => i % 256)); + final peerPub = Uint8List.fromList(List.generate(1312, (i) => (i + 7) % 256)); + final initiatorSpk = Uint8List.fromList([0x51, 0x20, ...List.generate(32, (i) => i)]); // v1 spk + final peerSpk = Uint8List.fromList([0x51, 0x20, ...List.generate(32, (i) => 0xff - i)]); + final secretKey = Uint8List.fromList(List.filled(32, 0x11)); + final funding = OutPoint(Uint8List.fromList(List.filled(32, 0xfd)), 0); + final fundingAmount = BigInt.from(100000000); + + Uint8List mockSign(Uint8List message, Uint8List sk) { + final out = Uint8List(2420); + for (var i = 0; i < 2420; i++) { + out[i] = (sk[0] + message[i % message.length] + i) & 0xff; + } + return out; + } + + DilithiumEltooBuilder builder({int csv = 288}) => DilithiumEltooBuilder(EltooBuilderOpts( + funding: funding, + fundingAmountSat: fundingAmount, + initiatorPub: initiatorPub, + peerPub: peerPub, + initiatorScriptPubKey: initiatorSpk, + peerScriptPubKey: peerSpk, + secretKey: secretKey, + sign: mockSign, + settlementCsv: csv, + )); + + // A minimal UpdateContext (the builder reads only the next-state fields). + UpdateContext ctx({int state = 1, int init = 80000000, int peer = 20000000}) => UpdateContext( + channel: const LnChannel( + channelId: 'ch_1', + initiatorPubKeyHex: '', + peerPubKeyHex: '', + capacitySat: 100000000, + initiatorBalanceSat: 100000000, + peerBalanceSat: 0, + stateIndex: 0, + state: 'open', + csvDelay: 288, + createdAtUnix: 0, + ), + nextStateIndex: state, + nextInitiatorBalanceSat: init, + nextPeerBalanceSat: peer, + ); + + group('Opt3 P5 — tx graph construction', () { + test('buildUpdateTx: v2, locktime=state+1, funding input, v6 output at capacity', () { + final tx = builder().buildUpdateTx(5); + expect(tx.version, 2); + expect(tx.locktime, 6); // state + 1 + expect(tx.vin.length, 1); + expect(toHex(tx.vin[0].prevout.txid), toHex(funding.txid)); + expect(tx.vin[0].sequence, 0); + expect(tx.vout.length, 1); + expect(tx.vout[0].value, fundingAmount); + // output re-commits the state-N eLTOO script as a v6 program + final expectedSpk = p2wshV6(eltooUpdateScript(5, initiatorPub, peerPub, settlementCsv: 288)); + expect(toHex(tx.vout[0].scriptPubKey), toHex(expectedSpk)); + }); + + test('buildSettlementTx: v2, CSV sequence, 2 balance outputs spending the update', () { + final updateTxid = txidInternal(builder().buildUpdateTx(1)); + final s = builder().buildSettlementTx(updateTxid, BigInt.from(80000000), BigInt.from(20000000)); + expect(s.version, 2); + expect(s.vin[0].sequence, 288); // relative CSV (ELSE branch) + expect(toHex(s.vin[0].prevout.txid), toHex(updateTxid)); // spends the update + expect(s.vout.length, 2); + expect(s.vout[0].value, BigInt.from(80000000)); + expect(toHex(s.vout[0].scriptPubKey), toHex(initiatorSpk)); + expect(s.vout[1].value, BigInt.from(20000000)); + expect(toHex(s.vout[1].scriptPubKey), toHex(peerSpk)); + }); + }); + + group('Opt3 P5 — build(ctx) seam + self-consistency', () { + test('is a drop-in UpdateTxBuilder (the Opt2→Opt3 swap)', () { + expect(builder(), isA()); + }); + + test('build() returns real hex + a ctv_hash consistent with the settlement graph', () async { + final b = builder(); + final r = await b.build(ctx()); + + // ctv_hash must equal the CTV hash of the settlement tx the builder derives. + final updateTxid = txidInternal(b.buildUpdateTx(1)); + final settlement = b.buildSettlementTx(updateTxid, BigInt.from(80000000), BigInt.from(20000000)); + expect(r.ctvHash, toHex(ctvHash(settlement, 0))); + + // update hex carries the 0x42 signature (legacy/opaque transport): much larger than an + // unsigned tx, and the placeholder is gone. + expect(r.updateTxHex.length, greaterThan(2 * 2420)); // >2420-byte sig embedded + expect(r.updateTxHex, isNot('placeholder')); + expect(r.settlementTxHex, isNot('placeholder')); + }); + + test('deterministic for a fixed ctx (mock signer) and sensitive to balance changes', () async { + final b = builder(); + final a1 = await b.build(ctx()); + final a2 = await b.build(ctx()); + expect(a1.updateTxHex, a2.updateTxHex); + expect(a1.ctvHash, a2.ctvHash); + + final diff = await b.build(ctx(init: 70000000, peer: 30000000)); + expect(diff.ctvHash, isNot(a1.ctvHash)); // settlement payout changed → CTV changed + }); + }); +} From 2200a0ee8dbdb532d2104fe00bd4c2b9f859cee6 Mon Sep 17 00:00:00 2001 From: Odenrider <54157783+odenrider@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:24:26 -0600 Subject: [PATCH 10/11] opt3(B1 step3): port Dart SDK script layer to B1 (executes on V6) + node-pin Replaces the dead non-executing eltooUpdateScript (IF/CLTV/CHECKSIG inline-pubkey spec-ref) with the B1 forms in keyhash.dart: - eltooUpdateScriptV6 / ...FromKeyhashes: IF CLTV DROP keyhash-2of2 / ELSE CSV DROP keyhash-2of2 ENDIF (ratchet + settlement execute on V6) - htlcScriptV6 / ...FromKeyhashes: IF SHA256 EQUALVERIFY keyhash / ELSE CLTV keyhash ENDIF - dilithiumKeyhash2of2ScriptFromHashes (32-byte keyhash body, reused) Rewires DilithiumEltooBuilder.buildUpdateTx to emit the B1 script; refreshes docstrings (script executes/node-pinned; broadcast witness assembly = step 4). New test/eltoo/b1_script_test.dart byte-matches the node b1_script_vectors. Full eltoo suite 34/34, analyze clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/lightning/eltoo/eltoo_builder.dart | 23 +++--- lib/src/lightning/eltoo/keyhash.dart | 91 +++++++++++++++++++++- lib/src/lightning/eltoo/script.dart | 23 +----- test/eltoo/b1_script_test.dart | 58 ++++++++++++++ test/eltoo/eltoo_builder_test.dart | 4 +- 5 files changed, 165 insertions(+), 34 deletions(-) create mode 100644 test/eltoo/b1_script_test.dart diff --git a/lib/src/lightning/eltoo/eltoo_builder.dart b/lib/src/lightning/eltoo/eltoo_builder.dart index 9aa2148..db9a371 100644 --- a/lib/src/lightning/eltoo/eltoo_builder.dart +++ b/lib/src/lightning/eltoo/eltoo_builder.dart @@ -8,19 +8,23 @@ // into SoqLightning(txBuilder: ...) makes payments self-custodial with ZERO facade/UI change. // // For each state transition it builds the canonical eLTOO tx graph (update output re-commits the -// state-N script; settlement spends it via the CSV branch paying both balances), signs the update -// input with SIGHASH_ANYPREVOUTANYSCRIPT (0x42, rebindable), and returns the opaque transport hex -// + the CTV hash committing the settlement payout. +// state-N B1 script; settlement spends it via the CSV branch paying both balances), signs the +// update input with SIGHASH_ANYPREVOUTANYSCRIPT (0x42, rebindable), and returns the opaque +// transport hex + the CTV hash committing the settlement payout. // -// ⚠️ NOT YET NODE-VALIDATED. Like the TS source, the exact tx graph must be byte-checked against a -// node vector + accepted on stagenet (P6) before any real broadcast. The crypto PRIMITIVES it -// composes (serialization, APO-0x42 sighash, CTV, keyhash funding) are individually node-pinned -// (P1–P4); the GRAPH assembly is not. Do not broadcast builder output to mainnet until P6 passes. +// The update output now carries the B1 eLTOO script (eltooUpdateScriptV6) — IF/CLTV-ratchet + +// keyhash-2-of-2 — which EXECUTES on V6 under SCRIPT_VERIFY_V6_CONTROLFLOW and is node-pinned +// (b1_script_vectors). The keyhash sigs are produced via signApoWitness/0x42 (the opcode reads the +// trailing hashType and CheckSig's over the sighash — the correct primitive for keyhash, unlike +// CSFS). ⚠️ REMAINING (step 4): the self-custodial BROADCAST witness assembly (the IF/ELSE selector +// + keyhash 2-of-2 satisfaction + trailing v6 pubkey) and on-chain stagenet acceptance. build() +// still emits OPAQUE transport hex for the LSP co-sign flow; do not broadcast until step 4 lands. import 'dart:typed_data'; import '../update_tx_builder.dart'; import 'ctv.dart'; +import 'keyhash.dart'; import 'script.dart'; import 'serialization.dart'; import 'sighash.dart'; @@ -61,9 +65,10 @@ class DilithiumEltooBuilder implements UpdateTxBuilder { /// The update TX for state [stateNum]: spends the funding output into a v6 output that /// re-commits the state-N eLTOO script. locktime = stateNum+1 satisfies the prior script's - /// IF-branch CLTV (newer state supersedes older). + /// IF-branch CLTV (newer state supersedes older — this ratchet EXECUTES on V6 under + /// SCRIPT_VERIFY_V6_CONTROLFLOW; see DL-V6-CONTROLFLOW-RESTORE). Tx buildUpdateTx(int stateNum) { - final ws = eltooUpdateScript(stateNum, o.initiatorPub, o.peerPub, settlementCsv: o.settlementCsv); + final ws = eltooUpdateScriptV6(stateNum, o.initiatorPub, o.peerPub, settlementCsv: o.settlementCsv); return Tx( version: 2, locktime: stateNum + 1, diff --git a/lib/src/lightning/eltoo/keyhash.dart b/lib/src/lightning/eltoo/keyhash.dart index ee09e3b..6f8d9b8 100644 --- a/lib/src/lightning/eltoo/keyhash.dart +++ b/lib/src/lightning/eltoo/keyhash.dart @@ -36,14 +36,97 @@ Uint8List dilithiumKeyhashScript(Uint8List rawPubKey) => concatBytes([ u8(Op.opOne), ]); +/// Key-committed 2-of-2 body from raw 32-byte keyhashes: ` OP_CDKH OP_CDKH OP_1`. +/// khB is committed FIRST because the first opcode checks the TOP eval item (witness puts B on top). +Uint8List dilithiumKeyhash2of2ScriptFromHashes(Uint8List khA, Uint8List khB) { + if (khA.length != 32 || khB.length != 32) { + throw ArgumentError('keyhash must be 32 bytes'); + } + return concatBytes([ + pushData(khB), u8(Op.checkdilithiumkeyhash), + pushData(khA), u8(Op.checkdilithiumkeyhash), + u8(Op.opOne), + ]); +} + /// Key-committed 2-of-2 script: ` OP_CDKH OP_CDKH OP_1`. pubB is committed /// FIRST because the first opcode checks the TOP eval item, and the witness puts pubB on top. -Uint8List dilithiumKeyhash2of2Script(Uint8List pubA, Uint8List pubB) => concatBytes([ - pushData(dilithiumKeyHash(pubB)), u8(Op.checkdilithiumkeyhash), - pushData(dilithiumKeyHash(pubA)), u8(Op.checkdilithiumkeyhash), - u8(Op.opOne), +Uint8List dilithiumKeyhash2of2Script(Uint8List pubA, Uint8List pubB) => + dilithiumKeyhash2of2ScriptFromHashes(dilithiumKeyHash(pubA), dilithiumKeyHash(pubB)); + +/// B1 eLTOO update output witnessScript (DL-V6-CONTROLFLOW-RESTORE §4.1): +/// IF CLTV DROP CDKH CDKH OP_1 (supersession, APO-0x42) +/// ELSE CSV DROP CDKH CDKH OP_1 (settlement after CSV) +/// ENDIF +/// The CLTV ratchet (newer state supersedes older) and the CSV settlement delay EXECUTE on V6 once +/// SCRIPT_VERIFY_V6_CONTROLFLOW is active. Settlement keyhashes default to the update keyhashes. +/// Node-pinned: lightning_script_tests.cpp/b1_script_vectors. +Uint8List eltooUpdateScriptV6FromKeyhashes( + int stateNum, + Uint8List khAupdate, + Uint8List khBupdate, { + Uint8List? khAsettle, + Uint8List? khBsettle, + int settlementCsv = 288, +}) => + concatBytes([ + u8(Op.opIf), + scriptNum(stateNum + 1), u8(Op.cltv), u8(Op.opDrop), + dilithiumKeyhash2of2ScriptFromHashes(khAupdate, khBupdate), + u8(Op.opElse), + scriptNum(settlementCsv), u8(Op.csv), u8(Op.opDrop), + dilithiumKeyhash2of2ScriptFromHashes(khAsettle ?? khAupdate, khBsettle ?? khBupdate), + u8(Op.opEndif), ]); +/// [eltooUpdateScriptV6FromKeyhashes] taking raw 1312-byte ML-DSA pubkeys (hashed internally). +Uint8List eltooUpdateScriptV6( + int stateNum, + Uint8List updateA, + Uint8List updateB, { + Uint8List? settleA, + Uint8List? settleB, + int settlementCsv = 288, +}) => + eltooUpdateScriptV6FromKeyhashes( + stateNum, + dilithiumKeyHash(updateA), + dilithiumKeyHash(updateB), + khAsettle: settleA == null ? null : dilithiumKeyHash(settleA), + khBsettle: settleB == null ? null : dilithiumKeyHash(settleB), + settlementCsv: settlementCsv, + ); + +/// B1 HTLC output witnessScript (DL-V6-CONTROLFLOW-RESTORE §4.2): +/// IF OP_SHA256 OP_EQUALVERIFY CDKH OP_1 (SUCCESS: preimage + payee sig) +/// ELSE CLTV DROP CDKH OP_1 (TIMEOUT: absolute CLTV + payer) +/// ENDIF +/// Node-pinned: lightning_script_tests.cpp/b1_script_vectors. +Uint8List htlcScriptV6FromKeyhashes( + Uint8List paymentHash, + Uint8List khPayee, + Uint8List khPayer, + int cltvExpiry, +) { + if (paymentHash.length != 32) { + throw ArgumentError('paymentHash must be 32 bytes'); + } + return concatBytes([ + u8(Op.opIf), + u8(Op.opSha256), pushData(paymentHash), u8(Op.equalverify), + pushData(khPayee), u8(Op.checkdilithiumkeyhash), u8(Op.opOne), + u8(Op.opElse), + scriptNum(cltvExpiry), u8(Op.cltv), u8(Op.opDrop), + pushData(khPayer), u8(Op.checkdilithiumkeyhash), u8(Op.opOne), + u8(Op.opEndif), + ]); +} + +/// [htlcScriptV6FromKeyhashes] taking raw 1312-byte ML-DSA pubkeys (hashed internally). +Uint8List htlcScriptV6(Uint8List paymentHash, Uint8List payeePub, Uint8List payerPub, int cltvExpiry) => + htlcScriptV6FromKeyhashes( + paymentHash, dilithiumKeyHash(payeePub), dilithiumKeyHash(payerPub), cltvExpiry); + /// v6 witness for a single-key committed spend. Eval items: [sig, pub] (pub on top). List dilithiumKeyhashWitness( Uint8List sig, diff --git a/lib/src/lightning/eltoo/script.dart b/lib/src/lightning/eltoo/script.dart index 52cebc1..b74e0ed 100644 --- a/lib/src/lightning/eltoo/script.dart +++ b/lib/src/lightning/eltoo/script.dart @@ -76,25 +76,10 @@ Uint8List p2wshV6(Uint8List witnessScript) => Uint8List dilithiumWitnessPubKey(Uint8List mldsaPubKey) => concatBytes([u8(0x00), mldsaPubKey]); -/// eLTOO update output witness script (channel.ts:96-106, spec §2.2): -/// IF CLTV DROP CHECKSIGVERIFY CHECKSIG (supersession path) -/// ELSE CSV DROP CHECKSIGVERIFY CHECKSIG (settlement path) -/// ENDIF -/// ⚠️ V6 reality: Soqucoin's EvalScript implements no standard opcodes, so this IF/CLTV/CHECKSIG -/// form is a SPEC REFERENCE that does not execute on-chain — the real authorization is keyhash -/// 2-of-2 + CSFS/CTV. It is committed as the update output's script for the canonical tx graph; -/// the graph must be byte-validated against a node vector before mainnet (P6 / B1 §3.5). -Uint8List eltooUpdateScript(int stateNum, Uint8List aPub, Uint8List bPub, {int settlementCsv = 288}) { - return concatBytes([ - u8(Op.opIf), - scriptNum(stateNum + 1), u8(Op.cltv), u8(Op.opDrop), - pushData(aPub), u8(Op.checksigverify), pushData(bPub), u8(Op.checksig), - u8(Op.opElse), - scriptNum(settlementCsv), u8(Op.csv), u8(Op.opDrop), - pushData(aPub), u8(Op.checksigverify), pushData(bPub), u8(Op.checksig), - u8(Op.opEndif), - ]); -} +// The eLTOO update output witnessScript now lives in keyhash.dart as +// eltooUpdateScriptV6 (DL-V6-CONTROLFLOW-RESTORE §4.1) — IF/CLTV-ratchet + keyhash-2-of-2, +// which actually EXECUTES on V6. The old IF/CLTV/CHECKSIG spec-reference (inline 1312-byte +// pubkeys > 520-byte push limit, no-op opcodes) has been removed: it never executed on-chain. /// Assemble a v6 witness stack: [...satisfaction, witnessScript, trailingPubKey]. /// [trailingPubKey] must already be 0x00-prefixed (use [dilithiumWitnessPubKey]). diff --git a/test/eltoo/b1_script_test.dart b/test/eltoo/b1_script_test.dart new file mode 100644 index 0000000..72ab481 --- /dev/null +++ b/test/eltoo/b1_script_test.dart @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Soqucoin Labs Inc. +// Distributed under the MIT software license. +// +// b1_script_test.dart — pins the B1 eLTOO + HTLC witnessScript bytes to the node. +// +// Ground truth: soqucoin-build src/test/lightning_script_tests.cpp / b1_script_vectors +// (DL-V6-CONTROLFLOW-RESTORE §4). The keyhash = SHA256(pubkey) step is separately pinned (P4), +// so these vectors commit raw 32-byte keyhashes directly and exercise the script FORM byte-exact. + +import 'dart:typed_data'; + +import 'package:soqushield_sdk/eltoo.dart'; +import 'package:test/test.dart'; + +Uint8List fill(int b) => Uint8List(32)..fillRange(0, 32, b); + +void main() { + group('B1 script vectors (node-pinned)', () { + test('eLTOO update script byte-matches the node vector', () { + // stateNum=600000, csv=288; update kh A=0x11 B=0x22, settle kh A=0x33 B=0x44. + final ws = eltooUpdateScriptV6FromKeyhashes( + 600000, + fill(0x11), + fill(0x22), + khAsettle: fill(0x33), + khBsettle: fill(0x44), + settlementCsv: 288, + ); + expect( + toHex(ws), + '6303c12709b175202222222222222222222222222222222222222222222222222222222222222222b6201111111111111111111111111111111111111111111111111111111111111111b65167022001b275204444444444444444444444444444444444444444444444444444444444444444b6203333333333333333333333333333333333333333333333333333333333333333b65168', + ); + }); + + test('HTLC script byte-matches the node vector', () { + // cltv=500; H=0xab, payee kh=0x55, payer kh=0x66. + final ws = htlcScriptV6FromKeyhashes(fill(0xab), fill(0x55), fill(0x66), 500); + expect( + toHex(ws), + '63a820abababababababababababababababababababababababababababababababab88205555555555555555555555555555555555555555555555555555555555555555b6516702f401b175206666666666666666666666666666666666666666666666666666666666666666b65168', + ); + }); + + test('pubkey wrappers agree with the from-keyhash form', () { + final pubA = Uint8List(1312)..fillRange(0, 1312, 0xa1); + final pubB = Uint8List(1312)..fillRange(0, 1312, 0xb2); + final viaPub = eltooUpdateScriptV6(7, pubA, pubB, settlementCsv: 144); + final viaKh = eltooUpdateScriptV6FromKeyhashes( + 7, dilithiumKeyHash(pubA), dilithiumKeyHash(pubB), settlementCsv: 144); + expect(toHex(viaPub), toHex(viaKh)); + + final h = Uint8List(32)..fillRange(0, 32, 0x07); + final htlcViaPub = htlcScriptV6(h, pubA, pubB, 1000); + final htlcViaKh = htlcScriptV6FromKeyhashes(h, dilithiumKeyHash(pubA), dilithiumKeyHash(pubB), 1000); + expect(toHex(htlcViaPub), toHex(htlcViaKh)); + }); + }); +} diff --git a/test/eltoo/eltoo_builder_test.dart b/test/eltoo/eltoo_builder_test.dart index 8039921..d9e7efa 100644 --- a/test/eltoo/eltoo_builder_test.dart +++ b/test/eltoo/eltoo_builder_test.dart @@ -70,8 +70,8 @@ void main() { expect(tx.vin[0].sequence, 0); expect(tx.vout.length, 1); expect(tx.vout[0].value, fundingAmount); - // output re-commits the state-N eLTOO script as a v6 program - final expectedSpk = p2wshV6(eltooUpdateScript(5, initiatorPub, peerPub, settlementCsv: 288)); + // output re-commits the state-N B1 eLTOO script as a v6 program + final expectedSpk = p2wshV6(eltooUpdateScriptV6(5, initiatorPub, peerPub, settlementCsv: 288)); expect(toHex(tx.vout[0].scriptPubKey), toHex(expectedSpk)); }); From 636668719cb1094443a1bdac32eeb6eb9b5439b7 Mon Sep 17 00:00:00 2001 From: Odenrider <54157783+odenrider@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:31:08 -0600 Subject: [PATCH 11/11] opt3(B1 step4): Dart B1 branch witness assemblers (node-accepted layouts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the v6 satisfaction-stack builders for spending the B1 outputs: - eltooUpdateBranchWitness (IF/supersession, truthy selector) - eltooSettlementBranchWitness (ELSE/close, empty selector) - htlcSuccessWitness (preimage + payee, truthy) / htlcTimeoutWitness (payer, empty) Each = [2-of-2 keyhash satisfaction, selector, witnessScript, 0x00||trailingPub] — the exact layout the node accepts (lightning_script_tests eltoo_v6_ratchet_target / htlc_v6_target). Structural test pins selector position/value, script, trailer. eltoo suite 35/35, analyze clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/lightning/eltoo/keyhash.dart | 36 +++++++++++++++++++++++++++ test/eltoo/b1_script_test.dart | 37 ++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/lib/src/lightning/eltoo/keyhash.dart b/lib/src/lightning/eltoo/keyhash.dart index 6f8d9b8..88edbb4 100644 --- a/lib/src/lightning/eltoo/keyhash.dart +++ b/lib/src/lightning/eltoo/keyhash.dart @@ -298,3 +298,39 @@ List signKeyhashFunding2of2( return combineKeyhash2of2Witness(funding.witnessScript, [pa, pb], trailingPubKey: dilithiumWitnessPubKey(pubA)); } + +// ── B1 branch witnesses (step 4) ── +// The v6 satisfaction layouts a node accepts for spending the B1 eLTOO/HTLC outputs. Each is the +// branch's keyhash satisfaction followed by the OP_IF selector (truthy 0x01 = IF, empty = ELSE), +// then the witnessScript + trailing 0x00-prefixed pubkey. Pinned by the node tests +// lightning_script_tests.cpp/{eltoo_v6_ratchet_target,htlc_v6_target}. + +final Uint8List _selTrue = Uint8List.fromList([0x01]); // truthy IF selector +final Uint8List _selFalse = Uint8List(0); // empty → CastToBool false → ELSE + +/// B1 eLTOO UPDATE-branch (supersession, IF) witness. sigA/pubA commit to khAupdate, sigB/pubB to +/// khBupdate (B is checked first → ends up on top): satisfaction = [sigA, pubA, sigB, pubB, TRUE]. +/// The spending (next-state) update tx sets nLockTime ≥ stateNum+1 so its CLTV ratchet passes. +List eltooUpdateBranchWitness(Uint8List eltooScript, Uint8List sigA, Uint8List pubA, + Uint8List sigB, Uint8List pubB, Uint8List trailing) => + p2wshV6Witness([sigA, pubA, sigB, pubB, _selTrue], eltooScript, trailing); + +/// B1 eLTOO SETTLEMENT-branch (close, ELSE) witness: same 2-of-2 satisfaction with a FALSE +/// selector. The settlement tx must spend with nSequence ≥ settlementCsv (the CSV delay). +List eltooSettlementBranchWitness(Uint8List eltooScript, Uint8List sigA, Uint8List pubA, + Uint8List sigB, Uint8List pubB, Uint8List trailing) => + p2wshV6Witness([sigA, pubA, sigB, pubB, _selFalse], eltooScript, trailing); + +/// B1 HTLC SUCCESS (IF) witness: [sigPayee, pubPayee, preimage, TRUE]. [preimage] must SHA256 to +/// the committed paymentHash. +List htlcSuccessWitness(Uint8List htlcScript, Uint8List sigPayee, Uint8List pubPayee, + Uint8List preimage, Uint8List trailing) { + if (preimage.length != 32) throw ArgumentError('preimage must be 32 bytes'); + return p2wshV6Witness([sigPayee, pubPayee, preimage, _selTrue], htlcScript, trailing); +} + +/// B1 HTLC TIMEOUT (ELSE) witness: [sigPayer, pubPayer, FALSE]. The claim tx must set +/// nLockTime ≥ cltvExpiry with a non-final input so the CLTV passes. +List htlcTimeoutWitness(Uint8List htlcScript, Uint8List sigPayer, Uint8List pubPayer, + Uint8List trailing) => + p2wshV6Witness([sigPayer, pubPayer, _selFalse], htlcScript, trailing); diff --git a/test/eltoo/b1_script_test.dart b/test/eltoo/b1_script_test.dart index 72ab481..214dc3a 100644 --- a/test/eltoo/b1_script_test.dart +++ b/test/eltoo/b1_script_test.dart @@ -41,6 +41,43 @@ void main() { ); }); + test('B1 branch witnesses match the node-accepted satisfaction layout', () { + final sigA = Uint8List(2421)..fillRange(0, 2421, 0x01); + final pubA = Uint8List(1312)..fillRange(0, 1312, 0xa1); + final sigB = Uint8List(2421)..fillRange(0, 2421, 0x02); + final pubB = Uint8List(1312)..fillRange(0, 1312, 0xb2); + final script = Uint8List.fromList([0x63, 0x68]); // any witnessScript stand-in + final trailing = dilithiumWitnessPubKey(pubA); // 0x00 ‖ pubA + + // UPDATE branch: [sigA, pubA, sigB, pubB, 0x01, script, trailing] + final up = eltooUpdateBranchWitness(script, sigA, pubA, sigB, pubB, trailing); + expect(up.length, 7); + expect(up[0], sigA); + expect(up[1], pubA); + expect(up[2], sigB); + expect(up[3], pubB); + expect(up[4], Uint8List.fromList([0x01])); // truthy IF selector + expect(up[5], script); + expect(up[6][0], 0x00); // trailing v6 pubkey is 0x00-prefixed + + // SETTLEMENT branch: same but FALSE (empty) selector + final set = eltooSettlementBranchWitness(script, sigA, pubA, sigB, pubB, trailing); + expect(set.length, 7); + expect(set[4].isEmpty, isTrue); // empty → ELSE branch + + // HTLC success: [sigPayee, pubPayee, preimage, 0x01, script, trailing] + final preimage = Uint8List(32)..fillRange(0, 32, 0x07); + final ok = htlcSuccessWitness(script, sigA, pubA, preimage, trailing); + expect(ok.length, 6); + expect(ok[2], preimage); + expect(ok[3], Uint8List.fromList([0x01])); + + // HTLC timeout: [sigPayer, pubPayer, empty, script, trailing] + final to = htlcTimeoutWitness(script, sigA, pubA, trailing); + expect(to.length, 5); + expect(to[2].isEmpty, isTrue); + }); + test('pubkey wrappers agree with the from-keyhash form', () { final pubA = Uint8List(1312)..fillRange(0, 1312, 0xa1); final pubB = Uint8List(1312)..fillRange(0, 1312, 0xb2);