From 939f6b06ce1495a154c56eec4163a012ab4dbdfb Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Sun, 20 Sep 2026 15:31:56 -0400 Subject: [PATCH 01/23] feat(ffor): add variant d protocol primitives --- .github/workflows/ffor-protocol.yml | 25 +++ Cargo.lock | 10 ++ Cargo.toml | 2 +- crates/ffor-protocol/Cargo.toml | 16 ++ crates/ffor-protocol/README.md | 79 +++++++++ crates/ffor-protocol/src/amounts.rs | 103 +++++++++++ crates/ffor-protocol/src/book.rs | 166 ++++++++++++++++++ crates/ffor-protocol/src/lib.rs | 14 ++ crates/ffor-protocol/src/transcript.rs | 71 ++++++++ crates/ffor-protocol/tests/amounts.rs | 127 ++++++++++++++ crates/ffor-protocol/tests/book.rs | 151 ++++++++++++++++ .../ffor-protocol/tests/data/appendix-d.json | 92 ++++++++++ .../tests/data/extract_appendix_d.py | 94 ++++++++++ crates/ffor-protocol/tests/transcript.rs | 96 ++++++++++ src/wallet/mod.rs | 6 +- 15 files changed, 1049 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ffor-protocol.yml create mode 100644 crates/ffor-protocol/Cargo.toml create mode 100644 crates/ffor-protocol/README.md create mode 100644 crates/ffor-protocol/src/amounts.rs create mode 100644 crates/ffor-protocol/src/book.rs create mode 100644 crates/ffor-protocol/src/lib.rs create mode 100644 crates/ffor-protocol/src/transcript.rs create mode 100644 crates/ffor-protocol/tests/amounts.rs create mode 100644 crates/ffor-protocol/tests/book.rs create mode 100644 crates/ffor-protocol/tests/data/appendix-d.json create mode 100644 crates/ffor-protocol/tests/data/extract_appendix_d.py create mode 100644 crates/ffor-protocol/tests/transcript.rs diff --git a/.github/workflows/ffor-protocol.yml b/.github/workflows/ffor-protocol.yml new file mode 100644 index 0000000000..08558a74a7 --- /dev/null +++ b/.github/workflows/ffor-protocol.yml @@ -0,0 +1,25 @@ +name: FFOR protocol primitives + +on: + pull_request: + paths: + - 'crates/ffor-protocol/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/ffor-protocol.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - run: cargo fmt -p ffor-protocol -- --check + - run: cargo test -p ffor-protocol --locked + - run: cargo clippy -p ffor-protocol --all-targets --locked -- -D warnings diff --git a/Cargo.lock b/Cargo.lock index 5be41f2822..1c3fb98faf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1032,6 +1032,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "ffor-protocol" +version = "0.1.0" +dependencies = [ + "bitcoin", + "proptest", + "serde", + "serde_json", +] + [[package]] name = "filetime" version = "0.2.29" diff --git a/Cargo.toml b/Cargo.toml index aff8af778d..1df8187b07 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [".", "crates/bdk-wallet-aggregate"] +members = [".", "crates/bdk-wallet-aggregate", "crates/ffor-protocol"] exclude = ["bindings/uniffi-bindgen"] [package] diff --git a/crates/ffor-protocol/Cargo.toml b/crates/ffor-protocol/Cargo.toml new file mode 100644 index 0000000000..907070f8cc --- /dev/null +++ b/crates/ffor-protocol/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ffor-protocol" +version = "0.1.0" +edition = "2021" +rust-version = "1.85" +license = "MIT OR Apache-2.0" +description = "Experimental FFOR Variant D arithmetic and transcript primitives" +publish = false + +[dependencies] +bitcoin = "0.32.7" + +[dev-dependencies] +proptest = "1.0.0" +serde = { version = "1.0.210", features = ["derive"] } +serde_json = "1.0.128" diff --git a/crates/ffor-protocol/README.md b/crates/ffor-protocol/README.md new file mode 100644 index 0000000000..3c056dac65 --- /dev/null +++ b/crates/ffor-protocol/README.md @@ -0,0 +1,79 @@ +# FFOR protocol foundation + +This unpublished crate implements checked Variant D amount and anchor-channel book +calculations, plus the domain-separated transcript hashes from draft v0.9.4. It is +not connected to `Node`, the payment handlers, custom peer messages or UniFFI. +It cannot prepare an offline invoice or receive a payment. + +Work is tracked in [ldk-node #117](https://github.com/synonymdev/ldk-node/issues/117). + +## Reference and verification scope + +The normative source is FFOR at +[`d719161f42d1eeb6bd6c3856d564222f03c2205e`](https://github.com/coreyphillips/ffor/tree/d719161f42d1eeb6bd6c3856d564222f03c2205e). +`tests/data/appendix-d.json` contains the public Appendix D transcript fixtures. +The extraction script reconstructs the abbreviated D.5 messages and book using the +published deterministic inputs and existing signatures, then checks the published +wire hashes and book hash before writing the fixture. No signing keys are needed. + +```sh +python3 crates/ffor-protocol/tests/data/extract_appendix_d.py /path/to/ffor/ffor-variant-d-vectors.md +cargo test -p ffor-protocol --locked +cargo clippy -p ffor-protocol --all-targets --locked -- -D warnings +cargo fmt -p ffor-protocol -- --check +``` + +Tests cover the six published transcript scenarios, including the 483-slot book, +verification of their public node signatures, both funder roles, both commitment +dust limits, an unfunded receiver, exact budgets, fee-spike reserves, negotiated +limits, deadlines, overflow and public-channel fee selection. Property tests compare +arithmetic with a wide-integer oracle and mutate budgets, amounts and transcript +domains. They do not construct or broadcast commitment transactions. + +There is no wire parser, signer, persistence implementation or channel state machine +in this crate. Parser fuzzing, crash injection, monitor recovery, measured coverage +and cross-engine regtest remain requirements for the engine port. Passing these +tests is not evidence of offline payment settlement or recovery. + +## Trust boundaries + +- Amounts, peer data and public policy are untrusted. Overflow, underpayment, + overpayment and inconsistent budgets must fail before committing vouchers. +- Only the channel engine may supply channel type, negotiated limits, balances, + funder identity and dust limits. Application aggregate inbound liquidity is not + sufficient. Existing ordinary HTLCs must be drained before book validation. +- A public fee exception requires an authenticated local public channel to the + correct receiver, all four announcement signatures, correct node/funding-key + bindings and the onion's actual SCID. This crate only compares fee amounts. +- Hash functions operate on public transcript bytes and authenticate nothing on + their own. Callers must validate canonical encodings and low-S signatures. The + existing signer must retain all private keys. +- A valid book is a proposed reservation, not received money. Neither amount checks + nor matching transcript hashes imply durable activation or invoice readiness. + +There is no new unsafe code, secret handling, nonce generation or custom crypto. +SHA256 uses the existing Bitcoin dependency. Signature verification in the fixture +tests uses its secp256k1 implementation. No production signing API is introduced. + +## Required integration + +The registry `lightning 0.2.5` used by ldk-node has no FFOR receiver support. The +next engine changes must own voucher recognition, both-view commitment completion, +persistent ACTIVE freeze, activation acknowledgement replay, cooperative drain and +on-chain enforcement inside rust-lightning. A second channel state machine in +ldk-node would duplicate signing authority and is not an acceptable substitute. + +After that integration, ldk-node must provide exact-amount eligibility, durable and +retry-safe preparation, invoice exposure, recovery and status through generated +Kotlin/Swift bindings. Readiness must require the agreed witness acknowledgements. +The Bitkit provider adapters must only be enabled after these real APIs exist and +the interoperability tests demonstrate payer success with the receiver stopped. + +The first app profile is a positive fixed amount in whole satoshis, single-part +BOLT 11, one eligible anchor channel, with a `Receive Offline` checkbox. Amountless +invoices, just-in-time liquidity and aggregate multi-channel capacity cannot qualify. +The node must enforce the selected offline window, claim margin, chain watching and +fee funding. No production window or witness deployment is chosen by this crate. + +The requested settlement baseline is LND v0.21.3-beta. LND implementation work is +kept local. No native dependency, binding version or release is changed here. diff --git a/crates/ffor-protocol/src/amounts.rs b/crates/ffor-protocol/src/amounts.rs new file mode 100644 index 0000000000..52606d2ec1 --- /dev/null +++ b/crates/ffor-protocol/src/amounts.rs @@ -0,0 +1,103 @@ +//! Checked fixed-amount arithmetic from FFOR v0.9.4 section 7.6. + +use std::fmt; + +const MILLION: u64 = 1_000_000; + +/// Invalid amount arithmetic. Errors must prevent setup or settlement. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AmountError { + /// A fixed-amount voucher cannot have a zero value. + ZeroAmount, + /// A multiplication, fee, or gross amount exceeds the protocol's u64 bound. + Overflow, + /// A plaintext hop attempts to credit a different amount than the signed voucher. + WrongPayeeAmount, + /// The upstream HTLC does not cover the forwarding amount and required fee. + InsufficientFee, +} + +impl fmt::Display for AmountError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::ZeroAmount => "FFOR voucher amount must be positive", + Self::Overflow => "FFOR amount exceeds u64", + Self::WrongPayeeAmount => "forwarding amount differs from voucher amount", + Self::InsufficientFee => "incoming amount does not cover forwarding fee", + }) + } +} + +impl std::error::Error for AmountError {} + +/// BOLT 7 forwarding terms, applied to the voucher's payee amount in millisatoshis. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FeePolicy { + /// Fixed forwarding fee in millisatoshis. + pub base_msat: u32, + /// Proportional fee in millionths of the payee amount. + pub proportional_millionths: u32, +} + +impl FeePolicy { + /// Calculate the fee without reducing the voucher amount. + /// + /// Section 7.6 rejects a product exceeding u64 even if division would make it fit. + /// This check must also run during setup, before committing the voucher. + /// + /// ``` + /// use ffor_protocol::amounts::FeePolicy; + /// let fees = FeePolicy { base_msat: 1_000, proportional_millionths: 5_000 }; + /// assert_eq!(fees.fee_msat(1_000_000), Ok(6_000)); + /// ``` + pub fn fee_msat(self, payee_msat: u64) -> Result { + let product = payee_msat + .checked_mul(u64::from(self.proportional_millionths)) + .ok_or(AmountError::Overflow)?; + (product / MILLION).checked_add(u64::from(self.base_msat)).ok_or(AmountError::Overflow) + } + + /// Calculate the book-priced upstream amount with all protocol overflow checks. + pub fn gross_msat(self, payee_msat: u64) -> Result { + payee_msat.checked_add(self.fee_msat(payee_msat)?).ok_or(AmountError::Overflow) + } +} + +/// Check the two plaintext amount conditions for a Variant D payment. +/// +/// `qualifying_public_policy` may only be supplied after the engine verifies section 7.6's +/// public channel announcement, both node/funding-key bindings and the onion's actual SCID. +/// Private channels, aliases and unverified announcements require `None`. This function +/// cannot authenticate channel gossip. Never use it for blinded payments. +/// +/// Successful amount checks do not authorize preimage release: the channel engine must also +/// verify the slot, durable epoch, deadlines and irrevocable upstream HTLC before settlement. +pub fn check_plaintext_amount( + payee_msat: u64, forward_msat: u64, incoming_msat: u64, book_policy: FeePolicy, + qualifying_public_policy: Option, +) -> Result<(), AmountError> { + if payee_msat == 0 { + return Err(AmountError::ZeroAmount); + } + if forward_msat != payee_msat { + return Err(AmountError::WrongPayeeAmount); + } + let book_fee = book_policy.gross_msat(payee_msat)? - payee_msat; + // Compare complete policies. Mixing the cheapest base and rate would undercharge. + let required_fee = match qualifying_public_policy { + Some(public) => { + // The setup product restriction applies to the signed book. A later public policy + // increase must never prevent settlement at the valid book fee. + let public_fee = u128::from(public.base_msat) + + u128::from(payee_msat) * u128::from(public.proportional_millionths) + / u128::from(MILLION); + u128::from(book_fee).min(public_fee) as u64 + }, + None => book_fee, + }; + let paid_fee = incoming_msat.checked_sub(forward_msat).ok_or(AmountError::InsufficientFee)?; + if paid_fee < required_fee { + return Err(AmountError::InsufficientFee); + } + Ok(()) +} diff --git a/crates/ffor-protocol/src/book.rs b/crates/ffor-protocol/src/book.rs new file mode 100644 index 0000000000..052b5c9e89 --- /dev/null +++ b/crates/ffor-protocol/src/book.rs @@ -0,0 +1,166 @@ +//! Arithmetic eligibility for ECDSA zero-fee-HTLC anchor channels. +//! +//! The caller must derive every limit from the same current channel state, with no other +//! pending HTLCs. This module does not establish negotiated support or sign commitments. + +use std::fmt; + +use crate::amounts::{AmountError, FeePolicy}; + +/// BOLT 2 upper bound, not a promise of available channel capacity. +pub const MAX_VOUCHERS: usize = 483; +const ANCHOR_COMMITMENT_WEIGHT: u64 = 1124; +const HTLC_OUTPUT_WEIGHT: u64 = 172; +const BOTH_ANCHORS_SAT: u64 = 660; + +/// Proposed fixed-amount book. All amounts are millisatoshis and deadlines are block heights. +#[derive(Clone, Debug)] +pub struct BookTerms { + /// Exact amount of every voucher, in hash-set order. + pub amounts_msat: Vec, + /// Signed sum of all voucher amounts, excluding forwarding fees. + pub budget_msat: u64, + /// Signed minimum voucher amount. + pub minimum_payment_msat: u64, + /// Signed forwarding policy. + pub fees: FeePolicy, + /// Last height at which the peer can admit delegated payments. + pub settlement_deadline: u32, + /// Uniform absolute HTLC expiry. + pub voucher_expiry: u32, +} + +/// Current limits for a channel already verified to use zero-fee-HTLC anchors. +/// +/// These values must come from the channel engine, not application balances or peer claims. +/// Normal pending HTLCs must be drained before testing a new Variant D book. +#[derive(Clone, Copy, Debug)] +pub struct AnchorChannelLimits { + /// Receiver's negotiated maximum accepted HTLC count. + pub max_accepted_htlcs: u16, + /// Receiver's negotiated maximum in-flight value in millisatoshis. + pub max_in_flight_msat: u64, + /// Minimum HTLC amount for offers from settlement peer to receiver. + pub htlc_minimum_msat: u64, + /// Dust limit in the receiver's commitment view. + pub receiver_dust_sat: u64, + /// Dust limit in the settlement peer's commitment view. + pub settlement_dust_sat: u64, + /// Settlement peer's balance before the voucher round, before commitment fee deduction. + pub settlement_balance_msat: u64, + /// Receiver's balance before the voucher round, before commitment fee deduction. + pub receiver_balance_msat: u64, + /// Reserve the settlement peer must retain in satoshis. + pub settlement_reserve_sat: u64, + /// Reserve the receiver must retain in satoshis. + pub receiver_reserve_sat: u64, + /// Whether the settlement peer funds the channel and pays commitment fees. + pub settlement_is_funder: bool, + /// Frozen commitment feerate in satoshis per 1000 weight units. + pub feerate_sat_per_kw: u32, +} + +/// An arithmetic condition preventing voucher setup. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BookError { + /// Empty book, protocol count exceeded, or negotiated count exceeded. + SlotCount, + /// A zero amount, signed minimum violation, or channel HTLC minimum violation. + MinimumAmount, + /// At least one voucher would be trimmed in a commitment view. + Dust, + /// The signed budget differs from the voucher sum. + BudgetMismatch, + /// The voucher sum exceeds the receiver's negotiated in-flight bound. + InFlightLimit, + /// Admission has already ended, or the required claim margin does not fit. + Deadline, + /// The settlement peer cannot fund the book and retain its reserve. + SettlementReserve, + /// The funder cannot retain its reserve, both anchors and the doubled-feerate buffer. + FeeReserve, + /// A required sum, product, or upstream amount exceeds its protocol bound. + Overflow, +} + +impl fmt::Display for BookError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "ineligible FFOR voucher book: {self:?}") + } +} + +impl std::error::Error for BookError {} + +impl From for BookError { + fn from(_: AmountError) -> Self { + Self::Overflow + } +} + +/// Validate the arithmetic conditions for a proposed Variant D anchor-channel book. +/// +/// `claim_margin_blocks` is a deployment policy, not an assumed offline window. Section 7.1 +/// recommends at least 1008 blocks. The caller must additionally validate channel delay, +/// chain watching, negotiated variant, signatures, unique hashes, HTLC identities, actual +/// commitment outputs, and persistence. At activation, recheck these conditions using the +/// retained pre-round balances and current policy. Do not pass post-round balances here: +/// this function subtracts the book budget, which would debit the vouchers twice. Actual +/// committed outputs and remaining balances require separate engine validation. +/// +/// This function has no side effects and never means an offline invoice is ready. +pub fn validate_anchor_book( + terms: &BookTerms, channel: &AnchorChannelLimits, current_height: u32, claim_margin_blocks: u32, +) -> Result<(), BookError> { + let count = terms.amounts_msat.len(); + if count == 0 || count > MAX_VOUCHERS || count > usize::from(channel.max_accepted_htlcs) { + return Err(BookError::SlotCount); + } + let required_expiry = terms.settlement_deadline.checked_add(claim_margin_blocks); + if terms.settlement_deadline <= current_height + || claim_margin_blocks == 0 + || required_expiry.is_none_or(|expiry| terms.voucher_expiry < expiry) + { + return Err(BookError::Deadline); + } + let mut total_msat = 0_u64; + for &amount in &terms.amounts_msat { + if amount == 0 || amount < terms.minimum_payment_msat || amount < channel.htlc_minimum_msat + { + return Err(BookError::MinimumAmount); + } + if amount / 1000 < channel.receiver_dust_sat.max(channel.settlement_dust_sat) { + return Err(BookError::Dust); + } + terms.fees.gross_msat(amount)?; + total_msat = total_msat.checked_add(amount).ok_or(BookError::Overflow)?; + } + if total_msat != terms.budget_msat { + return Err(BookError::BudgetMismatch); + } + if total_msat > channel.max_in_flight_msat { + return Err(BookError::InFlightLimit); + } + let settlement_after_msat = channel + .settlement_balance_msat + .checked_sub(total_msat) + .ok_or(BookError::SettlementReserve)?; + if settlement_after_msat / 1000 < channel.settlement_reserve_sat { + return Err(BookError::SettlementReserve); + } + let (funder_after_sat, funder_reserve_sat) = if channel.settlement_is_funder { + (settlement_after_msat / 1000, channel.settlement_reserve_sat) + } else { + (channel.receiver_balance_msat / 1000, channel.receiver_reserve_sat) + }; + // Count is bounded by 483 and feerate by u32, so this u64 product cannot overflow. + let weight = ANCHOR_COMMITMENT_WEIGHT + HTLC_OUTPUT_WEIGHT * count as u64; + let spike_fee_sat = u64::from(channel.feerate_sat_per_kw) * 2 * weight / 1000; + let required_sat = funder_reserve_sat + .checked_add(spike_fee_sat) + .and_then(|amount| amount.checked_add(BOTH_ANCHORS_SAT)) + .ok_or(BookError::Overflow)?; + if funder_after_sat < required_sat { + return Err(BookError::FeeReserve); + } + Ok(()) +} diff --git a/crates/ffor-protocol/src/lib.rs b/crates/ffor-protocol/src/lib.rs new file mode 100644 index 0000000000..a743e8aaec --- /dev/null +++ b/crates/ffor-protocol/src/lib.rs @@ -0,0 +1,14 @@ +//! Pure primitives for the draft FFOR Variant D protocol. +//! +//! These primitives do not implement a receiver or settlement peer. In particular, successful +//! arithmetic or transcript construction is not proof of channel commitments, signature +//! verification, durable activation, or readiness to expose an offline invoice. +//! +//! The channel engine must retain sole ownership of commitment transitions and signing keys. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +pub mod amounts; +pub mod book; +pub mod transcript; diff --git a/crates/ffor-protocol/src/transcript.rs b/crates/ffor-protocol/src/transcript.rs new file mode 100644 index 0000000000..0a53995a70 --- /dev/null +++ b/crates/ffor-protocol/src/transcript.rs @@ -0,0 +1,71 @@ +//! Domain-separated SHA256 transcript primitives from FFOR section 7.5. +//! +//! Inputs are public transcript data. The caller must validate canonical wire encodings and +//! signatures before using these digests. Hashing alone authenticates nothing. + +use bitcoin::hashes::{sha256, Hash, HashEngine}; + +/// A 32-byte SHA256 transcript digest in raw byte order. +pub type Digest = [u8; 32]; + +fn hash_parts(tag: &[u8], parts: &[&[u8]]) -> Digest { + let mut engine = sha256::Hash::engine(); + engine.input(tag); + for part in parts { + engine.input(part); + } + sha256::Hash::from_engine(engine).to_byte_array() +} + +/// Digest signed by a node key: one SHA256 over domain, type and unsigned body. +/// +/// The unsigned body includes channel id, epoch id, fixed fields and every TLV, but excludes +/// the final 64-byte signature. Signature verification must separately reject high-S values. +pub fn message_digest(message_type: u16, unsigned_body: &[u8]) -> Digest { + hash_parts(b"ffor/msg", &[&message_type.to_be_bytes(), unsigned_body]) +} + +/// Hash the complete, validated `ff_init` wire message including type and signature. +pub fn init_hash(init_wire: &[u8]) -> Digest { + hash_parts(b"ffor/tr/init", &[init_wire]) +} + +/// Bind the complete `ff_accept` wire message to its preceding signed init. +pub fn setup_hash(init: &Digest, accept_wire: &[u8]) -> Digest { + hash_parts(b"ffor/tr/setup", &[init, accept_wire]) +} + +/// Hash the canonical voucher book, including its epoch, variant, profile and ordered entries. +/// +/// Canonical encoding and book validation are the caller's responsibility. +pub fn book_hash(canonical_book: &[u8]) -> Digest { + hash_parts(b"ffor/book", &[canonical_book]) +} + +/// Bind the current commitment numbers and both transaction ids. +/// +/// Transaction ids must be in internal byte order, the reverse of display hex. The caller +/// must obtain them from the actual signed, fully committed voucher transactions. +pub fn commitment_hash( + receiver_number: u64, receiver_txid_internal: &[u8; 32], settlement_number: u64, + settlement_txid_internal: &[u8; 32], +) -> Digest { + hash_parts( + b"ffor/commit", + &[ + &receiver_number.to_be_bytes(), + receiver_txid_internal, + &settlement_number.to_be_bytes(), + settlement_txid_internal, + ], + ) +} + +/// Bind setup, voucher book, both commitments and the agreed activation height. +/// +/// A matching digest does not establish durable ACTIVE state or invoice readiness. +pub fn activation_hash( + setup: &Digest, book: &Digest, commitment: &Digest, start_height: u32, +) -> Digest { + hash_parts(b"ffor/activate", &[setup, book, commitment, &start_height.to_be_bytes()]) +} diff --git a/crates/ffor-protocol/tests/amounts.rs b/crates/ffor-protocol/tests/amounts.rs new file mode 100644 index 0000000000..068b7a4883 --- /dev/null +++ b/crates/ffor-protocol/tests/amounts.rs @@ -0,0 +1,127 @@ +use ffor_protocol::amounts::{check_plaintext_amount, AmountError, FeePolicy}; +use proptest::prelude::*; + +#[test] +fn appendix_amounts_preserve_invoice_value() { + let fees = FeePolicy { base_msat: 1000, proportional_millionths: 5000 }; + for (amount, fee, gross) in [ + (1_000_000, 6000, 1_006_000), + (994_000, 5970, 999_970), + (546_250, 3731, 549_981), + (49_749_000, 249_745, 49_998_745), + (546_000, 3730, 549_730), + ] { + assert_eq!(fees.fee_msat(amount), Ok(fee)); + assert_eq!(fees.gross_msat(amount), Ok(gross)); + assert_eq!(check_plaintext_amount(amount, amount, gross, fees, None), Ok(())); + assert_eq!( + check_plaintext_amount(amount, amount, gross - 1, fees, None), + Err(AmountError::InsufficientFee) + ); + } +} + +#[test] +fn rejects_overflow_before_division_and_at_gross_sum() { + let product = FeePolicy { base_msat: 0, proportional_millionths: 2 }; + assert_eq!(product.fee_msat(u64::MAX / 2 + 1), Err(AmountError::Overflow)); + assert!(product.fee_msat(u64::MAX / 2).is_ok()); + let base = FeePolicy { base_msat: 1, proportional_millionths: 0 }; + assert_eq!(base.gross_msat(u64::MAX), Err(AmountError::Overflow)); + assert_eq!(FeePolicy::default().gross_msat(u64::MAX), Ok(u64::MAX)); +} + +#[test] +fn public_policy_compares_complete_fees() { + let book = FeePolicy { base_msat: 1000, proportional_millionths: 0 }; + let public = FeePolicy { base_msat: 0, proportional_millionths: 2000 }; + assert_eq!( + check_plaintext_amount(1_000_000, 1_000_000, 1_000_999, book, Some(public)), + Err(AmountError::InsufficientFee) + ); + assert_eq!(check_plaintext_amount(1_000_000, 1_000_000, 1_001_000, book, Some(public)), Ok(())); + assert_eq!( + check_plaintext_amount(1_000_000, 1_000_000, 1_000_000, book, Some(FeePolicy::default())), + Ok(()) + ); + assert_eq!( + check_plaintext_amount(1_000_000, 1_000_000, 1_000_000, book, None), + Err(AmountError::InsufficientFee) + ); +} + +#[test] +fn public_policy_increase_cannot_disable_book_pricing() { + let amount = u64::MAX / 2; + let public = FeePolicy { base_msat: u32::MAX, proportional_millionths: u32::MAX }; + assert_eq!( + check_plaintext_amount(amount, amount, amount, FeePolicy::default(), Some(public)), + Ok(()) + ); +} + +#[test] +fn cheaper_public_policy_can_have_product_above_book_setup_bound() { + let book = FeePolicy { base_msat: 5000, proportional_millionths: u32::MAX - 1 }; + let public = FeePolicy { base_msat: 0, proportional_millionths: u32::MAX }; + let amount = 4_294_967_298; + let public_fee = 18_446_744_078_004; + assert_eq!( + check_plaintext_amount(amount, amount, amount + public_fee, book, Some(public)), + Ok(()) + ); + assert_eq!( + check_plaintext_amount(amount, amount, amount + public_fee - 1, book, Some(public)), + Err(AmountError::InsufficientFee) + ); +} + +#[test] +fn rejects_mismatched_payee_and_incoming_underflow() { + for forward in [999, 1001] { + assert_eq!( + check_plaintext_amount(1000, forward, 2000, FeePolicy::default(), None), + Err(AmountError::WrongPayeeAmount) + ); + } + assert_eq!( + check_plaintext_amount(1000, 1000, 999, FeePolicy::default(), None), + Err(AmountError::InsufficientFee) + ); +} + +#[test] +fn public_discount_cannot_admit_invalid_signed_book_amounts() { + let fees = FeePolicy { base_msat: 1, proportional_millionths: 0 }; + assert_eq!( + check_plaintext_amount(u64::MAX, u64::MAX, u64::MAX, fees, Some(FeePolicy::default())), + Err(AmountError::Overflow) + ); + assert_eq!( + check_plaintext_amount(0, 0, 0, FeePolicy::default(), None), + Err(AmountError::ZeroAmount) + ); +} + +proptest! { + #[test] + fn fee_bounds_match_wide_integer_oracle(amount in any::(), base in any::(), ppm in any::()) { + let policy = FeePolicy { base_msat: base, proportional_millionths: ppm }; + let product = u128::from(amount) * u128::from(ppm); + let gross = u128::from(amount) + u128::from(base) + product / 1_000_000; + if product > u128::from(u64::MAX) || gross > u128::from(u64::MAX) { + prop_assert_eq!(policy.gross_msat(amount), Err(AmountError::Overflow)); + } else { + prop_assert_eq!(policy.gross_msat(amount), Ok(gross as u64)); + } + } + + #[test] + fn gross_payment_passes_and_payee_changes_fail(amount in 1_u64..1_000_000_000, base in any::(), ppm in 0_u32..1_000_000) { + let policy = FeePolicy { base_msat: base, proportional_millionths: ppm }; + let gross = policy.gross_msat(amount).unwrap(); + prop_assert_eq!(check_plaintext_amount(amount, amount, gross, policy, None), Ok(())); + prop_assert_eq!(check_plaintext_amount(amount, amount - 1, gross, policy, None), Err(AmountError::WrongPayeeAmount)); + prop_assert_eq!(check_plaintext_amount(amount, amount + 1, gross, policy, None), Err(AmountError::WrongPayeeAmount)); + } +} diff --git a/crates/ffor-protocol/tests/book.rs b/crates/ffor-protocol/tests/book.rs new file mode 100644 index 0000000000..7e28ba88dd --- /dev/null +++ b/crates/ffor-protocol/tests/book.rs @@ -0,0 +1,151 @@ +use ffor_protocol::amounts::FeePolicy; +use ffor_protocol::book::{validate_anchor_book, AnchorChannelLimits, BookError, BookTerms}; +use proptest::prelude::*; + +fn terms(amounts_msat: Vec) -> BookTerms { + BookTerms { + budget_msat: amounts_msat.iter().sum(), + amounts_msat, + minimum_payment_msat: 546_000, + fees: FeePolicy { base_msat: 1000, proportional_millionths: 5000 }, + settlement_deadline: 798_992, + voucher_expiry: 800_000, + } +} + +fn channel() -> AnchorChannelLimits { + AnchorChannelLimits { + max_accepted_htlcs: 483, + max_in_flight_msat: 5_000_000_000, + htlc_minimum_msat: 1, + receiver_dust_sat: 546, + settlement_dust_sat: 546, + settlement_balance_msat: 7_000_000_000, + receiver_balance_msat: 3_000_000_000, + settlement_reserve_sat: 10_000, + receiver_reserve_sat: 10_000, + settlement_is_funder: true, + feerate_sat_per_kw: 2500, + } +} + +fn validate(book: &BookTerms, channel: &AnchorChannelLimits) -> Result<(), BookError> { + validate_anchor_book(book, channel, 790_000, 1008) +} + +#[test] +fn appendix_d_books_fit_both_funder_roles() { + for amounts in + [vec![1_000_000], vec![994_000, 546_250, 49_749_000], vec![546_000], vec![546_000; 483]] + { + for settlement_is_funder in [false, true] { + let channel = AnchorChannelLimits { settlement_is_funder, ..channel() }; + assert_eq!(validate(&terms(amounts.clone()), &channel), Ok(())); + } + } +} + +#[test] +fn unfunded_receiver_can_receive_in_settlement_funded_channel() { + let mut channel = channel(); + channel.receiver_balance_msat = 0; + assert_eq!(validate(&terms(vec![1_000_000]), &channel), Ok(())); + channel.settlement_is_funder = false; + assert_eq!(validate(&terms(vec![1_000_000]), &channel), Err(BookError::FeeReserve)); +} + +#[test] +fn requires_fee_spike_buffer_including_both_anchors() { + let book = terms(vec![1_000_000]); + let mut channel = channel(); + // D.1 fee at twice 2500 sat/kw is 6480 sat, plus 660 sat for anchors. + channel.settlement_balance_msat = book.budget_msat + (10_000 + 6480 + 660) * 1000; + assert_eq!(validate(&book, &channel), Ok(())); + channel.settlement_balance_msat -= 1; + assert_eq!(validate(&book, &channel), Err(BookError::FeeReserve)); + channel.settlement_balance_msat = book.budget_msat + channel.settlement_reserve_sat * 1000 - 1; + assert_eq!(validate(&book, &channel), Err(BookError::SettlementReserve)); + channel.settlement_balance_msat = book.budget_msat - 1; + assert_eq!(validate(&book, &channel), Err(BookError::SettlementReserve)); +} + +#[test] +fn validates_both_commitment_dust_limits() { + let book = terms(vec![546_999]); + let mut channel = channel(); + channel.receiver_dust_sat = 547; + assert_eq!(validate(&book, &channel), Err(BookError::Dust)); + channel.receiver_dust_sat = 546; + channel.settlement_dust_sat = 547; + assert_eq!(validate(&book, &channel), Err(BookError::Dust)); + channel.settlement_dust_sat = u64::MAX; + assert_eq!(validate(&book, &channel), Err(BookError::Dust)); +} + +#[test] +fn rejects_budget_and_channel_limit_violations() { + let mut book = terms(vec![1_000_000]); + let mut channel = channel(); + book.budget_msat += 1; + assert_eq!(validate(&book, &channel), Err(BookError::BudgetMismatch)); + book.budget_msat -= 1; + channel.max_in_flight_msat = book.budget_msat - 1; + assert_eq!(validate(&book, &channel), Err(BookError::InFlightLimit)); + channel.max_in_flight_msat = book.budget_msat; + channel.htlc_minimum_msat = book.budget_msat + 1; + assert_eq!(validate(&book, &channel), Err(BookError::MinimumAmount)); + channel.htlc_minimum_msat = 1; + book.minimum_payment_msat = book.budget_msat + 1; + assert_eq!(validate(&book, &channel), Err(BookError::MinimumAmount)); + book.minimum_payment_msat = 0; + book.amounts_msat = vec![0]; + assert_eq!(validate(&book, &channel), Err(BookError::MinimumAmount)); +} + +#[test] +fn bounds_slot_count_before_processing_amounts() { + for count in [0, 484] { + assert_eq!(validate(&terms(vec![546_000; count]), &channel()), Err(BookError::SlotCount)); + } + let channel = AnchorChannelLimits { max_accepted_htlcs: 1, ..channel() }; + assert_eq!(validate(&terms(vec![546_000; 2]), &channel), Err(BookError::SlotCount)); +} + +#[test] +fn rejects_expired_or_overflowing_deadlines() { + let mut book = terms(vec![1_000_000]); + book.settlement_deadline = 790_000; + assert_eq!(validate(&book, &channel()), Err(BookError::Deadline)); + book.settlement_deadline = u32::MAX; + assert_eq!(validate(&book, &channel()), Err(BookError::Deadline)); + book.settlement_deadline = 798_993; + assert_eq!(validate(&book, &channel()), Err(BookError::Deadline)); + assert_eq!( + validate_anchor_book(&terms(vec![1_000_000]), &channel(), 790_000, 0), + Err(BookError::Deadline) + ); +} + +#[test] +fn rejects_arithmetic_overflow_without_wrapping() { + let mut book = terms(vec![1_000_000]); + book.amounts_msat = vec![u64::MAX]; + assert_eq!(validate(&book, &channel()), Err(BookError::Overflow)); + book.fees = FeePolicy::default(); + book.amounts_msat = vec![u64::MAX, 546_000]; + assert_eq!(validate(&book, &channel()), Err(BookError::Overflow)); + let mut channel = channel(); + channel.settlement_is_funder = false; + channel.receiver_reserve_sat = u64::MAX; + assert_eq!(validate(&terms(vec![1_000_000]), &channel), Err(BookError::Overflow)); +} + +proptest! { + #[test] + fn valid_books_cannot_exceed_signed_budget(amounts in prop::collection::vec(546_000_u64..2_000_000, 1..100)) { + let mut book = terms(amounts); + prop_assert_eq!(validate(&book, &channel()), Ok(())); + book.budget_msat += 1; + prop_assert_eq!(validate(&book, &channel()), Err(BookError::BudgetMismatch)); + } +} diff --git a/crates/ffor-protocol/tests/data/appendix-d.json b/crates/ffor-protocol/tests/data/appendix-d.json new file mode 100644 index 0000000000..356ae81cb1 --- /dev/null +++ b/crates/ffor-protocol/tests/data/appendix-d.json @@ -0,0 +1,92 @@ +[ + { + "scenario": "D.1", + "init_hash": "343331edc28e500ea03cbce26983583e66fff33dc9f096a3d6924b8239f68eb8", + "setup_hash": "ba34be8afe89e4457543a6e7279f86054f1d6e9038234b786b740e9c905c001f", + "book_hash": "8ae276dd460c7b2030287a608677bc51eb9cdef5b7cfc1f73d005e463076510a", + "commitment_hash": "723024696ad20b4758b228284620d18fd30750b7051cc64efca23a1417cf1321", + "activation_hash": "580a4a958e8131b32452741e51d724e0f231cb518896c743fa59de236ac6e716", + "init_wire": "d6d9bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff150400000000000f4240000100000000000854d0000c3110000c3500000003e80000138800000000000000000000090800000000000f4240b650a5af1658bafe768dc0d5b07af4556ee5776b010bd4da19cd64aefd40f1a0184923774f95b2d3d9e024283db263075e8ed6f793ba4553b9960e638fd90933", + "accept_wire": "d6dbbef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff15000000000000002a0120a0a62adc5092cd3c202aaea0900b5826c242194c5f9984070e833e58b273eda507080000000000000000090800000000000f42400b20343331edc28e500ea03cbce26983583e66fff33dc9f096a3d6924b8239f68eb898fbd933af0a181f69b015f155a48608d1295327607d85bdb03a9598b54dc58f6ac01b0421309cebbbaa596e62113de7e6fe2aafaccc281f535eda535525b3ba", + "activate_wire": "d705bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff15ba34be8afe89e4457543a6e7279f86054f1d6e9038234b786b740e9c905c001f8ae276dd460c7b2030287a608677bc51eb9cdef5b7cfc1f73d005e463076510a723024696ad20b4758b228284620d18fd30750b7051cc64efca23a1417cf1321000c0df003aa4c0c160338dfb3783120761fb4a6053836307e196fe3597032f28715b9a021074ec06ceb9c5f06af9854f8b21ff1b6b74ff3ae326a049de8270f2c8dc559", + "ack_wire": "d707bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff15580a4a958e8131b32452741e51d724e0f231cb518896c743fa59de236ac6e7161a20d93b0e00046483cffe0d9b54b0747a351ccaa3971a92aaf9245eb7cde5734f20a6ed584ccd30d81e833d9e89d9d9e42878b541cce5913a647b5ffc1ca9a3", + "book": "fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff15040100010001a0a62adc5092cd3c202aaea0900b5826c242194c5f9984070e833e58b273eda500000000000f4240000c3500000c31100000000000000000", + "receiver_txid": "c56247558caf290d78e2fe7d948d9605320c012b20fa181db87fc847ce69159b", + "settlement_txid": "d23aaf74371cdda6cbaf75b7390fdd75b3247dfe90c98d2e19e9802783c349b9" + }, + { + "scenario": "D.2", + "init_hash": "19644c62830070c3d7ca623b582f274e6e94669d4938e54262a1c6e6e6ed1791", + "setup_hash": "8465b03bd1ab3001129cd91fffb6e5129460cf82fb396cad2ebbb0ede8916396", + "book_hash": "09e4e3f8cef88225f7401bdcf539cf557932eeb035a265d602afba2083125eec", + "commitment_hash": "e7e996cb809a52736589d78d22b1a7c2a76b2ba07a8ac1f957aabc2651b0b9c5", + "activation_hash": "1d561fb397d1ac599cf6876a4f390d6bd81de4cd425b30383b14a5de4b4b61d4", + "init_wire": "d6d9bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884892436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d60400000000030e9ca2000300000000000854d0000c3110000c3500000003e80000138800000000000000000000091800000000000f2ad000000000000855ca0000000002f71c081950e84703f6ab745d5e93ed17fadd4cb4750b1b4bc04b01b18dd4051b0fc58114f5b24aa52b2a1c5c391d98621ce6a37512049c6dac1cbf048f61cb52868547", + "accept_wire": "d6dbbef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884892436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d6000000000000002a016024e4e3ab2a0cbbe957d5dc41c688721d956cf7babe8f304ad24b4589787bd993a7334c46d83bba22364ed8b863d3052fe24f62b973072776b537aaf707132596934cbfbc3fdfea5fc984e1270f06ebb219beb18ee02e8441acafa05ec7d928f607080000000000000007091800000000000f2ad000000000000855ca0000000002f71c080b2019644c62830070c3d7ca623b582f274e6e94669d4938e54262a1c6e6e6ed179105ec065a191d90cbb8a6adbeb0d1fd2838be33fa511588e2a3ccb83ce7313a0a1946d43ead676cd1be4a6780b5dd6feb03dc71f0c8d98056ce61304ab243d91b", + "activate_wire": "d705bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884892436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d68465b03bd1ab3001129cd91fffb6e5129460cf82fb396cad2ebbb0ede891639609e4e3f8cef88225f7401bdcf539cf557932eeb035a265d602afba2083125eece7e996cb809a52736589d78d22b1a7c2a76b2ba07a8ac1f957aabc2651b0b9c5000c0df01b5ca88c942d833703ab7702576abcbe1694524b37ef6b762f61358c9e2c31710cd2d600c69ef3a810d29a4b25705759de647e7b666909f0baec2a7af766eca4", + "ack_wire": "d707bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884892436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d61d561fb397d1ac599cf6876a4f390d6bd81de4cd425b30383b14a5de4b4b61d40e976c39f969145764a509691cd84aafedaddd873be2e3b35fbb42b55d43087b79aadcd67eb097ea539931314d7b8aee65758147094f78cd43736c0e496d2e66", + "book": "2436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d604010003000124e4e3ab2a0cbbe957d5dc41c688721d956cf7babe8f304ad24b4589787bd99300000000000f2ad0000c3500000c311000000000000000070002a7334c46d83bba22364ed8b863d3052fe24f62b973072776b537aaf70713259600000000000855ca000c3500000c311000000000000000080003934cbfbc3fdfea5fc984e1270f06ebb219beb18ee02e8441acafa05ec7d928f60000000002f71c08000c3500000c31100000000000000009", + "receiver_txid": "2cc9f550a3c31100426d327e5308e0ee875b1c33d2382097027f0db822320b83", + "settlement_txid": "69e73334f97a3fe4ddcde4f3161ba1112d6d5bc6953e95199f0ec0bb1b552e2c" + }, + { + "scenario": "D.3", + "init_hash": "196ce823fe02a2daea0a6a544f05f7b1c51520c0c323836663b31af3318a9cf0", + "setup_hash": "43bce577aa29c966a8c9a8bc614a9edfc4994f8ee4954414bc635cb6dcd745ff", + "book_hash": "5bb093a59800241c8f94a2ab083fadadda85a5d08e81b046e0dcf4623310819b", + "commitment_hash": "c034dadab45e153536220113ba43e4aeaf2a87391cda1fa33b9d7d89472ce5ad", + "activation_hash": "bcb6c726241e7b35d19c5e0f952271531cf2e61b4979c61cf8c817ebcd22da03", + "init_wire": "d6d9bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884898685960c118c83535c1bcc47e33666a775e26e9d1ca6cbca79aade33f9fe3ecb0400000000030e9ca2000300000000000854d0000c3110000c3500000003e80000138800000000000000000000091800000000000f2ad000000000000855ca0000000002f71c08b1ab264efb4d47b962fe9f5aeb72b5c1d67e5f0cd7709835f07a6bd58450fa9a048758571653c17a8c91eacd8750cbc7efe0bd648caca5c1861ed14ccad2c885", + "accept_wire": "d6dbbef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884898685960c118c83535c1bcc47e33666a775e26e9d1ca6cbca79aade33f9fe3ecb000000000000002a016021f2705152e9753f5d63b5a0bdbb1d3cd0da4d56d83e3d3853cd44c12dfe6f8458bdb1620d8f37d85a846d3f80a4e43ca084cbe1743d043d429de8dcfb0843c0dfe77b118b59852036e38b7409770e502899c35e8aced4a5d405ebecc167d0ea07080000000000000000091800000000000f2ad000000000000855ca0000000002f71c080b20196ce823fe02a2daea0a6a544f05f7b1c51520c0c323836663b31af3318a9cf025b2bb680e71adfd82af8c7ff8a10a871775112ca01901a0dc56895b5e1217567c4f5dada7a4da2f3e4211d7faf6db8664f59d1062d31b31ba83d4233462b07d", + "activate_wire": "d705bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884898685960c118c83535c1bcc47e33666a775e26e9d1ca6cbca79aade33f9fe3ecb43bce577aa29c966a8c9a8bc614a9edfc4994f8ee4954414bc635cb6dcd745ff5bb093a59800241c8f94a2ab083fadadda85a5d08e81b046e0dcf4623310819bc034dadab45e153536220113ba43e4aeaf2a87391cda1fa33b9d7d89472ce5ad000c0df0872c5320024fbba9ee9ee119fc2b61b48107ee6aed280b508eae7d233e8bc9db501f0541eb37c0649ef9526e75f035d355a6dede257c86d1222a728814cd8d82", + "ack_wire": "d707bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884898685960c118c83535c1bcc47e33666a775e26e9d1ca6cbca79aade33f9fe3ecbbcb6c726241e7b35d19c5e0f952271531cf2e61b4979c61cf8c817ebcd22da03b07d07776548693e5a9ceace1cca137794ef60f13c53d67ad6ac4baa64518e8576fa49931dd22c60ccd8b66d1319834cb6993d41df0b281dd45930f1b5e475b4", + "book": "8685960c118c83535c1bcc47e33666a775e26e9d1ca6cbca79aade33f9fe3ecb04010003000121f2705152e9753f5d63b5a0bdbb1d3cd0da4d56d83e3d3853cd44c12dfe6f8400000000000f2ad0000c3500000c31100000000000000000000258bdb1620d8f37d85a846d3f80a4e43ca084cbe1743d043d429de8dcfb0843c000000000000855ca000c3500000c311000000000000000010003dfe77b118b59852036e38b7409770e502899c35e8aced4a5d405ebecc167d0ea0000000002f71c08000c3500000c31100000000000000002", + "receiver_txid": "a351e3b1b081a909e61b6106a8c4684aebbb40c32f597aa2d2a0494eea7468c6", + "settlement_txid": "37f499ed459db4049a86c452b91740a254f550f678a0e85b6bbf063b1dbedada" + }, + { + "scenario": "D.4", + "init_hash": "b4a90a0eb194b5d7d283a45707f5778bfca2eda67c762e3f548520024f0cc257", + "setup_hash": "29b3ef77eec90177a34bf4d9785afd1fd6ffb2a28bad4ed571f295fe711c35a9", + "book_hash": "6beceabe7c2571353e03fdb7f82e3f4241c2467967ba67c2b9efebf32a4a6839", + "commitment_hash": "06602b655a1dbeb1059e87e9f8c0fefd89bed2a6501a5df25724e54d1c2e56fe", + "activation_hash": "982ee14fdd19b21558b89adda15b53f54122fc1c5a72bae5ef4261ee154cc776", + "init_wire": "d6d9bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848987cb2b4c1f4e6b9246bc30f7a7cb145dff0ad8b75153061fe08a31344ec15a0d0400000000000854d0000100000000000854d0000c3110000c3500000003e80000138800000000000000000000090800000000000854d0babbf4d69c60af8d49aaca2364c217375b270927d0340b18f630396bb6d9b1cd5490997cf98fc8ea3d38ef2f517e3a6e26e0ec9ce8c33ae503625e40a46fa451", + "accept_wire": "d6dbbef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848987cb2b4c1f4e6b9246bc30f7a7cb145dff0ad8b75153061fe08a31344ec15a0d000000000000002a0120ec5abb4d6d027af6f9bc6451f6cc98d95ad28fb9c20d67d3e51cb30614c15a6e07080000000000000000090800000000000854d00b20b4a90a0eb194b5d7d283a45707f5778bfca2eda67c762e3f548520024f0cc2575f178313b04c050db7aa8053d8fd271677247d8058ac49f3096d0f7abc1d09f75c82c95bcb0702117007a7ca143b106392a006f81cde971644bdad587af2cc7f", + "activate_wire": "d705bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848987cb2b4c1f4e6b9246bc30f7a7cb145dff0ad8b75153061fe08a31344ec15a0d29b3ef77eec90177a34bf4d9785afd1fd6ffb2a28bad4ed571f295fe711c35a96beceabe7c2571353e03fdb7f82e3f4241c2467967ba67c2b9efebf32a4a683906602b655a1dbeb1059e87e9f8c0fefd89bed2a6501a5df25724e54d1c2e56fe000c0df02938ff94475f468d79e2503c5159adc276699ea1681343cf53169b350b3046ae09015bb2005c24c4f8d9f19649523ed164abbe7f367ff2687cf8a27ebacfff91", + "ack_wire": "d707bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848987cb2b4c1f4e6b9246bc30f7a7cb145dff0ad8b75153061fe08a31344ec15a0d982ee14fdd19b21558b89adda15b53f54122fc1c5a72bae5ef4261ee154cc776e551b9a3347022ed74ca206fe49684de31f1150e6d3aace73807bf6055b24942176c45fb7eb36a13f2c1e3866e223cef4617b6740621c114f7278f68d283d000", + "book": "87cb2b4c1f4e6b9246bc30f7a7cb145dff0ad8b75153061fe08a31344ec15a0d040100010001ec5abb4d6d027af6f9bc6451f6cc98d95ad28fb9c20d67d3e51cb30614c15a6e00000000000854d0000c3500000c31100000000000000000", + "receiver_txid": "6c5524cdfb79105aa2431263458cc44849c91f1af2218c5bede450935b40b8e3", + "settlement_txid": "6fd405cb69cf32914b5de6c56cf73a034c29a1b8472edf5ef6a4d840535ed995" + }, + { + "scenario": "D.5", + "init_hash": "e0c80af58e7643d04abbd00763600d69e7ad33429020676adae4d4d341201b3e", + "setup_hash": "e012e565964e65d584adbc7138f1d115f6e00c27a90903e6c1b1657982fe5cff", + "book_hash": "556ddfc432a327526e84bbfe843070d8fca46b05d25b3a0dfccd406ec4f03a29", + "commitment_hash": "61034010d3510c0a44a53e0ef4a086226bfa039708f944ce4da9833f1ad5e5f3", + "activation_hash": "7d36f8f1728a75368594fed88f57f4b24113c1a76d673264b1a2b89c4a233365", + "init_wire": "d6d9bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848957d51e11b3a8feac73e8ea58aa6824b7bba69e045416f6ba4a586348adc40da604000000000fb8047001e300000000000854d0000c3110000c3500000003e8000013880000000000000000000009fd0f1800000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d017a3e152d4dca7cfe072f02634463aa789ac5e5a0c4dbaed0ce8cb7baf418ffa25f7b1a4f8d10e36916250b3cb6098ca4fb177335d1c24c78ed9e69cc3939805", + "accept_wire": "d6dbbef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848957d51e11b3a8feac73e8ea58aa6824b7bba69e045416f6ba4a586348adc40da6000000000000002a01fd3c607305b11a874bbd995b29b11d109eff432402136904a3bab1f7bd9f869109cfc1cbf141d9ad3b41938c25083328862acf711291bb47ab53b33dc37898ef9326b462e42ded68912761a397087a1310af972f9022330fbbbf24c580b9e71479fd87754bccfbb8e3ee44224e0290582ada7cb1dcb05ad968df63ac0aa01e81298f1b7c23775d75a9e3e878e5e33ba9cf7da51c78a4d54431bc1981c8524eda36bb0dbcd5fb4815309c1de8fbc2867eb93635d8764c565d952917457874fc6eceda18e3ae54a63b8f9526f7955db3417b5a1f6743b99ad0ca7cc92c3fb199c149b768bd471262b242310a4d1b298156a5fc3469395eb2fa3d28be02b88d8d0c970811397b027bfa95da000fac9dac5d41825bb54a0cd0071a262be00d6d4445f3e2e9db17a978e9e05effc0d7f4c33f7fb78898deec1a0b44212bf943818091fbc09e0fc65290b128e63d598e654d4a7bbe14cc3ef946f6fb6fe0d5b46cb9e0e263883f951c45254175c92549460b5a89d5d3c61a950e7e366f992dac9f7f7a7e6ce7fa9d083023788850024c7eb588fae1c7542604d23cc12f44ddd568c2320a754f3aa43187fba488b5530dbc366257f82058801f6983509716a9e9bf00db680f187404ba142c5ad30fb6769e7f6a84843d4c437bd50254a72815c8b75c1deb0c4b28ced94a2fc573f8c251838edfc807be39d1b910d18ede82aa7a127c5888b3a3a6a8f52194b94c6d0b521756e5c2c20ab5b6da8a2483e44aeb48447112c068042014e1e38afe84f6da72dbab22108ec05f60555504bac35bbfc7c62d1ac3621113667fb8817a03dd2f5fd97325ffaec561f597256d58742291c569ee251257fb30ec738e4babdd6d0b18b82a7f5a7415074cd283260197c4efe5d439671c9abc3e9e411c4bcb405bda68f31ef974cf91e5dc7a530fa8580048c4bc742805125a7aacb62cd9c823ddf004ca04fb936c3093029ddceaef569190534039885b1b8da9e7ea66fc54f78b5a4441c5ca6ebaa742485894509dc379783c52d881fad9e4063c74693de53572427dd71747f7a47f95a72b83b95f62b60be7479516593c53454a89ae1311664f5155c45544265e5b33911a120db3b683522ac590e64528b2db1d9b5644e6d6b7befb0f222e567bef23ec977fd3d5a6f146b6b195f48384e454755b708745f22c50b1ded9d0f24bc9b87d93d95046accde5e0d0c1b27ee3a01b29bd0c8af340c2ede7c9552fd9410b4c731a47156f54ae2fe2ef6ab170a3f93b68781e8a122e02fdbade74d727eedafc80663a79c32726b7d555e730f6b00c508a679821f8e0b736552664ffd03360eb8e08acc5ac5c653ce280885222ca80599fd0cf5ce9487aae5390f0aa7e2f55bbf36c7fab020879c262c74f0659b80d1d35e8a88f39201d36bb5d8603822ee851e4f936cc9d941eda8f0c132be674ed8b0edea50f2efd820966577036831199400b94a6a5ddc1efde5caa77d770fd938267950cb8f56aee3a11f312d601c4e2f49e229db76eebe9df97b467478fe8043e52b8a3ff4d5f870fa11ba4e1a7ffc5f35310840f33f01da137e5802ad25db1bb7507ed8a6155c9d7d58e9798f1822e143da862250702f237267f91bf1f6bc33dbe9c22ae8f8733c3a60d21c30bf7e51a304f53bac7c4a2c6e3cbe61157679598a89d04d4471120348d82a09c6588128fe927d38dfe26d98932961fc842764036a20a0e1e99e0e8f0f9d389acb4ce07130e58276e847f52bf8d17c2bf36fc33f5d5256f0c02a8e76d9de795b0a9a6624e694f2df4d106bfe32fa46b60400a3308853d9812b63820a7c738e5fc15ab6181cd391a059c248b700657d4b21b4e5db49ad03151b291227dc42af60dd203c373a9533afdc9875f4c02d08560c4533fdc43a956e852c354169466af263dd38c4f278c18fb54d209d9b65ef1828cbec21c3da0878d813c406978c150f03595e9a26d582ad426d14ef279edd0e8f452fbdccf935988fc435b5713186e04f48e53fe514466473a48ad5e0afad0d22fe464e113b733aec9e1d33dfd95556e546977a6b907fda9164c86f41dff868d4a2cc3f6fd342210a6de76e74742ecb52b84f482aeed8d2e0b0a843cd8167b4554f5d6882620e0b4f1feee1c51202c3b9623135d41499777d1341f63730f98dad4702cba08838987d86618a0dc670de584e3cb9602dd2f60278ea817a31d7f311e634d3f4fce46408992a463e8d153537aa72ed50c22cd6c76648191a9cb4b43de2b9b2f030755b807abfb879b08abe7a8b15adcd9efd2f419214fa5d02d099fda96106918f0ebfa087f6b72399aca062f7bde3577c44fd288090b9ff2718fc6f5cc81f70d71f8c06e27b949b91c969119a7cb85a4779bef0397a4d2a6847b49d128dd31f229a547af63a7d20a9603285eb3793ed667b25f4a22bd8cd94344665f31a8d872bb09a3b355b08b3bf759b436cbc36f6d645fc01dccf919454235b8cf509b7bf97f96e2d82c0396dd60d8a32ca53a9034f4fafb590a6f8b3f02e3d16a26bd1d44995dc923077293887ec5dab71deac2055a37fdf727d5a2c0d216bbb5a25f6fc5c81c3b0c8b25375f88a324a38ad92d72a73d2771886704e24ed7ab53595164a7f4d976600e0d3670d51fd1f1a08937a5247cd7314e78fc85a6c692cee5b1304c70efe0cdf3be30823623953ef905d37831a080ae3ae1d0c261f7921ab76752f4c9863d3ae76c166247d9225b19a416ad39f7f46ff197bc95f695201dafe676fbeb3ff3afa2700541928f23f11d59f8a56d40abd6b10086d70ff568c1b39d83a811fe4aa924595c69a33b636f0410f8da6c59683048f6a63d011eecd0fc74ab8a8b2d7759b68431f7539de4e7c40fcf22a15cbb3d472dd20a31d690bd6960c1305b0e61a9ea2da6d693234a815de4e712aa7a38fecf60291bb0e0af44276a434b51045e65ddc0caf75b30495e146c0076fc28951d7396a23ecff1690e2f8a9df059ca0da40a29d8afeebcfcd02cefa7cdef598a2673065f7d55b6617884c0ca418d14c97d01e4c0aad582a5174c09a21638342681bbcdf02ef3ff5f56cab237594556a4f9feebc7b9efaf166d36bc7011be23886a2c89e590f9ad2a4665a5ad51420b09a07509e900aaa4fcc6cf6bace255fef4d89c31727806446edbba51c733ad0dee32d9a52a4d62c9c96186e1afd55d35f54ddc21fa980f771e17fd7f464b71d7e34e559d518b268791aa82b54cec4fc2aefa34cc66fe9f21eb90948acc2d776c5d23a365ffcb4604ee65a09cce259c871dab39b04939d4f3d2252a3d6469912e6512ff7a494d00d8ab8977f1378479cdd5919a687a6b4912af46667e378f6cfb89eef63eaf08aa5d3f5d83c90698ba63169104be2a28e46f9d1ccca709e07c300817fd060a676575d6376ea8deaccbe2eec3714954f953826781831caf5d250305bc69ba1cd8af664d439f6469f65361e3f5fe870b765f99bf9c840aaf472cbaa57978e18a7beb1aab7b3231d10ca16d6900093ded5c8ac76aee2fdcd208f7cc7c79c678fa3d3af63e43f8145e12c509786de6d484f0fa9eefd4df65e28a412244c366997dc906ad005406191314722626a355dbbaa982075b3195b9164032bafa294351f3da469060dabc9ed49a700436b28e33ba98eb19575777b2f7c31d1c01ea89f750fd13cea775b223d7b33fb72787d312a5491919dbf1ccbe25364a6509a5cad019874190a2d2575fed1814db0ec63349e0d45da1ddef18ccd67bbaecdc0ceda6c4c73f713e4f2b0720eb3d477f514148f2dac77cc50ea510f6bb6e89201ad4cf91179cb56daa171bd244803fd2d2552f81d9e8613b9a3991e8f85ca405596250eaa741554d424ed40359b7bc9a29df7116ecf508708d8a70c87b12700480cfac14a3b6abeee0ce1529d0c8dcfe84a8ba767e9766247665362caeafc4b9bf93574c0f8d4bc94f57a85d9f23efc58c769e716f0d7b18b38dd969126f4e713f05ba71906ad30b5673030fe0d3f82d874741e2dc94bdcea49e102bb80bd902fbb168b3121206e194f44db5ef0cbaaa3198ecb971466d128f17514082cad26a5c16ddc9f61d81b789669510a05281c338443b02ba356ea887c02f265d9a20fafd71d0aa9696c1aaa52a8b570d3cd3aecff66d6a2ccbfa71e14d39ae6967ae7b6549b26551f919039be40948601cf75d580bd8a7273102944d505d98476373841a91019fc58c72deef72db55794552bbe06b99a0cc70b545c257c782011b8a630ed5a1d31aed2f71d4007347e50c49d0273abc634d77b7fd732c570ad83b5a1329af2453f9a24a11729b565921757b15b24342cfa0cd68926626d9e98244eb7266bac47dfe83ad32ea0aa6c2a71c6c6efeeb6fc94dbec2888836a0c0bd14f5ac63c549c1df54d26ebd93b57f93f3610f87dc19b15d6178e97a3c87c56dcdbbffe2ac6cd828e1885083843d6dff6883b91a547ed66ab4315d7e3bf72e89e3b0df85fbe1eff77b8841b90b0fbc491812b75ac167e0df33b212447b539ed51626c3998d5bb14679fe39d2fc267375317eec386d12d9805e45bd9082d5071eda545451d9d4b94201fb5b5f6623d970d3839e46f870d7fa3bf2d2cf7ece93865c1b6f4070c9bc30c2f29286d8eea729414d2828c3827d7add206c22b5d8fdf7662574899c10d0b4b4119824dc80a26595b4b44284ab1a6d95bf48da05e3f6e8deab009f70f621e12f36a033dc1bf7ce211100ec76ebbbdfbe1a8a296bbf3c96ed2d4e91b99750437ed50e90544e3ace2456c7fdef5e6d81d1c69797041264507da352e9f70ab879dbc4a2aa073630b6d61dd0e1eaf5bc215510ae551ed0523085244700d8bc42a08c99dab05caa6d4e00a0a3e974355e6a5cadd6a86049843e8fb4fde1fb78042e83c43096cc68ab22b2d41b945781c674dff62520d3cfd4c3b0a4e860f9d09f8e28525c44e2b32684d8ba68228a3f1f79db0598b2eea7bfbb7a54fc65f2cc087f91d4a54e89c6a9189994fdf807cbace173f150527f420940c82246dec163f6bcea0066dc1b8e42153a9a89cb87335bc874fef74954ae10fe330651d32c31fcab4753b4467cac95586300c264136f8f86aac3721fb165a82c392b6da093a4bd54e33100ea7cd77b5bdcf688b1fb691b474a10e0bc36362f743009fa6915ebadbfbfa27399c3a30f9b29b48e39e8e76a6bef920de3a577815323725370ca6a62de58f815237acb314bc3c4ca763f5c260923f0dd2b4fcc6727a4ae8c2ebb1d033eb969abd7476e90dc6b40b54f75b5f4d6d755679f6c097e07701c7cde480625ca923bebaa832bc17a6abed269b7b1d3601175c2ff6cfa4173d50ab54b95deae539126064ca1a69a831ac4bfc2c9cc419c6f1da7572f51c8bba7fb3d037affa75e8ab2b6784f404e9ef50f7a76b9382874b24086d5b5dd1cae9402e611c095147a803f5b408d27ebaa9344cb951fb073666fd308fa5574fad15542780583e26b83691532044687dce16a82ea097c13d339da899a5ee02a245471ab91354bcc2c2bb09ab2a2b96840d5447ba9e3b9db177f63bf1894ab759a09a07adac2cf5ee978ef0898161ab40e666963976b0dcbbbaae8a0d7f8b28014e2ab39cda053ec7cb75fdda214d03f85c30d9d137083645ec85d70119906c2768ff392d5d959b68e4eab28b75c719679c43ccc65060d8d9d1b55c56e410b12aed573b2efbc90729cc17c11f1947952c29ba79945746be3a3635cac70dcbef9b5f95315fb041ea28586d613d2a976d82d348d960989c7b9416fb3d56104da1640d95f0c5da8d32977131dc41a05a92f1a6876ca043a638240c2b1c8521f1114e58f57a865ff75144bdd08549c83c656431b680ab2e57f55501308f826216b83d7f2320258354c20a9069262030e08e266332394343063bcea0cb1869f329ea267c32697927595a930d79d6af1b4d0ef7eb48eca60a66b0b9295e281b05534527954935a783c0293c7338c17e95ca0e84642aee0b1357adda4264d19d6dfc5a1649f6f69ab5b4062727281bfc562602b8d23866140ff820bae3629c28d2b68bb8646a8a5d0c0a6d6abfc5db6d6f7f0675e2e502248d2c9868363efc858272cc637ccf66214c602f0155d3350d8410b27f835f10d7ed7480d38697ee5928580aa4dc5c25b4e7e181bacc5dc94f96cea194f4ad582ff9082e49f43af951f5f2c72b4a423a735be5b23a37b7e502b1d730cdf511b5f5dcd7e3dac491dbf42454bd7d0300ea2db54c07c732c435c8b390eff7af09d1fec7d4183f26c11af6f06ae8406eb3dd61b8993afd0aad49eaab19f9b363acc565df1eee863fd0cf189ae9e79e2f3402d5ec19a1b0c2df4f3bb8f8375692869e820a1e4071468c85bbfd561e1a4210d942e844c1b72a9418b1b4b3c4b0f6e514fe3b0f75e3d7df746e95326e400608445dec5823810557eb56baddb365cdba4e465f2498abe69cd50b0333f3fcd2a4d3bb7d1b6be07dd1f3c90d3cca5f5b3cedb97379673782f5afb23c30200254a9a75cdf945525320029f7e8c5aba45dfd5b4a426f346603348a66b7dd45325074aff4e140b03cc239486412dd0a130e3ec93b2648b95569ae55e5782af05d8a8b5c8d165c407816b8eb5e89adb271a1078cdca4f29315161593ec86f7695aa968f2a69bb072521c766134d9c1521b5d49a368b0dd00ea3b4e21883f7c55692d8e3576e981f7ef9fafcb12635311778fa6e9bd7544bb502e310113869bb2bfce131a771b67463a1bf5b20192031a7780c14e21a74d4ac5a0c4468a45998ae52267d49a6543212b263beea088dfe20227f97d1cc1a3b935bbfa0e11e47548413496ffa2340f37f578504f289f12a5fc253e2e534f83ed2878e342bd0187c8ac168e856e6948c8eb949d48e53f1c64266341a91c7995547f441d3f2bb7db66efd09c7a336cc28e97b5ff1e5c83d05c3e68257ddb2587b0c20338833512dcdce256bb41df47f9dc594b20b7aed566d3457cd3feab6a2ac54021ac61a65b74cd51304a8cec2aaa0fb13195fc09a06d571fcc8ac066797be25754ce7da18783c4682e93cabb272fbed2f0954226d7871dadbcc06fb772436fe4bd5e79a509dc33a67f4fce731aef9e25e456361ebaa95b8dd448180b3780a57dd5e996e155fc12aee0a1488e1db16efd197fe3307bb1ef3fecc70be961fb5d83f57293b23897427d8b7f44f79f3825b0898063c9be52e8430a4fc6556380971e45ba2bff87d41f30cd85fbd33a658c370afb29f2c452a9c176e6b15b85cb999c849c3e5fcc3571672fb1ed9a9b17e17dc9252ab1b6c57842adef570518d2ddb9ca68fbd6cd6864f0e18954a79e2d97b37db47861208d7191933965b8a66a8fa2192c2f037065395f2891d485e7fd8fb3acffbe35e13ee5fbc7ab4bd4b856819d6cff8e9b46054c1b17223b7dc16a717a0143c327abdf0ecec7a56ce9d4a42a187228d4764f3adaae664a80ba167ef9a42fe98f9e60997922edf02ab62b9515f3cc04cd742bbc0a01dc5c03a9ee981ff70177b153af6073d60b5dd62f266b099e2a9a4d4db5228b51cb37a70492764ccb4e974c7d238592775eccce4089735e69dddad210d68642d0821c9c1ef4a9e69d5f508ce86029fdc4601e2a1faeed4a8bbc0617640f5cea6be06ffb02722e4f7b8b02d33806acdf4fc6050e20ae54bb1955aafd407a8be9d7518052cc5fc266960839ca65bbdcb933b36eaf9b2f61c37192e6ba5effca7669ea401167af230c9b049bd02d00e1636f21319cdab24c46937d04d8a4ddb41eb1f1b2f6031fd9c8380f15b2928f042e9645332d6e0bc2f55e06778108ece17ffb8410e7035d2c12ede9ebc2c6bc81af83b27d172db05b66b10c7c7f48c57222e1b3fb423b0daed330e204543b2fab0fa9aafff31b387535ad322f5743c7b78d82c9d6f58fd85711958fcfd2824474818828d5ecb641bdeed41c988b6eec5c22e015bda7aab18ccb03f23a253bd3b94ec400a67d057cadeb092167e45001856df35eab6ff950e9ec80d4563255908a7ea09a5ae5dfbd0e3c2944295709ecd7e35957cfa2365934f72f6559f95997c60304b0a98e3c9e0dc99ab7ea6554bb0a2611012ec8ec892231dbc8ce89d60f3bb9b375718e460e7b5bd1b8e84631e291a9bca5d940ca7b8fb92c4baecb5a6e464d3c3b5aa5c12e67d9e70650327b858ebcc9d5412f7b492ac0203d7a82aabe23f8aa7776d173df7da0afc42147476614d9111d3ab9e16a2c977acd6d131284d2fc8c0d1af94dd6191d6a84344bc47221354eb9a2b0b9a696572dfc2adebd5f9177abda3769f294babd21480a6839801474b078be2982828fed4bb25781dc9bccaa624f6058c722eac222b4a659e9489edbe819151088a9e7aada910f638ca1a248bc94b4878b1031a41c101af8f35c9aef1f28de95ff9677f064bb695651008dd14631262188ce64c392f192d6efdf8d4f19ea9e5ffbeda1fd8846698475c057567d1f2c133c6d548f3e9722b3b8b9ec6960689a54e3457da2e4ec2a9b07cad6b256469eab859e2e8354b27bbb9f08c3ca7460f9ac1863a0d05133befb41a245dba3e136fe3e9de43e1000316991d135c420006cb6b8ace8eb6f74e97e484bb283a123132eb4976f0c665de5394ed0b02217b38bdf8c89b9b0db4eb62ff10e98308e9ce8647200922d8d790b7dac81bef8e81157bfa3420a29f5124ddf629161d0b986616e8aaac86278b838b7caadd8508b67b3796c8da1c71ac82a89c2fa7a8b40e2ee560c6df7ac42cc99e51645918c138b2fb62c7aa54b15597b8422142cd49a776092d55f92d5dad31e417844c63b31e7701adb1fc46ca8f1c24d0e24cc40fe7d9daff7deeb63211fbf5bc1c123c02e2cb25607821cfb7e67028ef96056f6e3f81f72db1bf8851432472936bbc6476d9392204247dfded154f8863582e44f0acd0a30a3bc8a38ab35ab744b73ca18192e42b479904bc5abf82fbb467577afe7f53632f5a8e89be6791a36b49c326f54412ac310f1fbc90220101c29f748304f9496740c3eee14b64c8fc17155835275fdd380ed620c5abc8a4160f18375928c0ed6a926043eaf91365dfdefddef93f0c8edc3204a6c47c4e9115755a24c31763efa78db17bfaa9645aabeb4972c89a83e02383dcaff8714d79fc6df28563584f79be4435bb3632958eb98649d214e3e136d56b460bf32ae200ce0fe980817a2bbc78112c9969c70db679c2aa30d73b4cd753d127fca8a62042ce2dae9cf035561530b3e2e95f60fe74245ce293dfb62ee0ed4ba29122e130bfd9a32626fd46ac15ce11642d63e5c596b7d23d6b142392b59998dda902b1005f23746254e625f4a70103d02b0ee3cd7cf6f19425188d040c9ed7f1af1ab9d41814211ca770b40c250364d4a26bd0d57b4d6de3d406c8cf54bb912bf745b8761c10c6ed566edc96e82ad8e7a9bc3fcd22fb0e3974cfb4a81886dd94579a2ebe3c14379490aec6fd8bc8bcd7981094f23011e1690bf6a853b474acbb9539a6631f51144498802e9eeb237d8ba7d11ac050c4a392129afcc08ec36aaac6cb7b82a482059dd448f61a9b25249bc5d4e6be5517baf5f5e242693de4032c7083eed7fb1803c6d5624a7e32d3107bd7a3939cc89a083388264bf9756396a45f40c30da3a0a67d4dc1838b20a098e5351a1e4f344dbbe00dcb86780b34031a24731ecf7e0e6844097a8e62b43267d565dd6d3a4886e556206789bcc1624846430d2de342f54d7b8e61d264bfa39528a1fcc63532b291c994c2ab7a217a4e347a614aa9e651bd234eb0c3d49830a6a804e7ede79c77959ce14f731d361f1504f6625c8289222276f9bceafa3281738e27e15f6547884c9ca7a5dcfe2501594d57b4c25674b575507428defccdccfe69543a182575dd4817b9179d45eebf5678f6a340be93a51d2e263e764839c2454a10bf8853fac76861923dc3a5d81abfe6b8ac5e302b89b6057f782aad54a65adb51ed77e50d3a366db9d7a20e6556de4b9b77a9d866d3bda9f4a87208766b0cc701414a4deaac8da2acfe5abc0a8294b1cc8edafc68b3a95d9263f1499a02d63c93a0fe30633ad556a5b446a1078aac0d34309b68c23dcc3d7da53e7f88c24e6ed76850021ab5ac808d6b1a412173ca93c1a3db66b0695504c0151d21f1e65e417d5671dde59931256ac4fde601a904450d4f2e168e917bf4438d900cd17f571719d60bb900fb0e0740d65251a8a26ec67cb3ba4b95f2ac4be486745285e9434dcebd8f7df9f743dbd36530d20396497388e26286a2a9bfef575a2269ea85b36b4005d294302aa053758620abd37177996abc69e2ca24b121f45b32718a095250c0d533a89785fbc1692f819491b3619b64e09337ff2b31ed0a98c07d47c910346fa6e43ff4da9c16a43170fa38ff7b2f735c6d7ed6697968cc2792e9ed4c2cc9957e2152c773a23e109690d82876021b20bad6dd3833506b1e2c7ddb85e8fc754b11f0099c558ad676db0d3f7a7c70b38e38652fe97203085488d82bc013586ad8a093aeceb4deaf2ee93bc0e5f3123292708627ace19965a0fb622f8eef55e33dd21febe02b2fb6dc2025e9c9a099ac9f0603aa645a573caf5b99f8eb03b28e881010b96d87e1ed55ce341d875edf3e4221594917fca299d02b1c3f1990f6069f944330c3b2ad9edcba7b48640f071c6bcd7c1a94314bd8615a6f6493919fc216a555b9dabca0121775f5457e830ff6e6eae43268bb92d5fecdbf8832a37b575285b1e113b29ea7e5f8c955b2b3b16fa963985019f4a6cac17b51fd164b85efd22180221311ba043e1688eed5a265e23b6fee64a6800737f859959582dae01bf804141b7dc199139dc15a7920b76551c9c0381e120a166e200e20e3b2e0d54267bb3c9f69a0a374af5f53550cb6ae690524fbf6a6845f2df5da2d7e4aacc218263bc216db4be3c269ae52b3ee1187678512ea1324b3e746df8c76d52008263fd5ed6ee3307608b02a7e66c0c14618d3cc34a24260fcb5c7819db74e65d8eca279451104abcf31eb27a7fe64108c7261a446b472fa45a0c29af4b821210c8aabe6c113d292b6300224dc2cbcf946cf2868a4749a156c578a4a6e40a2a7bc1716b35880eb9d87a4748ae09673465d6084beef0010d1ac1e7e2fcbc6783d854f3927e6d51f272c52bcc6bda16d175c4b0f2a9dbe8437a34789c0343991526bc83bdece3c71963f86cc51183a34b71da18c6f995f91aac176268d6ac0a3a2acf2717801ebf23b9d91926e9144f380ea1d68711d9db0ab45e3397dbd39b5e728dcec8d55930f6f2affb0ca12c5834434e1f82adc0b7ac0bd9ec0d4179d36e2a611ede605a42977de8a9e466c453479e0daf8b53f8c04ba0eddac3848dd88ae4cafeecc59bdec10b881101c883b61b82153bc8cf58c32d180d4f46da8f40ca6d8a3fff5ed4d5ba16a1fa73d4e67d00cfde8a915c23cda075f4a88dc0ae74792bd8ece23dcfdba72f56b9813b0ebd559de901dbb27927b10cbb56d57e3b244a9f5c248ac8d85221547ba9dabfb3dc68cdfd7846524e752cbb7ba1aa0e495352325399e03d91a7016e3671fd964d591141f42588c3501522fa211cdffd484c7889c2cc6716de25654169609657a49e6ef1453006abcf1da0dfe1572ef43a2082f91f230b0f14ce55a6361e8650b9212266945cef53747caa27b2ecb733afa5afa42bac3afee722206d2d0c5abb811c7ca46af876947a45686fa1f11f8842a6ba14ef982a40f5138bf420c6f7317623f1661f5435f16624c0e51a7a644ece8fe3d7f84af61a410787b30feeeb966d0cadaf4cd6e8d42db0682daba7a65e6a51434b843b5cf75213ea4427e0319f4efbc7d18e01e7f39891798b254ec5fbfd4066bfa49c634f8e3eac8fb5329aeb7439cc3be9f123da1cbb918809e10bee97bdc9fa805b06c557bea2210471fa33078f1a7aae9140ccfd98127b56044d9104e9de5fbcd682859598eef0d6f9ca92aabfaa9ff70179a7b0eff8bef5d45379521f1110e8db1ee03b96d579764c8c344c607d1b09149d5c5275f85a10d8cd9202c9400ca2def4ad6791075291b4c3c5df3ca26248920d3f7592d14d56435f07ccaba5650dfb6a88e4e39075376402718a3dee81eaa499f230eefdac7766ac46fda3d15ce50ba634d5dddaa37ed8b27706fcddf241b4264b637a0ac3315b33d20ad2b4ee78abe9b109cd2ff12910527db052073acd612d2f8b24c44a0d791c8e458cb4209a578c5e54059a80bf078ea62f2f2bc07ca3c0917130c0549bb4528b3d30787e8a58e1a4e395f797ee1d5313c861adb3bb2a1d5a92c063c04d8e4ccba90f31d32705ab9e5bf9937abcae9bd411316d6fc2b34c08eb4c4047768a720a11c236f83a193eddbefb5f177811e1cb748f7ef1e699a40d95dcd4c155bfe82b2458ffc85aad59e9a7b9a38b6f8e20129cbccd2d776df1d26ee861943082701bbb4b69249fadd8f143b597ac73e2af5c1c556ceceb7b569011a449af0c305c8dbd28c6f9703aed425f8373c8497188b2b9eb2f5712de794c52523a62778e83e0e59a1bbc6f06f97b7789670f0e5039ea9a1693cec364193e49f8f6aebf42955d1b49acda6c888a3a6e2ee274e372a45494b4ebc0b53de00bce7dc1119c28cbf0648dc7ad74c976e4b49b1e5ab6d372fe7c87dc46f6965e74bbaeb7789ee532264e843103a39d7a9d75132de7e9b5ef02a5300b1c69e8c2aee8a42f849b56150603e0541ba97656911b2bec92fb6b80e1c47be8e25448c1ed1d4cf6851269c121e1c45e3cd58292714ca7a0afcd8c6813dc142ef09220765bb717aad3c2d4dcfe180e8c0ebd8421df820386626d3a85e4d29595e3312d192af31f4735e7352203a2b3e1ff5c8712247ea76b8d6521328a281bb01550134b55966c3b8a175688d7fbd913988eb37701053abdebcd07b2c0ee2c8b44418ae4ad010eb4a16252265eb6e1ffbb8238ae74055e93170d5aed29599996d1a0367ed61db81a131b5b163de5d6f2f693055b87a3cc7a0c6a15bb72ef826b6c2e89d781417985751c1fde5cd6069167fce4c56138370bcaa24d03ce24b9e7af1b72e21a2785359677d3c101e9029b117a43dba617f8ea56d7dd022d8d72d2e1c3b68cf4b0e8bd492eb5741cf78a3b36847fa0a97c3304a95dcc7a8a1440e06ed3926bb95bf57bc7ee2c9e5f1c5bd661bacb1ce5a1a2deabe6736c83678a6abd820de07f5cda1819cd9a9146f0adf4d8dcc60e3cab50bc5e1abc14891e27ac610ff9d6969b53d28f85a92a1fad522b3bee82f76f010f45ef0a3e4c7c15d7255670d53527fa3d330df360ac6c98d49a6a5ce3f6fdf1de4ca700844c8fa3e1d6d6e357f1227e5e942653388260e8238b5612f4927fbfdce258bc4d0b7594354e9dbc5cf7149868716b45dbdecdc449d0b39b5eafd69e3b49a7ed29d4459aee285b863674b89d72cfb884f5cd5eb523ac0b493661c58f7851c5547a959ed81819532ccb84729849fbc4cce0ac5f8236350c3b67b316338e15cc62b954d30b4d23d76e2a5311b80e76c5229b01cb07f2bb85646fdcb4c62adc82f198b047bebf661b595023ecb62cbf93a64d234dbf889166b48fa8eb394bb9fbe841e1cd5660513cfb510f00b170cf080b3ce2b850cec369d35c0550eff4b8fb2b6c91dfda8b9d1d054cf18aae245c31e11c0d122ed5aadd8a3b35ad39dfe30ac8798a46d23e06b5899ba907b2923ca8677fa61c7f0943cdd7c1c40def5d83aad630bc00eda293692db9029a74afbc68d94e574decdd9aa7f388e8aa344379018c4da18256e6c2b3b309d75e679971f71daae4c69990da8707c359765d2c1f74a56ea45a25191b688dbb1263e7d9c8898a7356dd313e14f066a6db7dbd8a05af623cfb142305d9daa987405ea5c478cc99679df047e1cca5d28b2ae550d88570c9a7b361b462d4bf0f568c72724e502c4ea615d9bdfe76205af4649db3381bb039a6c81d5e7387afecf394d4357604f540282bbd442f84f456c5ff810d7964941b31bc12f5fb19741e64da87111ec3ab8a714db12a89a0e19cf667a0049a9dcd72aa5c30cfe041c4bfedd2c41b363acd3fa746b0e65820abf0259851616c147a62539828606bd4d8149d173108a9685d7305f1624dae2c8dda08e475d90494ff93e3b68d3f06807c6439794cc48e36826e845ad919d758bc0308611f2b2063f35e59badcc28b94b58b11fd0a16ba852395acedb03563c9992103fbb9c4541c0140ad25bba0d98f301ca34257ff0bb60f8a29e8c2e75569f12c2e757cfcd86d441987754db98d33a298ca334510f32a78b0d175ef81aa1283c5a43a0a36997337a188cf28df16535f74d7b79af5ab10020827567deb1d7feb0d68c97b24375444fc91685c3d4cd31ecebd30f110cd6faeddf43207ca9ce128876a99d882d1eb2956ac66baeb680dc9f243390f1e5ff5f824b3558f3a71558ee2b76b6dc85b8767d8c3a762a9935e665b8a599899c0e0c8e20d270917ab23a1c5ec1392031cda458edb1c95f47f390439757563240273ed69da2e0b49158ffac612227c538ab4df21e668f072475199d7a5f1d5702a5e97030d9b85af5459e59ece07c34cf4eba44fc2fbe75f388eb334984eb5a14d76478f8c7e277804cb813c573fe185e1269a0b1b3a7c4af2934a43425a844e0cfcdce1f191ac83c216aabfe9cfae82ad8fb80e5ed32fd953e4182cabfe4bca69a2b2d26394b851e6f313fd7400e34f66b207fa7997336eebeca3c2b2abf74149413af897d503c0940a04e65488cdca79adc80f3a1022fa7358cc7525792ec60cad166479beed863fbda52d75a2b3b21b44cb3948c74c3e5084911fc1e172537175b5cb1ed8e0ecae73beda0b4e5e13b826819e1dfeab3d9415167f1a6ff33eb7eb3a208895a8bc11057d6fc0e3d3cde21eb23a9e8f0428f6bd1174aa697898fbf5fa97167d246f94cc930add143f6b5f3ba1c189160fe99630f10f3c50d48328bf9fb5ecc61598d7fb7da587fb7821e4b42151afc4ee36ddc396d879f620ece1af21eb2ae7af3ccb63520b7e5c2c86ff9704ecfeae2897336074edd4eb00de98fd3f92fa28e76e19abdccc7183a427e0f80f25450992490329bd6b4a05c40900c16d963d4cb2c32cd3b866fd2704809359d4e7be2b4d3c9e0d278e14a883573033cdcdbfe68747f69c02a77aee2631a53854877cd977f8ccc619d317d853e8a8c9605c6b2016c5b77d1ed0ce2764865af5bb58da60d25fe091da59c8e15c2d0b67a0c03241ed38bcaa08afe611e374ff56a04b1493496c051d3aca70ad269740eb8143d40c40ccd9655137e1a797db99c19f0c74496eb02e8b3f8186945c665a130ce8bb5022be66ee9a82d58bd04995dc2ed8817b9cf5382bbf5437a38dff5538c0853418b67eed524fd6ec6730af6e83fd7e1819aa9a9126f1cdc86e71f3a885ba96d926e619de1dc600a5e917556b59349709a09202f42f8ff596b8d24eb09eadb09653801d690e24c1fd00201534185e5d76616e75138866d0eaecd45ba609331dc127de1cc6de73bedf6c8857b39d7bca574d5a959fc5edceefdd5ba830ee696fa281bfaff5f19f03a517bcc14f2a723cb43db17bcabd47e25adb0dc27d1c5a063dd640ee22ac5716a1dcdbe152c27c2a4925deee344080f378bc7003840f1d01437c268fb20c7f9bf4b38cfaf1b18bbcdf7368fc5130157d7beaaf191672619dffdb99204c2ab01b35d299c29b4a4c9f1364570df139c7812864191abffe378364b46c4929ce9f354e57994e56ef8690a9adbdc6b76b343cf768eea6ea9430c144d1f833d2fd19f01a0b8d04e09ad0625c0c1ce47f182f63a888b037893c449126093bb70f97b7360b903cc02705619b8232cac97ddb46b199bd616225b980e5cf964c647898a44241ca45335f679143b0097d4869d4246b117a38112ba1bd600e78490ba170a1d1307043f3c83c2a1a2d50106c905f692aacc895ec966a4c41045525845352c1993f7d5fad4f836f286565c62b9051eb76af4a34d66106771401412f01f4ac661594140355541f815f5e1e74903b7e76d9101f61768d4ea6470dbf632941ad250c20d2ed9b3a9b62fc2ae398c0e5cf571d20737997ec5f0f856dc59a1104d5b153b9377cab0a3107dcef4ee1c340bc27dbed94378bb13a6b1bc1a61789be9572d1d42a7611338d5a3e1351768d00e63f7f71c19b7eda6368a15dc5dc2e7295541888e60738db15b3cb792926170144788ed1842d11dfc84d06d656f2b8390e11778066f21d5f97626bd72b475fdc4642f1fc647f5c8efe7acc255ba6bab55d280a095546aaca38921df154937581e52b4f91a493d81e0ad67231f835c8f1ec2fbab50d1f4e5e324504000ee2445b037cfbfe23395592f82f1ee8d5eab8a2277d8e3644908c775b16396774338f5d3781d0b608c534512544055182fe979fc8d439f5c49b64cc948bd6f7b6314df800b2593788c198f7321ce6424338ef7e83123e241aa7fbe8dc38e7c1e3de86297b0f44eae74f3a3bfba8c15b94663e93067a575289e7034c8ca6fb8fed4d1cd87ab47f0d8959d60eb4147714356816f6fc95b0fb1f92abfb087f9fdd9721a6015c8f1a325c0740647aa9af8d6f41b9b364f2d6509cba4c318784ab5f98664fddd3765c9c21ac14cced443eed9254bb76cf595dd19763bdad8f3762118180c3305dc8d8d03f1d1b79383291c9b91c6dbed7b5868793a94d86f8cd0b02ea9dffb9285e8ae6dac04fec83f99de4cfd81186130119af91f3af4c2c2ed9556cd73b85f9b9c5c46848b51684d15e7a018c16c7433251d4e05bf0a6fbb1c22e26624eb356f2fcca2d099ddf402b0f28922c8be5276c406fb6a262a10e1ba2d37c1310d2361231644dae69b1d58f83ac1795d89062f1d20f1e18bc58a38beeab8a143d5fc410231f56ded5634a2b827172d7ff54326b4d9ba55aff6f76dc0c1d8abe734a3ce42369319689c90263bcf693655b2b5f73426e4299bb3edd27a61fcea84d6529f4cfceb408d8e4db8a93a81948458df679039b6b286ef8e2d6b392e584f7b652a19f302298f2ddedc5bdde42af0c1617f981d5942d33bd7bb436780ef950578799b9c840639e4d0d50fd3e6e4728c62c0bd0378096552acbbd4096572acb88eb8760d4cf5b9077c2871390b10c3af43aa24de7873c145ee0efb6549c7d6ec662d3d9dff9fc1daee14bac71957f258d155d3cf5351507f7f9c75f9fdf97501e0dd1b9635b59c05c79911a8d80bff18b7575e6429c8d709a869e6a06e04c30bfc1ae8163736881a06d397844694f4c1b611b2ec421e3e85830e6a0b06e76264103f4ec21762e94c3a6176166b0112f1dec7ed9cec100950fed6956966a114a747369da11f17dbfdebe6329cef69ae7150b9003b2a03efda39443b9958f8154c93127c2a56004f8d0a7a8fe5866174cea89697bf41df5e122ce7a23215e2d60425e789b4b54bce1b239c8f012e156269e109a865bb46cf6d615412a6bd1e060a15d04d386172988a10fb74a66f2edb3798cae4ae180cbf1b110e1ce447d1cdedd14b28e04b1008b7716368751b7281fd1c56efb14c4bda843b2e3e56a6dd167b08135c514ba440467ae403e0a65e81f359ae4241789416377f7502ce739f6c304d1153cb1b5b24b1d021059395131e760ff239f5ac04487fabed4265acb7f43a59c506c7f1424e8f6a02cbb4b0488a8253ed13971bd35d9857273fc03d9c2e3edefb781b52260a9d6fd63130f1b8d6d603e4247eaae108d635e2e5bc04c5382e5335d54f7383e77089b8fdd6652f3fef12fb0f70fb2a84d2affb193bb1b7095fbf3e5a3c2868086d150869804d22d5da63dba8992833908943ce66f90dad171afed9687c86989821940a117f7fc601ff678677a12178775caca21a09249da22af8ce826220eef839cd84d2b9df12a0f0ee5806b4aa19c173a92308a9f08454311921efda69bc2cf34f1e47081b44b6400291eb927f4980a94f3134b111a2523237c353aaaec137e0c9db2d33427135e6f8473ea35384b6a3eb206af450916ed1f3a2a2e0cebabac99e3d67147da573f39fcab1746d2a05d2e22a060af92efe969375113bf780682741b62602e90cb9b3a50132ef3443924c0b93b7f3c0ea80a6b5085704b4b6b823059acda09858529e4e9fb542b52c668b392bc2900378b079da08774a6ff7e6968eddad0fe921d39051caaf3c2bc2aa047102baa574852e7ee6006b57c070a434ba17e69fc30dfdd88fc3faf7c00465df22f22fc575051deaeb004427e0c3795eeb74ae52d8eb908d6395d93e90d93f113a941d728c7a186625cce4babf6d48d362e642b15f5234e346d0ad56e01eff852be1d4bad5da4d9f7de1734b52094a5045f4d3a962b0811c40148ab37a8cb8949ada98b9bb2ef98ef6bb35d733dafb8788f89a35fc734c6fae0c79c7ef79d9ab79a619331d5991bef608ebfa1bda8ea7044d7614459001856762aea6fa2a8da8a84f63b35b784aa95efd4cd712e8d819797cbdd2e5eb14ab8479581992c5cb4fefb71acd21259c98e469abb620208408b16c28a5b22a408e86745059c187e533738786c8321e62d655b7d42391295649268e33972b9b05e3863b06e927b1b6821f6be5c22cd5b323a71224eb2c47f79b4b7897332b4edf21831b271dd31265ea82a56ee423fbc51d625238678138161a8a222110d7fae260b0d53dd718d8f0bbebc6dbf9f7c603d0998478e02c1ccb514f538e60d2d42bead26565df3239926c7ab2ba9e2556375ccadb273ea32290f6b387130461ebd022c6b559d4485f2dded4dc866794b1952edcc463d2f12315dd333110aec4216d3ad68442ea011ebbf65cc03d6fc4def28d26af9c7b0ad6986d2014ee1c344720f7e5ecb5912d4533e3db9cec5abf3766fc37b0243c9147d7cdb24ab677cb997793184b4c6cbe078a2339c7050553debf100b65bf566728aa8f0bd3b5f8b03efad99fe4d86570c3792a009bbd23aed885167c0f33fb634f5b1cfad7cc130da55119a2fc282e4975d40204af4084a9df275310d4665d3a1c11e4bd872b0b47d822fefc75e8010f2766febba4206548da19e4e5998fdf3f51aedac8c428ebbe458f8039f8da6d996381998888846d7d7eac934aa1e7b220a2ccb3d28ae1fc383aa4319dcb8df1a554f98e360ccb1b630674b00296a7c4204e0380ac993850b4041042fcff74542ba713574070c2982e5fe06d1bcebf698d2ebc669f1e2e3ebbdb2bc9745616b0dddf8503965e9ce1bfbfbd4317ca3153cfcf2c91ab678671c0193785a38fd13243f23bacf712297c1c3161df42aa6f3b6b844e832587ca38676960f99da8a7bf1cd0861f9476e8148056dc7b1677bf1f8138392b8a896be86688d8e2a852c94abed179d515ea75cbb0f011445d07f5c149c59191cf2e949908b485c269c7237df230bcc66bff9d221b912af14af01101f2fadf75503b90fa5519fabc113be039f7cac24d6a425ff6f4d46c91b75ae35825e628b1d386a81c380eac87e5063ea2fb581058a44e67647606faecb3ace4f6e0b4d2504edf42d9fa09490ca53c59401c5ac8eae407a0f0347dc2b4da0319bb42c74cb71cc24608d303045b7c66b21c9cad1bcefa23190ea9008afde61dacb664d45941c172a551ede9e667bcf095113c15bac9217e1069c4549d3a6fea6aac4b9e2c07142170421a6f4f81ba464c8ddc49887d9f0f7826314b772e1df4dfc3f008e8b2348c48574f1c8e555a987162ec8347d793d0ceda160c9528e793bdc7a9b90cfd1a09081912fe24f588947146e99c46f58754cb16f81f6cd9ce19f510bda9ca936f1ce98af2c35aed4c87e98c38927a095b9b94f741305f94c7930f425336449c2c3208d0e0b74d37e1eb5c1c0311e9a77bf00bf103daa2dfc7d9c3fecba42d41c4ceb332133da641fb6cdc531897be0caabbc3261cac5c59871ca2ac3fcb9449b1cd3903798aa08e0e751d505d9aadec2d2b912094dd05c5d1dac0d405505faf085050662a9b20fe2e3398fd01a9b011dda41a55c740485c8aca06e52f2ee6f62f413b23f3bac9b4da3e93a2fb788f34c085be0128a293e650562444dd139b2bc45aa2064e6f945e1b4ff89ceef30842195d7e3766b5a254a3112af2adcf6c56ac46a4cbf93312e2b6b7ea1848185a85273a7b11251c2179fa802b543ca6922c0656320b0761b883b9c1d75228ac9a7e9626ec76a7c84820b2fcc13e833dd5bdd5d3c8ecb1745f50d831619c895ba3dcd24597bc52e0f6751ddb4c5c55ed2746c69088f47da9cf8450cae2cba98e8a997381709e234c8115f88327f3873e70e5742dba0a8a41ab4555ef75ccbd49812534d625a38086465c877f5cb86d90f3acfe194d244ff85604036251e73be7453d043470e8e87d1df11b6468631457a999e13d8556d28875f80d475e4080a822606e53ab4521e612fae27506829355cd7edd93156f5d3d78038a3cfa19d1b94502097914558d7a99d3e922eb6004f428a319dce0b00be94d9d65e8a891e0da8092cd17eb57f42719d72cf19e8b9cdbbce8788a37b61803d8c5ac9b705b451a16cf3fd4e44c98a697846d4c389a327e75e07e26dde46d96ec5f9cbe652798c6e4c4f071e518ca431ae4ea874c9bfb2c727a572b1bb77a3911ddfe4890f0f4cc152b67e4490953fa74133078da4bda050f5f723492d3f63a9f465a3f8352414b9b24747052268e8fd9f00cbf24109afca71dc10ec95847ccd30693a340ac17a1c438de32c9d5ec779ce6d1eebb68e4560bfc706060c2557d03e5fe55e5c76f1b0ef8e8b9102af101e1b660f5505127a1c513a7bf51a506bbd38413db23366690dde3b7a64ada8b38c3bf5f2a77c993b3c8c2860b9b6f661657e2ed9c3fb8a7f07b9f86e6908ff61e5040beeced096c0d29cb3117bd673c5a13fcc06788b4db4d7d24a2bb143cd675772293861f3b9c8f22a3fc7f39be4019cbee78624119caa0ac94b509b691e516835c3083a24fa948cd62be1d683b9cacaba9117c6a95ba3af20f8df2bedefbe34563d12e24a4fc1c9116fc4eeead881d06ea1a7ecfbe5b50a60da676d03906784c355c572a6639f35bec2e4ac4d0afad8f0b1a5b56f379ba18bb0f5a2239bed56845fe0dd02a29705d5066285c49d0f62b8b7703247af1ca3f3ef80c72d2e73d4b09270a3d63c0ee9b56b60d8422820e7d421d7cb5edd90bd36f171f3a248ca8ba22a0ad7dd4c1094a8d2cad3b5f58c2f6521a82fcda6145f50a67031c99fac5ee33bbc593f697857610ae607fcc8aaadfbfa9ccc8b666c1610fe58638c4b81b39d62ef8ef79e3f3125048e96e178148b987ad8ce75ddb86f226c9422f88311a1e54584d870aff1afb86ba69ddc59fe8f846915e32928ec2b41ecfdcec2ddf6e03483f220ce62505abf5bd046d810d0a73121491fc5845984c6fc49d95fff620acf0239fd782432147be7b01479d1bbf685ad71220496c5fbe49751f43661ad97ffa452dbbf443cb66f47d94219064c1e11d02a35eee965b7f853b387676a12fc1bbc92ca5e3fea2af39b5290131d48ae5ac6b8b3da7f8939f3416427aa7bf23c495532077ccb94f30ba0e14ae25792e182fd3a9c89a136112a7875a582877d067c9994fd79bcbb666c872f3cf82331b4890fd26895337b42a7f957e3da716d9dedeb9563d83c0644780a2f7dc8441b391e23ad1a9d86a23b965931c13d66e41314ea356c15452b8070d12716b40768018b3315c33b00a40c54fc825a77e16189b5850d2f5e73eeac5e1263a0df8a53ba2b1deb5b8e7815c61bd8d70015ed1912fa27498c3f78d908282db2e0ad1a117dad769c93569014fa2500d266959dc58f9d70708000000000000000009fd0f1800000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d00b20e0c80af58e7643d04abbd00763600d69e7ad33429020676adae4d4d341201b3e8f809b84eb46a8e2f6891931ceb07e375c8f7df92beae625f3a2193c77a1f5da52bf64dd15d0231dbb13c5594a519c0eaee2ed23ff4d269a9919b204c3aae789", + "activate_wire": "d705bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848957d51e11b3a8feac73e8ea58aa6824b7bba69e045416f6ba4a586348adc40da6e012e565964e65d584adbc7138f1d115f6e00c27a90903e6c1b1657982fe5cff556ddfc432a327526e84bbfe843070d8fca46b05d25b3a0dfccd406ec4f03a2961034010d3510c0a44a53e0ef4a086226bfa039708f944ce4da9833f1ad5e5f3000c0df05779c6f246b9d57721a604df34edb76ca1669844ee5799dc2eadacb6ba6d531254e954c09d705cdf51e5ea9728528cccc4af0bdebe4dd29853e8315e96edca63", + "ack_wire": "d707bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848957d51e11b3a8feac73e8ea58aa6824b7bba69e045416f6ba4a586348adc40da67d36f8f1728a75368594fed88f57f4b24113c1a76d673264b1a2b89c4a2333658196bbac6ca9362246f559f4c15d5645ef908635dc460bdaea0f3e8e860460215c584b017c77a3a2254a39d5832206367aa74cd262def4e8a819c60a2360b988", + "book": "57d51e11b3a8feac73e8ea58aa6824b7bba69e045416f6ba4a586348adc40da6040101e300017305b11a874bbd995b29b11d109eff432402136904a3bab1f7bd9f869109cfc100000000000854d0000c3500000c311000000000000000000002cbf141d9ad3b41938c25083328862acf711291bb47ab53b33dc37898ef9326b400000000000854d0000c3500000c31100000000000000001000362e42ded68912761a397087a1310af972f9022330fbbbf24c580b9e71479fd8700000000000854d0000c3500000c311000000000000000020004754bccfbb8e3ee44224e0290582ada7cb1dcb05ad968df63ac0aa01e81298f1b00000000000854d0000c3500000c3110000000000000000300057c23775d75a9e3e878e5e33ba9cf7da51c78a4d54431bc1981c8524eda36bb0d00000000000854d0000c3500000c311000000000000000040006bcd5fb4815309c1de8fbc2867eb93635d8764c565d952917457874fc6eceda1800000000000854d0000c3500000c311000000000000000050007e3ae54a63b8f9526f7955db3417b5a1f6743b99ad0ca7cc92c3fb199c149b76800000000000854d0000c3500000c311000000000000000060008bd471262b242310a4d1b298156a5fc3469395eb2fa3d28be02b88d8d0c97081100000000000854d0000c3500000c311000000000000000070009397b027bfa95da000fac9dac5d41825bb54a0cd0071a262be00d6d4445f3e2e900000000000854d0000c3500000c31100000000000000008000adb17a978e9e05effc0d7f4c33f7fb78898deec1a0b44212bf943818091fbc09e00000000000854d0000c3500000c31100000000000000009000b0fc65290b128e63d598e654d4a7bbe14cc3ef946f6fb6fe0d5b46cb9e0e2638800000000000854d0000c3500000c3110000000000000000a000c3f951c45254175c92549460b5a89d5d3c61a950e7e366f992dac9f7f7a7e6ce700000000000854d0000c3500000c3110000000000000000b000dfa9d083023788850024c7eb588fae1c7542604d23cc12f44ddd568c2320a754f00000000000854d0000c3500000c3110000000000000000c000e3aa43187fba488b5530dbc366257f82058801f6983509716a9e9bf00db680f1800000000000854d0000c3500000c3110000000000000000d000f7404ba142c5ad30fb6769e7f6a84843d4c437bd50254a72815c8b75c1deb0c4b00000000000854d0000c3500000c3110000000000000000e001028ced94a2fc573f8c251838edfc807be39d1b910d18ede82aa7a127c5888b3a300000000000854d0000c3500000c3110000000000000000f0011a6a8f52194b94c6d0b521756e5c2c20ab5b6da8a2483e44aeb48447112c0680400000000000854d0000c3500000c3110000000000000001000122014e1e38afe84f6da72dbab22108ec05f60555504bac35bbfc7c62d1ac3621100000000000854d0000c3500000c31100000000000000011001313667fb8817a03dd2f5fd97325ffaec561f597256d58742291c569ee251257fb00000000000854d0000c3500000c31100000000000000012001430ec738e4babdd6d0b18b82a7f5a7415074cd283260197c4efe5d439671c9abc00000000000854d0000c3500000c3110000000000000001300153e9e411c4bcb405bda68f31ef974cf91e5dc7a530fa8580048c4bc742805125a00000000000854d0000c3500000c3110000000000000001400167aacb62cd9c823ddf004ca04fb936c3093029ddceaef569190534039885b1b8d00000000000854d0000c3500000c311000000000000000150017a9e7ea66fc54f78b5a4441c5ca6ebaa742485894509dc379783c52d881fad9e400000000000854d0000c3500000c311000000000000000160018063c74693de53572427dd71747f7a47f95a72b83b95f62b60be7479516593c5300000000000854d0000c3500000c311000000000000000170019454a89ae1311664f5155c45544265e5b33911a120db3b683522ac590e64528b200000000000854d0000c3500000c31100000000000000018001adb1d9b5644e6d6b7befb0f222e567bef23ec977fd3d5a6f146b6b195f48384e400000000000854d0000c3500000c31100000000000000019001b54755b708745f22c50b1ded9d0f24bc9b87d93d95046accde5e0d0c1b27ee3a000000000000854d0000c3500000c3110000000000000001a001c1b29bd0c8af340c2ede7c9552fd9410b4c731a47156f54ae2fe2ef6ab170a3f900000000000854d0000c3500000c3110000000000000001b001d3b68781e8a122e02fdbade74d727eedafc80663a79c32726b7d555e730f6b00c00000000000854d0000c3500000c3110000000000000001c001e508a679821f8e0b736552664ffd03360eb8e08acc5ac5c653ce280885222ca8000000000000854d0000c3500000c3110000000000000001d001f599fd0cf5ce9487aae5390f0aa7e2f55bbf36c7fab020879c262c74f0659b80d00000000000854d0000c3500000c3110000000000000001e00201d35e8a88f39201d36bb5d8603822ee851e4f936cc9d941eda8f0c132be674ed00000000000854d0000c3500000c3110000000000000001f00218b0edea50f2efd820966577036831199400b94a6a5ddc1efde5caa77d770fd9300000000000854d0000c3500000c3110000000000000002000228267950cb8f56aee3a11f312d601c4e2f49e229db76eebe9df97b467478fe80400000000000854d0000c3500000c3110000000000000002100233e52b8a3ff4d5f870fa11ba4e1a7ffc5f35310840f33f01da137e5802ad25db100000000000854d0000c3500000c311000000000000000220024bb7507ed8a6155c9d7d58e9798f1822e143da862250702f237267f91bf1f6bc300000000000854d0000c3500000c3110000000000000002300253dbe9c22ae8f8733c3a60d21c30bf7e51a304f53bac7c4a2c6e3cbe61157679500000000000854d0000c3500000c31100000000000000024002698a89d04d4471120348d82a09c6588128fe927d38dfe26d98932961fc842764000000000000854d0000c3500000c31100000000000000025002736a20a0e1e99e0e8f0f9d389acb4ce07130e58276e847f52bf8d17c2bf36fc3300000000000854d0000c3500000c311000000000000000260028f5d5256f0c02a8e76d9de795b0a9a6624e694f2df4d106bfe32fa46b60400a3300000000000854d0000c3500000c31100000000000000027002908853d9812b63820a7c738e5fc15ab6181cd391a059c248b700657d4b21b4e5d00000000000854d0000c3500000c31100000000000000028002ab49ad03151b291227dc42af60dd203c373a9533afdc9875f4c02d08560c4533f00000000000854d0000c3500000c31100000000000000029002bdc43a956e852c354169466af263dd38c4f278c18fb54d209d9b65ef1828cbec200000000000854d0000c3500000c3110000000000000002a002c1c3da0878d813c406978c150f03595e9a26d582ad426d14ef279edd0e8f452fb00000000000854d0000c3500000c3110000000000000002b002ddccf935988fc435b5713186e04f48e53fe514466473a48ad5e0afad0d22fe46400000000000854d0000c3500000c3110000000000000002c002ee113b733aec9e1d33dfd95556e546977a6b907fda9164c86f41dff868d4a2cc300000000000854d0000c3500000c3110000000000000002d002ff6fd342210a6de76e74742ecb52b84f482aeed8d2e0b0a843cd8167b4554f5d600000000000854d0000c3500000c3110000000000000002e0030882620e0b4f1feee1c51202c3b9623135d41499777d1341f63730f98dad4702c00000000000854d0000c3500000c3110000000000000002f0031ba08838987d86618a0dc670de584e3cb9602dd2f60278ea817a31d7f311e634d00000000000854d0000c3500000c3110000000000000003000323f4fce46408992a463e8d153537aa72ed50c22cd6c76648191a9cb4b43de2b9b00000000000854d0000c3500000c3110000000000000003100332f030755b807abfb879b08abe7a8b15adcd9efd2f419214fa5d02d099fda961000000000000854d0000c3500000c3110000000000000003200346918f0ebfa087f6b72399aca062f7bde3577c44fd288090b9ff2718fc6f5cc8100000000000854d0000c3500000c311000000000000000330035f70d71f8c06e27b949b91c969119a7cb85a4779bef0397a4d2a6847b49d128dd00000000000854d0000c3500000c31100000000000000034003631f229a547af63a7d20a9603285eb3793ed667b25f4a22bd8cd94344665f31a800000000000854d0000c3500000c311000000000000000350037d872bb09a3b355b08b3bf759b436cbc36f6d645fc01dccf919454235b8cf509b00000000000854d0000c3500000c3110000000000000003600387bf97f96e2d82c0396dd60d8a32ca53a9034f4fafb590a6f8b3f02e3d16a26bd00000000000854d0000c3500000c3110000000000000003700391d44995dc923077293887ec5dab71deac2055a37fdf727d5a2c0d216bbb5a25f00000000000854d0000c3500000c31100000000000000038003a6fc5c81c3b0c8b25375f88a324a38ad92d72a73d2771886704e24ed7ab53595100000000000854d0000c3500000c31100000000000000039003b64a7f4d976600e0d3670d51fd1f1a08937a5247cd7314e78fc85a6c692cee5b100000000000854d0000c3500000c3110000000000000003a003c304c70efe0cdf3be30823623953ef905d37831a080ae3ae1d0c261f7921ab76700000000000854d0000c3500000c3110000000000000003b003d52f4c9863d3ae76c166247d9225b19a416ad39f7f46ff197bc95f695201dafe600000000000854d0000c3500000c3110000000000000003c003e76fbeb3ff3afa2700541928f23f11d59f8a56d40abd6b10086d70ff568c1b39d00000000000854d0000c3500000c3110000000000000003d003f83a811fe4aa924595c69a33b636f0410f8da6c59683048f6a63d011eecd0fc7400000000000854d0000c3500000c3110000000000000003e0040ab8a8b2d7759b68431f7539de4e7c40fcf22a15cbb3d472dd20a31d690bd696000000000000854d0000c3500000c3110000000000000003f0041c1305b0e61a9ea2da6d693234a815de4e712aa7a38fecf60291bb0e0af44276a00000000000854d0000c3500000c311000000000000000400042434b51045e65ddc0caf75b30495e146c0076fc28951d7396a23ecff1690e2f8a00000000000854d0000c3500000c3110000000000000004100439df059ca0da40a29d8afeebcfcd02cefa7cdef598a2673065f7d55b6617884c000000000000854d0000c3500000c311000000000000000420044ca418d14c97d01e4c0aad582a5174c09a21638342681bbcdf02ef3ff5f56cab200000000000854d0000c3500000c31100000000000000043004537594556a4f9feebc7b9efaf166d36bc7011be23886a2c89e590f9ad2a4665a500000000000854d0000c3500000c311000000000000000440046ad51420b09a07509e900aaa4fcc6cf6bace255fef4d89c31727806446edbba5100000000000854d0000c3500000c311000000000000000450047c733ad0dee32d9a52a4d62c9c96186e1afd55d35f54ddc21fa980f771e17fd7f00000000000854d0000c3500000c311000000000000000460048464b71d7e34e559d518b268791aa82b54cec4fc2aefa34cc66fe9f21eb90948a00000000000854d0000c3500000c311000000000000000470049cc2d776c5d23a365ffcb4604ee65a09cce259c871dab39b04939d4f3d2252a3d00000000000854d0000c3500000c31100000000000000048004a6469912e6512ff7a494d00d8ab8977f1378479cdd5919a687a6b4912af46667e00000000000854d0000c3500000c31100000000000000049004b378f6cfb89eef63eaf08aa5d3f5d83c90698ba63169104be2a28e46f9d1ccca700000000000854d0000c3500000c3110000000000000004a004c09e07c300817fd060a676575d6376ea8deaccbe2eec3714954f953826781831c00000000000854d0000c3500000c3110000000000000004b004daf5d250305bc69ba1cd8af664d439f6469f65361e3f5fe870b765f99bf9c840a00000000000854d0000c3500000c3110000000000000004c004eaf472cbaa57978e18a7beb1aab7b3231d10ca16d6900093ded5c8ac76aee2fdc00000000000854d0000c3500000c3110000000000000004d004fd208f7cc7c79c678fa3d3af63e43f8145e12c509786de6d484f0fa9eefd4df6500000000000854d0000c3500000c3110000000000000004e0050e28a412244c366997dc906ad005406191314722626a355dbbaa982075b3195b900000000000854d0000c3500000c3110000000000000004f0051164032bafa294351f3da469060dabc9ed49a700436b28e33ba98eb19575777b200000000000854d0000c3500000c311000000000000000500052f7c31d1c01ea89f750fd13cea775b223d7b33fb72787d312a5491919dbf1ccbe00000000000854d0000c3500000c31100000000000000051005325364a6509a5cad019874190a2d2575fed1814db0ec63349e0d45da1ddef18cc00000000000854d0000c3500000c311000000000000000520054d67bbaecdc0ceda6c4c73f713e4f2b0720eb3d477f514148f2dac77cc50ea51000000000000854d0000c3500000c311000000000000000530055f6bb6e89201ad4cf91179cb56daa171bd244803fd2d2552f81d9e8613b9a399100000000000854d0000c3500000c311000000000000000540056e8f85ca405596250eaa741554d424ed40359b7bc9a29df7116ecf508708d8a7000000000000854d0000c3500000c311000000000000000550057c87b12700480cfac14a3b6abeee0ce1529d0c8dcfe84a8ba767e97662476653600000000000854d0000c3500000c3110000000000000005600582caeafc4b9bf93574c0f8d4bc94f57a85d9f23efc58c769e716f0d7b18b38dd900000000000854d0000c3500000c31100000000000000057005969126f4e713f05ba71906ad30b5673030fe0d3f82d874741e2dc94bdcea49e1000000000000854d0000c3500000c31100000000000000058005a2bb80bd902fbb168b3121206e194f44db5ef0cbaaa3198ecb971466d128f175100000000000854d0000c3500000c31100000000000000059005b4082cad26a5c16ddc9f61d81b789669510a05281c338443b02ba356ea887c02f00000000000854d0000c3500000c3110000000000000005a005c265d9a20fafd71d0aa9696c1aaa52a8b570d3cd3aecff66d6a2ccbfa71e14d3900000000000854d0000c3500000c3110000000000000005b005dae6967ae7b6549b26551f919039be40948601cf75d580bd8a7273102944d505d00000000000854d0000c3500000c3110000000000000005c005e98476373841a91019fc58c72deef72db55794552bbe06b99a0cc70b545c257c700000000000854d0000c3500000c3110000000000000005d005f82011b8a630ed5a1d31aed2f71d4007347e50c49d0273abc634d77b7fd732c5700000000000854d0000c3500000c3110000000000000005e00600ad83b5a1329af2453f9a24a11729b565921757b15b24342cfa0cd68926626d900000000000854d0000c3500000c3110000000000000005f0061e98244eb7266bac47dfe83ad32ea0aa6c2a71c6c6efeeb6fc94dbec2888836a000000000000854d0000c3500000c311000000000000000600062c0bd14f5ac63c549c1df54d26ebd93b57f93f3610f87dc19b15d6178e97a3c8700000000000854d0000c3500000c311000000000000000610063c56dcdbbffe2ac6cd828e1885083843d6dff6883b91a547ed66ab4315d7e3bf700000000000854d0000c3500000c3110000000000000006200642e89e3b0df85fbe1eff77b8841b90b0fbc491812b75ac167e0df33b212447b5300000000000854d0000c3500000c3110000000000000006300659ed51626c3998d5bb14679fe39d2fc267375317eec386d12d9805e45bd9082d500000000000854d0000c3500000c311000000000000000640066071eda545451d9d4b94201fb5b5f6623d970d3839e46f870d7fa3bf2d2cf7ece00000000000854d0000c3500000c31100000000000000065006793865c1b6f4070c9bc30c2f29286d8eea729414d2828c3827d7add206c22b5d800000000000854d0000c3500000c311000000000000000660068fdf7662574899c10d0b4b4119824dc80a26595b4b44284ab1a6d95bf48da05e300000000000854d0000c3500000c311000000000000000670069f6e8deab009f70f621e12f36a033dc1bf7ce211100ec76ebbbdfbe1a8a296bbf00000000000854d0000c3500000c31100000000000000068006a3c96ed2d4e91b99750437ed50e90544e3ace2456c7fdef5e6d81d1c69797041200000000000854d0000c3500000c31100000000000000069006b64507da352e9f70ab879dbc4a2aa073630b6d61dd0e1eaf5bc215510ae551ed000000000000854d0000c3500000c3110000000000000006a006c523085244700d8bc42a08c99dab05caa6d4e00a0a3e974355e6a5cadd6a8604900000000000854d0000c3500000c3110000000000000006b006d843e8fb4fde1fb78042e83c43096cc68ab22b2d41b945781c674dff62520d3cf00000000000854d0000c3500000c3110000000000000006c006ed4c3b0a4e860f9d09f8e28525c44e2b32684d8ba68228a3f1f79db0598b2eea700000000000854d0000c3500000c3110000000000000006d006fbfbb7a54fc65f2cc087f91d4a54e89c6a9189994fdf807cbace173f150527f4200000000000854d0000c3500000c3110000000000000006e00700940c82246dec163f6bcea0066dc1b8e42153a9a89cb87335bc874fef74954ae00000000000854d0000c3500000c3110000000000000006f007110fe330651d32c31fcab4753b4467cac95586300c264136f8f86aac3721fb16500000000000854d0000c3500000c311000000000000000700072a82c392b6da093a4bd54e33100ea7cd77b5bdcf688b1fb691b474a10e0bc363600000000000854d0000c3500000c3110000000000000007100732f743009fa6915ebadbfbfa27399c3a30f9b29b48e39e8e76a6bef920de3a57700000000000854d0000c3500000c311000000000000000720074815323725370ca6a62de58f815237acb314bc3c4ca763f5c260923f0dd2b4fcc00000000000854d0000c3500000c3110000000000000007300756727a4ae8c2ebb1d033eb969abd7476e90dc6b40b54f75b5f4d6d755679f6c0900000000000854d0000c3500000c3110000000000000007400767e07701c7cde480625ca923bebaa832bc17a6abed269b7b1d3601175c2ff6cfa00000000000854d0000c3500000c3110000000000000007500774173d50ab54b95deae539126064ca1a69a831ac4bfc2c9cc419c6f1da7572f5100000000000854d0000c3500000c311000000000000000760078c8bba7fb3d037affa75e8ab2b6784f404e9ef50f7a76b9382874b24086d5b5dd00000000000854d0000c3500000c3110000000000000007700791cae9402e611c095147a803f5b408d27ebaa9344cb951fb073666fd308fa557400000000000854d0000c3500000c31100000000000000078007afad15542780583e26b83691532044687dce16a82ea097c13d339da899a5ee02a00000000000854d0000c3500000c31100000000000000079007b245471ab91354bcc2c2bb09ab2a2b96840d5447ba9e3b9db177f63bf1894ab7500000000000854d0000c3500000c3110000000000000007a007c9a09a07adac2cf5ee978ef0898161ab40e666963976b0dcbbbaae8a0d7f8b28000000000000854d0000c3500000c3110000000000000007b007d14e2ab39cda053ec7cb75fdda214d03f85c30d9d137083645ec85d70119906c200000000000854d0000c3500000c3110000000000000007c007e768ff392d5d959b68e4eab28b75c719679c43ccc65060d8d9d1b55c56e410b1200000000000854d0000c3500000c3110000000000000007d007faed573b2efbc90729cc17c11f1947952c29ba79945746be3a3635cac70dcbef900000000000854d0000c3500000c3110000000000000007e0080b5f95315fb041ea28586d613d2a976d82d348d960989c7b9416fb3d56104da1600000000000854d0000c3500000c3110000000000000007f008140d95f0c5da8d32977131dc41a05a92f1a6876ca043a638240c2b1c8521f111400000000000854d0000c3500000c311000000000000000800082e58f57a865ff75144bdd08549c83c656431b680ab2e57f55501308f826216b8300000000000854d0000c3500000c311000000000000000810083d7f2320258354c20a9069262030e08e266332394343063bcea0cb1869f329ea200000000000854d0000c3500000c31100000000000000082008467c32697927595a930d79d6af1b4d0ef7eb48eca60a66b0b9295e281b055345200000000000854d0000c3500000c3110000000000000008300857954935a783c0293c7338c17e95ca0e84642aee0b1357adda4264d19d6dfc5a100000000000854d0000c3500000c311000000000000000840086649f6f69ab5b4062727281bfc562602b8d23866140ff820bae3629c28d2b68bb00000000000854d0000c3500000c3110000000000000008500878646a8a5d0c0a6d6abfc5db6d6f7f0675e2e502248d2c9868363efc858272cc600000000000854d0000c3500000c31100000000000000086008837ccf66214c602f0155d3350d8410b27f835f10d7ed7480d38697ee5928580aa00000000000854d0000c3500000c3110000000000000008700894dc5c25b4e7e181bacc5dc94f96cea194f4ad582ff9082e49f43af951f5f2c7200000000000854d0000c3500000c31100000000000000088008ab4a423a735be5b23a37b7e502b1d730cdf511b5f5dcd7e3dac491dbf42454bd700000000000854d0000c3500000c31100000000000000089008bd0300ea2db54c07c732c435c8b390eff7af09d1fec7d4183f26c11af6f06ae8400000000000854d0000c3500000c3110000000000000008a008c06eb3dd61b8993afd0aad49eaab19f9b363acc565df1eee863fd0cf189ae9e7900000000000854d0000c3500000c3110000000000000008b008de2f3402d5ec19a1b0c2df4f3bb8f8375692869e820a1e4071468c85bbfd561e100000000000854d0000c3500000c3110000000000000008c008ea4210d942e844c1b72a9418b1b4b3c4b0f6e514fe3b0f75e3d7df746e95326e400000000000854d0000c3500000c3110000000000000008d008f00608445dec5823810557eb56baddb365cdba4e465f2498abe69cd50b0333f3f00000000000854d0000c3500000c3110000000000000008e0090cd2a4d3bb7d1b6be07dd1f3c90d3cca5f5b3cedb97379673782f5afb23c3020000000000000854d0000c3500000c3110000000000000008f0091254a9a75cdf945525320029f7e8c5aba45dfd5b4a426f346603348a66b7dd45300000000000854d0000c3500000c31100000000000000090009225074aff4e140b03cc239486412dd0a130e3ec93b2648b95569ae55e5782af0500000000000854d0000c3500000c311000000000000000910093d8a8b5c8d165c407816b8eb5e89adb271a1078cdca4f29315161593ec86f769500000000000854d0000c3500000c311000000000000000920094aa968f2a69bb072521c766134d9c1521b5d49a368b0dd00ea3b4e21883f7c55600000000000854d0000c3500000c31100000000000000093009592d8e3576e981f7ef9fafcb12635311778fa6e9bd7544bb502e310113869bb2b00000000000854d0000c3500000c311000000000000000940096fce131a771b67463a1bf5b20192031a7780c14e21a74d4ac5a0c4468a45998ae00000000000854d0000c3500000c31100000000000000095009752267d49a6543212b263beea088dfe20227f97d1cc1a3b935bbfa0e11e47548400000000000854d0000c3500000c31100000000000000096009813496ffa2340f37f578504f289f12a5fc253e2e534f83ed2878e342bd0187c8a00000000000854d0000c3500000c311000000000000000970099c168e856e6948c8eb949d48e53f1c64266341a91c7995547f441d3f2bb7db66e00000000000854d0000c3500000c31100000000000000098009afd09c7a336cc28e97b5ff1e5c83d05c3e68257ddb2587b0c20338833512dcdce00000000000854d0000c3500000c31100000000000000099009b256bb41df47f9dc594b20b7aed566d3457cd3feab6a2ac54021ac61a65b74cd500000000000854d0000c3500000c3110000000000000009a009c1304a8cec2aaa0fb13195fc09a06d571fcc8ac066797be25754ce7da18783c4600000000000854d0000c3500000c3110000000000000009b009d82e93cabb272fbed2f0954226d7871dadbcc06fb772436fe4bd5e79a509dc33a00000000000854d0000c3500000c3110000000000000009c009e67f4fce731aef9e25e456361ebaa95b8dd448180b3780a57dd5e996e155fc12a00000000000854d0000c3500000c3110000000000000009d009fee0a1488e1db16efd197fe3307bb1ef3fecc70be961fb5d83f57293b2389742700000000000854d0000c3500000c3110000000000000009e00a0d8b7f44f79f3825b0898063c9be52e8430a4fc6556380971e45ba2bff87d41f300000000000854d0000c3500000c3110000000000000009f00a10cd85fbd33a658c370afb29f2c452a9c176e6b15b85cb999c849c3e5fcc3571600000000000854d0000c3500000c311000000000000000a000a272fb1ed9a9b17e17dc9252ab1b6c57842adef570518d2ddb9ca68fbd6cd6864f00000000000854d0000c3500000c311000000000000000a100a30e18954a79e2d97b37db47861208d7191933965b8a66a8fa2192c2f03706539500000000000854d0000c3500000c311000000000000000a200a4f2891d485e7fd8fb3acffbe35e13ee5fbc7ab4bd4b856819d6cff8e9b46054c100000000000854d0000c3500000c311000000000000000a300a5b17223b7dc16a717a0143c327abdf0ecec7a56ce9d4a42a187228d4764f3adaa00000000000854d0000c3500000c311000000000000000a400a6e664a80ba167ef9a42fe98f9e60997922edf02ab62b9515f3cc04cd742bbc0a000000000000854d0000c3500000c311000000000000000a500a71dc5c03a9ee981ff70177b153af6073d60b5dd62f266b099e2a9a4d4db5228b500000000000854d0000c3500000c311000000000000000a600a81cb37a70492764ccb4e974c7d238592775eccce4089735e69dddad210d68642d00000000000854d0000c3500000c311000000000000000a700a90821c9c1ef4a9e69d5f508ce86029fdc4601e2a1faeed4a8bbc0617640f5cea600000000000854d0000c3500000c311000000000000000a800aabe06ffb02722e4f7b8b02d33806acdf4fc6050e20ae54bb1955aafd407a8be9d00000000000854d0000c3500000c311000000000000000a900ab7518052cc5fc266960839ca65bbdcb933b36eaf9b2f61c37192e6ba5effca76600000000000854d0000c3500000c311000000000000000aa00ac9ea401167af230c9b049bd02d00e1636f21319cdab24c46937d04d8a4ddb41eb00000000000854d0000c3500000c311000000000000000ab00ad1f1b2f6031fd9c8380f15b2928f042e9645332d6e0bc2f55e06778108ece17ff00000000000854d0000c3500000c311000000000000000ac00aeb8410e7035d2c12ede9ebc2c6bc81af83b27d172db05b66b10c7c7f48c57222e00000000000854d0000c3500000c311000000000000000ad00af1b3fb423b0daed330e204543b2fab0fa9aafff31b387535ad322f5743c7b78d800000000000854d0000c3500000c311000000000000000ae00b02c9d6f58fd85711958fcfd2824474818828d5ecb641bdeed41c988b6eec5c22e00000000000854d0000c3500000c311000000000000000af00b1015bda7aab18ccb03f23a253bd3b94ec400a67d057cadeb092167e45001856df00000000000854d0000c3500000c311000000000000000b000b235eab6ff950e9ec80d4563255908a7ea09a5ae5dfbd0e3c2944295709ecd7e3500000000000854d0000c3500000c311000000000000000b100b3957cfa2365934f72f6559f95997c60304b0a98e3c9e0dc99ab7ea6554bb0a26100000000000854d0000c3500000c311000000000000000b200b41012ec8ec892231dbc8ce89d60f3bb9b375718e460e7b5bd1b8e84631e291a9b00000000000854d0000c3500000c311000000000000000b300b5ca5d940ca7b8fb92c4baecb5a6e464d3c3b5aa5c12e67d9e70650327b858ebcc00000000000854d0000c3500000c311000000000000000b400b69d5412f7b492ac0203d7a82aabe23f8aa7776d173df7da0afc42147476614d9100000000000854d0000c3500000c311000000000000000b500b711d3ab9e16a2c977acd6d131284d2fc8c0d1af94dd6191d6a84344bc4722135400000000000854d0000c3500000c311000000000000000b600b8eb9a2b0b9a696572dfc2adebd5f9177abda3769f294babd21480a6839801474b00000000000854d0000c3500000c311000000000000000b700b9078be2982828fed4bb25781dc9bccaa624f6058c722eac222b4a659e9489edbe00000000000854d0000c3500000c311000000000000000b800ba819151088a9e7aada910f638ca1a248bc94b4878b1031a41c101af8f35c9aef100000000000854d0000c3500000c311000000000000000b900bbf28de95ff9677f064bb695651008dd14631262188ce64c392f192d6efdf8d4f100000000000854d0000c3500000c311000000000000000ba00bc9ea9e5ffbeda1fd8846698475c057567d1f2c133c6d548f3e9722b3b8b9ec69600000000000854d0000c3500000c311000000000000000bb00bd0689a54e3457da2e4ec2a9b07cad6b256469eab859e2e8354b27bbb9f08c3ca700000000000854d0000c3500000c311000000000000000bc00be460f9ac1863a0d05133befb41a245dba3e136fe3e9de43e1000316991d135c4200000000000854d0000c3500000c311000000000000000bd00bf0006cb6b8ace8eb6f74e97e484bb283a123132eb4976f0c665de5394ed0b022100000000000854d0000c3500000c311000000000000000be00c07b38bdf8c89b9b0db4eb62ff10e98308e9ce8647200922d8d790b7dac81bef8e00000000000854d0000c3500000c311000000000000000bf00c181157bfa3420a29f5124ddf629161d0b986616e8aaac86278b838b7caadd850800000000000854d0000c3500000c311000000000000000c000c2b67b3796c8da1c71ac82a89c2fa7a8b40e2ee560c6df7ac42cc99e51645918c100000000000854d0000c3500000c311000000000000000c100c338b2fb62c7aa54b15597b8422142cd49a776092d55f92d5dad31e417844c63b300000000000854d0000c3500000c311000000000000000c200c41e7701adb1fc46ca8f1c24d0e24cc40fe7d9daff7deeb63211fbf5bc1c123c0200000000000854d0000c3500000c311000000000000000c300c5e2cb25607821cfb7e67028ef96056f6e3f81f72db1bf8851432472936bbc647600000000000854d0000c3500000c311000000000000000c400c6d9392204247dfded154f8863582e44f0acd0a30a3bc8a38ab35ab744b73ca18100000000000854d0000c3500000c311000000000000000c500c792e42b479904bc5abf82fbb467577afe7f53632f5a8e89be6791a36b49c326f500000000000854d0000c3500000c311000000000000000c600c84412ac310f1fbc90220101c29f748304f9496740c3eee14b64c8fc171558352700000000000854d0000c3500000c311000000000000000c700c95fdd380ed620c5abc8a4160f18375928c0ed6a926043eaf91365dfdefddef93f00000000000854d0000c3500000c311000000000000000c800ca0c8edc3204a6c47c4e9115755a24c31763efa78db17bfaa9645aabeb4972c89a00000000000854d0000c3500000c311000000000000000c900cb83e02383dcaff8714d79fc6df28563584f79be4435bb3632958eb98649d214e300000000000854d0000c3500000c311000000000000000ca00cce136d56b460bf32ae200ce0fe980817a2bbc78112c9969c70db679c2aa30d73b00000000000854d0000c3500000c311000000000000000cb00cd4cd753d127fca8a62042ce2dae9cf035561530b3e2e95f60fe74245ce293dfb600000000000854d0000c3500000c311000000000000000cc00ce2ee0ed4ba29122e130bfd9a32626fd46ac15ce11642d63e5c596b7d23d6b142300000000000854d0000c3500000c311000000000000000cd00cf92b59998dda902b1005f23746254e625f4a70103d02b0ee3cd7cf6f19425188d00000000000854d0000c3500000c311000000000000000ce00d0040c9ed7f1af1ab9d41814211ca770b40c250364d4a26bd0d57b4d6de3d406c800000000000854d0000c3500000c311000000000000000cf00d1cf54bb912bf745b8761c10c6ed566edc96e82ad8e7a9bc3fcd22fb0e3974cfb400000000000854d0000c3500000c311000000000000000d000d2a81886dd94579a2ebe3c14379490aec6fd8bc8bcd7981094f23011e1690bf6a800000000000854d0000c3500000c311000000000000000d100d353b474acbb9539a6631f51144498802e9eeb237d8ba7d11ac050c4a392129afc00000000000854d0000c3500000c311000000000000000d200d4c08ec36aaac6cb7b82a482059dd448f61a9b25249bc5d4e6be5517baf5f5e24200000000000854d0000c3500000c311000000000000000d300d5693de4032c7083eed7fb1803c6d5624a7e32d3107bd7a3939cc89a083388264b00000000000854d0000c3500000c311000000000000000d400d6f9756396a45f40c30da3a0a67d4dc1838b20a098e5351a1e4f344dbbe00dcb8600000000000854d0000c3500000c311000000000000000d500d7780b34031a24731ecf7e0e6844097a8e62b43267d565dd6d3a4886e55620678900000000000854d0000c3500000c311000000000000000d600d8bcc1624846430d2de342f54d7b8e61d264bfa39528a1fcc63532b291c994c2ab00000000000854d0000c3500000c311000000000000000d700d97a217a4e347a614aa9e651bd234eb0c3d49830a6a804e7ede79c77959ce14f7300000000000854d0000c3500000c311000000000000000d800da1d361f1504f6625c8289222276f9bceafa3281738e27e15f6547884c9ca7a5dc00000000000854d0000c3500000c311000000000000000d900dbfe2501594d57b4c25674b575507428defccdccfe69543a182575dd4817b9179d00000000000854d0000c3500000c311000000000000000da00dc45eebf5678f6a340be93a51d2e263e764839c2454a10bf8853fac76861923dc300000000000854d0000c3500000c311000000000000000db00dda5d81abfe6b8ac5e302b89b6057f782aad54a65adb51ed77e50d3a366db9d7a200000000000854d0000c3500000c311000000000000000dc00de0e6556de4b9b77a9d866d3bda9f4a87208766b0cc701414a4deaac8da2acfe5a00000000000854d0000c3500000c311000000000000000dd00dfbc0a8294b1cc8edafc68b3a95d9263f1499a02d63c93a0fe30633ad556a5b44600000000000854d0000c3500000c311000000000000000de00e0a1078aac0d34309b68c23dcc3d7da53e7f88c24e6ed76850021ab5ac808d6b1a00000000000854d0000c3500000c311000000000000000df00e1412173ca93c1a3db66b0695504c0151d21f1e65e417d5671dde59931256ac4fd00000000000854d0000c3500000c311000000000000000e000e2e601a904450d4f2e168e917bf4438d900cd17f571719d60bb900fb0e0740d65200000000000854d0000c3500000c311000000000000000e100e351a8a26ec67cb3ba4b95f2ac4be486745285e9434dcebd8f7df9f743dbd3653000000000000854d0000c3500000c311000000000000000e200e4d20396497388e26286a2a9bfef575a2269ea85b36b4005d294302aa05375862000000000000854d0000c3500000c311000000000000000e300e5abd37177996abc69e2ca24b121f45b32718a095250c0d533a89785fbc1692f8100000000000854d0000c3500000c311000000000000000e400e69491b3619b64e09337ff2b31ed0a98c07d47c910346fa6e43ff4da9c16a4317000000000000854d0000c3500000c311000000000000000e500e7fa38ff7b2f735c6d7ed6697968cc2792e9ed4c2cc9957e2152c773a23e10969000000000000854d0000c3500000c311000000000000000e600e8d82876021b20bad6dd3833506b1e2c7ddb85e8fc754b11f0099c558ad676db0d00000000000854d0000c3500000c311000000000000000e700e93f7a7c70b38e38652fe97203085488d82bc013586ad8a093aeceb4deaf2ee93b00000000000854d0000c3500000c311000000000000000e800eac0e5f3123292708627ace19965a0fb622f8eef55e33dd21febe02b2fb6dc202500000000000854d0000c3500000c311000000000000000e900ebe9c9a099ac9f0603aa645a573caf5b99f8eb03b28e881010b96d87e1ed55ce3400000000000854d0000c3500000c311000000000000000ea00ec1d875edf3e4221594917fca299d02b1c3f1990f6069f944330c3b2ad9edcba7b00000000000854d0000c3500000c311000000000000000eb00ed48640f071c6bcd7c1a94314bd8615a6f6493919fc216a555b9dabca0121775f500000000000854d0000c3500000c311000000000000000ec00ee457e830ff6e6eae43268bb92d5fecdbf8832a37b575285b1e113b29ea7e5f8c900000000000854d0000c3500000c311000000000000000ed00ef55b2b3b16fa963985019f4a6cac17b51fd164b85efd22180221311ba043e168800000000000854d0000c3500000c311000000000000000ee00f0eed5a265e23b6fee64a6800737f859959582dae01bf804141b7dc199139dc15a00000000000854d0000c3500000c311000000000000000ef00f17920b76551c9c0381e120a166e200e20e3b2e0d54267bb3c9f69a0a374af5f5300000000000854d0000c3500000c311000000000000000f000f2550cb6ae690524fbf6a6845f2df5da2d7e4aacc218263bc216db4be3c269ae5200000000000854d0000c3500000c311000000000000000f100f3b3ee1187678512ea1324b3e746df8c76d52008263fd5ed6ee3307608b02a7e6600000000000854d0000c3500000c311000000000000000f200f4c0c14618d3cc34a24260fcb5c7819db74e65d8eca279451104abcf31eb27a7fe00000000000854d0000c3500000c311000000000000000f300f564108c7261a446b472fa45a0c29af4b821210c8aabe6c113d292b6300224dc2c00000000000854d0000c3500000c311000000000000000f400f6bcf946cf2868a4749a156c578a4a6e40a2a7bc1716b35880eb9d87a4748ae09600000000000854d0000c3500000c311000000000000000f500f773465d6084beef0010d1ac1e7e2fcbc6783d854f3927e6d51f272c52bcc6bda100000000000854d0000c3500000c311000000000000000f600f86d175c4b0f2a9dbe8437a34789c0343991526bc83bdece3c71963f86cc51183a00000000000854d0000c3500000c311000000000000000f700f934b71da18c6f995f91aac176268d6ac0a3a2acf2717801ebf23b9d91926e914400000000000854d0000c3500000c311000000000000000f800faf380ea1d68711d9db0ab45e3397dbd39b5e728dcec8d55930f6f2affb0ca12c500000000000854d0000c3500000c311000000000000000f900fb834434e1f82adc0b7ac0bd9ec0d4179d36e2a611ede605a42977de8a9e466c4500000000000854d0000c3500000c311000000000000000fa00fc3479e0daf8b53f8c04ba0eddac3848dd88ae4cafeecc59bdec10b881101c883b00000000000854d0000c3500000c311000000000000000fb00fd61b82153bc8cf58c32d180d4f46da8f40ca6d8a3fff5ed4d5ba16a1fa73d4e6700000000000854d0000c3500000c311000000000000000fc00fed00cfde8a915c23cda075f4a88dc0ae74792bd8ece23dcfdba72f56b9813b0eb00000000000854d0000c3500000c311000000000000000fd00ffd559de901dbb27927b10cbb56d57e3b244a9f5c248ac8d85221547ba9dabfb3d00000000000854d0000c3500000c311000000000000000fe0100c68cdfd7846524e752cbb7ba1aa0e495352325399e03d91a7016e3671fd964d500000000000854d0000c3500000c311000000000000000ff010191141f42588c3501522fa211cdffd484c7889c2cc6716de25654169609657a4900000000000854d0000c3500000c311000000000000001000102e6ef1453006abcf1da0dfe1572ef43a2082f91f230b0f14ce55a6361e8650b9200000000000854d0000c3500000c31100000000000000101010312266945cef53747caa27b2ecb733afa5afa42bac3afee722206d2d0c5abb81100000000000854d0000c3500000c311000000000000001020104c7ca46af876947a45686fa1f11f8842a6ba14ef982a40f5138bf420c6f73176200000000000854d0000c3500000c3110000000000000010301053f1661f5435f16624c0e51a7a644ece8fe3d7f84af61a410787b30feeeb966d000000000000854d0000c3500000c311000000000000001040106cadaf4cd6e8d42db0682daba7a65e6a51434b843b5cf75213ea4427e0319f4ef00000000000854d0000c3500000c311000000000000001050107bc7d18e01e7f39891798b254ec5fbfd4066bfa49c634f8e3eac8fb5329aeb74300000000000854d0000c3500000c3110000000000000010601089cc3be9f123da1cbb918809e10bee97bdc9fa805b06c557bea2210471fa3307800000000000854d0000c3500000c311000000000000001070109f1a7aae9140ccfd98127b56044d9104e9de5fbcd682859598eef0d6f9ca92aab00000000000854d0000c3500000c31100000000000000108010afaa9ff70179a7b0eff8bef5d45379521f1110e8db1ee03b96d579764c8c344c600000000000854d0000c3500000c31100000000000000109010b07d1b09149d5c5275f85a10d8cd9202c9400ca2def4ad6791075291b4c3c5df300000000000854d0000c3500000c3110000000000000010a010cca26248920d3f7592d14d56435f07ccaba5650dfb6a88e4e39075376402718a300000000000854d0000c3500000c3110000000000000010b010ddee81eaa499f230eefdac7766ac46fda3d15ce50ba634d5dddaa37ed8b27706f00000000000854d0000c3500000c3110000000000000010c010ecddf241b4264b637a0ac3315b33d20ad2b4ee78abe9b109cd2ff12910527db0500000000000854d0000c3500000c3110000000000000010d010f2073acd612d2f8b24c44a0d791c8e458cb4209a578c5e54059a80bf078ea62f200000000000854d0000c3500000c3110000000000000010e0110f2bc07ca3c0917130c0549bb4528b3d30787e8a58e1a4e395f797ee1d5313c8600000000000854d0000c3500000c3110000000000000010f01111adb3bb2a1d5a92c063c04d8e4ccba90f31d32705ab9e5bf9937abcae9bd411300000000000854d0000c3500000c31100000000000000110011216d6fc2b34c08eb4c4047768a720a11c236f83a193eddbefb5f177811e1cb74800000000000854d0000c3500000c311000000000000001110113f7ef1e699a40d95dcd4c155bfe82b2458ffc85aad59e9a7b9a38b6f8e20129cb00000000000854d0000c3500000c311000000000000001120114ccd2d776df1d26ee861943082701bbb4b69249fadd8f143b597ac73e2af5c1c500000000000854d0000c3500000c31100000000000000113011556ceceb7b569011a449af0c305c8dbd28c6f9703aed425f8373c8497188b2b9e00000000000854d0000c3500000c311000000000000001140116b2f5712de794c52523a62778e83e0e59a1bbc6f06f97b7789670f0e5039ea9a100000000000854d0000c3500000c311000000000000001150117693cec364193e49f8f6aebf42955d1b49acda6c888a3a6e2ee274e372a45494b00000000000854d0000c3500000c3110000000000000011601184ebc0b53de00bce7dc1119c28cbf0648dc7ad74c976e4b49b1e5ab6d372fe7c800000000000854d0000c3500000c3110000000000000011701197dc46f6965e74bbaeb7789ee532264e843103a39d7a9d75132de7e9b5ef02a5300000000000854d0000c3500000c31100000000000000118011a00b1c69e8c2aee8a42f849b56150603e0541ba97656911b2bec92fb6b80e1c4700000000000854d0000c3500000c31100000000000000119011bbe8e25448c1ed1d4cf6851269c121e1c45e3cd58292714ca7a0afcd8c6813dc100000000000854d0000c3500000c3110000000000000011a011c42ef09220765bb717aad3c2d4dcfe180e8c0ebd8421df820386626d3a85e4d2900000000000854d0000c3500000c3110000000000000011b011d595e3312d192af31f4735e7352203a2b3e1ff5c8712247ea76b8d6521328a28100000000000854d0000c3500000c3110000000000000011c011ebb01550134b55966c3b8a175688d7fbd913988eb37701053abdebcd07b2c0ee200000000000854d0000c3500000c3110000000000000011d011fc8b44418ae4ad010eb4a16252265eb6e1ffbb8238ae74055e93170d5aed2959900000000000854d0000c3500000c3110000000000000011e0120996d1a0367ed61db81a131b5b163de5d6f2f693055b87a3cc7a0c6a15bb72ef800000000000854d0000c3500000c3110000000000000011f012126b6c2e89d781417985751c1fde5cd6069167fce4c56138370bcaa24d03ce24b00000000000854d0000c3500000c3110000000000000012001229e7af1b72e21a2785359677d3c101e9029b117a43dba617f8ea56d7dd022d8d700000000000854d0000c3500000c3110000000000000012101232d2e1c3b68cf4b0e8bd492eb5741cf78a3b36847fa0a97c3304a95dcc7a8a14400000000000854d0000c3500000c3110000000000000012201240e06ed3926bb95bf57bc7ee2c9e5f1c5bd661bacb1ce5a1a2deabe6736c8367800000000000854d0000c3500000c311000000000000001230125a6abd820de07f5cda1819cd9a9146f0adf4d8dcc60e3cab50bc5e1abc14891e200000000000854d0000c3500000c3110000000000000012401267ac610ff9d6969b53d28f85a92a1fad522b3bee82f76f010f45ef0a3e4c7c15d00000000000854d0000c3500000c3110000000000000012501277255670d53527fa3d330df360ac6c98d49a6a5ce3f6fdf1de4ca700844c8fa3e00000000000854d0000c3500000c3110000000000000012601281d6d6e357f1227e5e942653388260e8238b5612f4927fbfdce258bc4d0b7594300000000000854d0000c3500000c31100000000000000127012954e9dbc5cf7149868716b45dbdecdc449d0b39b5eafd69e3b49a7ed29d4459ae00000000000854d0000c3500000c31100000000000000128012ae285b863674b89d72cfb884f5cd5eb523ac0b493661c58f7851c5547a959ed8100000000000854d0000c3500000c31100000000000000129012b819532ccb84729849fbc4cce0ac5f8236350c3b67b316338e15cc62b954d30b400000000000854d0000c3500000c3110000000000000012a012cd23d76e2a5311b80e76c5229b01cb07f2bb85646fdcb4c62adc82f198b047beb00000000000854d0000c3500000c3110000000000000012b012df661b595023ecb62cbf93a64d234dbf889166b48fa8eb394bb9fbe841e1cd56600000000000854d0000c3500000c3110000000000000012c012e0513cfb510f00b170cf080b3ce2b850cec369d35c0550eff4b8fb2b6c91dfda800000000000854d0000c3500000c3110000000000000012d012fb9d1d054cf18aae245c31e11c0d122ed5aadd8a3b35ad39dfe30ac8798a46d2300000000000854d0000c3500000c3110000000000000012e0130e06b5899ba907b2923ca8677fa61c7f0943cdd7c1c40def5d83aad630bc00eda00000000000854d0000c3500000c3110000000000000012f0131293692db9029a74afbc68d94e574decdd9aa7f388e8aa344379018c4da18256e00000000000854d0000c3500000c3110000000000000013001326c2b3b309d75e679971f71daae4c69990da8707c359765d2c1f74a56ea45a25100000000000854d0000c3500000c31100000000000000131013391b688dbb1263e7d9c8898a7356dd313e14f066a6db7dbd8a05af623cfb1423000000000000854d0000c3500000c3110000000000000013201345d9daa987405ea5c478cc99679df047e1cca5d28b2ae550d88570c9a7b361b4600000000000854d0000c3500000c3110000000000000013301352d4bf0f568c72724e502c4ea615d9bdfe76205af4649db3381bb039a6c81d5e700000000000854d0000c3500000c311000000000000001340136387afecf394d4357604f540282bbd442f84f456c5ff810d7964941b31bc12f5f00000000000854d0000c3500000c311000000000000001350137b19741e64da87111ec3ab8a714db12a89a0e19cf667a0049a9dcd72aa5c30cfe00000000000854d0000c3500000c311000000000000001360138041c4bfedd2c41b363acd3fa746b0e65820abf0259851616c147a6253982860600000000000854d0000c3500000c311000000000000001370139bd4d8149d173108a9685d7305f1624dae2c8dda08e475d90494ff93e3b68d3f000000000000854d0000c3500000c31100000000000000138013a6807c6439794cc48e36826e845ad919d758bc0308611f2b2063f35e59badcc2800000000000854d0000c3500000c31100000000000000139013bb94b58b11fd0a16ba852395acedb03563c9992103fbb9c4541c0140ad25bba0d00000000000854d0000c3500000c3110000000000000013a013c98f301ca34257ff0bb60f8a29e8c2e75569f12c2e757cfcd86d441987754db9800000000000854d0000c3500000c3110000000000000013b013dd33a298ca334510f32a78b0d175ef81aa1283c5a43a0a36997337a188cf28df100000000000854d0000c3500000c3110000000000000013c013e6535f74d7b79af5ab10020827567deb1d7feb0d68c97b24375444fc91685c3d400000000000854d0000c3500000c3110000000000000013d013fcd31ecebd30f110cd6faeddf43207ca9ce128876a99d882d1eb2956ac66baeb600000000000854d0000c3500000c3110000000000000013e014080dc9f243390f1e5ff5f824b3558f3a71558ee2b76b6dc85b8767d8c3a762a9900000000000854d0000c3500000c3110000000000000013f014135e665b8a599899c0e0c8e20d270917ab23a1c5ec1392031cda458edb1c95f4700000000000854d0000c3500000c311000000000000001400142f390439757563240273ed69da2e0b49158ffac612227c538ab4df21e668f072400000000000854d0000c3500000c31100000000000000141014375199d7a5f1d5702a5e97030d9b85af5459e59ece07c34cf4eba44fc2fbe75f300000000000854d0000c3500000c31100000000000000142014488eb334984eb5a14d76478f8c7e277804cb813c573fe185e1269a0b1b3a7c4af00000000000854d0000c3500000c3110000000000000014301452934a43425a844e0cfcdce1f191ac83c216aabfe9cfae82ad8fb80e5ed32fd9500000000000854d0000c3500000c3110000000000000014401463e4182cabfe4bca69a2b2d26394b851e6f313fd7400e34f66b207fa7997336ee00000000000854d0000c3500000c311000000000000001450147beca3c2b2abf74149413af897d503c0940a04e65488cdca79adc80f3a1022fa700000000000854d0000c3500000c311000000000000001460148358cc7525792ec60cad166479beed863fbda52d75a2b3b21b44cb3948c74c3e500000000000854d0000c3500000c311000000000000001470149084911fc1e172537175b5cb1ed8e0ecae73beda0b4e5e13b826819e1dfeab3d900000000000854d0000c3500000c31100000000000000148014a415167f1a6ff33eb7eb3a208895a8bc11057d6fc0e3d3cde21eb23a9e8f0428f00000000000854d0000c3500000c31100000000000000149014b6bd1174aa697898fbf5fa97167d246f94cc930add143f6b5f3ba1c189160fe9900000000000854d0000c3500000c3110000000000000014a014c630f10f3c50d48328bf9fb5ecc61598d7fb7da587fb7821e4b42151afc4ee36d00000000000854d0000c3500000c3110000000000000014b014ddc396d879f620ece1af21eb2ae7af3ccb63520b7e5c2c86ff9704ecfeae2897300000000000854d0000c3500000c3110000000000000014c014e36074edd4eb00de98fd3f92fa28e76e19abdccc7183a427e0f80f2545099249000000000000854d0000c3500000c3110000000000000014d014f329bd6b4a05c40900c16d963d4cb2c32cd3b866fd2704809359d4e7be2b4d3c900000000000854d0000c3500000c3110000000000000014e0150e0d278e14a883573033cdcdbfe68747f69c02a77aee2631a53854877cd977f8c00000000000854d0000c3500000c3110000000000000014f0151cc619d317d853e8a8c9605c6b2016c5b77d1ed0ce2764865af5bb58da60d25fe00000000000854d0000c3500000c311000000000000001500152091da59c8e15c2d0b67a0c03241ed38bcaa08afe611e374ff56a04b1493496c000000000000854d0000c3500000c31100000000000000151015351d3aca70ad269740eb8143d40c40ccd9655137e1a797db99c19f0c74496eb0200000000000854d0000c3500000c311000000000000001520154e8b3f8186945c665a130ce8bb5022be66ee9a82d58bd04995dc2ed8817b9cf5300000000000854d0000c3500000c31100000000000000153015582bbf5437a38dff5538c0853418b67eed524fd6ec6730af6e83fd7e1819aa9a900000000000854d0000c3500000c311000000000000001540156126f1cdc86e71f3a885ba96d926e619de1dc600a5e917556b59349709a09202f00000000000854d0000c3500000c31100000000000000155015742f8ff596b8d24eb09eadb09653801d690e24c1fd00201534185e5d76616e75100000000000854d0000c3500000c31100000000000000156015838866d0eaecd45ba609331dc127de1cc6de73bedf6c8857b39d7bca574d5a95900000000000854d0000c3500000c311000000000000001570159fc5edceefdd5ba830ee696fa281bfaff5f19f03a517bcc14f2a723cb43db17bc00000000000854d0000c3500000c31100000000000000158015aabd47e25adb0dc27d1c5a063dd640ee22ac5716a1dcdbe152c27c2a4925deee300000000000854d0000c3500000c31100000000000000159015b44080f378bc7003840f1d01437c268fb20c7f9bf4b38cfaf1b18bbcdf7368fc500000000000854d0000c3500000c3110000000000000015a015c130157d7beaaf191672619dffdb99204c2ab01b35d299c29b4a4c9f1364570df00000000000854d0000c3500000c3110000000000000015b015d139c7812864191abffe378364b46c4929ce9f354e57994e56ef8690a9adbdc6b00000000000854d0000c3500000c3110000000000000015c015e76b343cf768eea6ea9430c144d1f833d2fd19f01a0b8d04e09ad0625c0c1ce4700000000000854d0000c3500000c3110000000000000015d015ff182f63a888b037893c449126093bb70f97b7360b903cc02705619b8232cac9700000000000854d0000c3500000c3110000000000000015e0160ddb46b199bd616225b980e5cf964c647898a44241ca45335f679143b0097d48600000000000854d0000c3500000c3110000000000000015f01619d4246b117a38112ba1bd600e78490ba170a1d1307043f3c83c2a1a2d50106c900000000000854d0000c3500000c31100000000000000160016205f692aacc895ec966a4c41045525845352c1993f7d5fad4f836f286565c62b900000000000854d0000c3500000c311000000000000001610163051eb76af4a34d66106771401412f01f4ac661594140355541f815f5e1e7490300000000000854d0000c3500000c311000000000000001620164b7e76d9101f61768d4ea6470dbf632941ad250c20d2ed9b3a9b62fc2ae398c0e00000000000854d0000c3500000c3110000000000000016301655cf571d20737997ec5f0f856dc59a1104d5b153b9377cab0a3107dcef4ee1c3400000000000854d0000c3500000c3110000000000000016401660bc27dbed94378bb13a6b1bc1a61789be9572d1d42a7611338d5a3e1351768d000000000000854d0000c3500000c3110000000000000016501670e63f7f71c19b7eda6368a15dc5dc2e7295541888e60738db15b3cb79292617000000000000854d0000c3500000c311000000000000001660168144788ed1842d11dfc84d06d656f2b8390e11778066f21d5f97626bd72b475fd00000000000854d0000c3500000c311000000000000001670169c4642f1fc647f5c8efe7acc255ba6bab55d280a095546aaca38921df1549375800000000000854d0000c3500000c31100000000000000168016a1e52b4f91a493d81e0ad67231f835c8f1ec2fbab50d1f4e5e324504000ee244500000000000854d0000c3500000c31100000000000000169016bb037cfbfe23395592f82f1ee8d5eab8a2277d8e3644908c775b16396774338f500000000000854d0000c3500000c3110000000000000016a016cd3781d0b608c534512544055182fe979fc8d439f5c49b64cc948bd6f7b6314df00000000000854d0000c3500000c3110000000000000016b016d800b2593788c198f7321ce6424338ef7e83123e241aa7fbe8dc38e7c1e3de86200000000000854d0000c3500000c3110000000000000016c016e97b0f44eae74f3a3bfba8c15b94663e93067a575289e7034c8ca6fb8fed4d1cd00000000000854d0000c3500000c3110000000000000016d016f87ab47f0d8959d60eb4147714356816f6fc95b0fb1f92abfb087f9fdd9721a6000000000000854d0000c3500000c3110000000000000016e017015c8f1a325c0740647aa9af8d6f41b9b364f2d6509cba4c318784ab5f98664fd00000000000854d0000c3500000c3110000000000000016f0171dd3765c9c21ac14cced443eed9254bb76cf595dd19763bdad8f3762118180c3300000000000854d0000c3500000c31100000000000000170017205dc8d8d03f1d1b79383291c9b91c6dbed7b5868793a94d86f8cd0b02ea9dffb00000000000854d0000c3500000c3110000000000000017101739285e8ae6dac04fec83f99de4cfd81186130119af91f3af4c2c2ed9556cd73b800000000000854d0000c3500000c3110000000000000017201745f9b9c5c46848b51684d15e7a018c16c7433251d4e05bf0a6fbb1c22e26624eb00000000000854d0000c3500000c311000000000000001730175356f2fcca2d099ddf402b0f28922c8be5276c406fb6a262a10e1ba2d37c1310d00000000000854d0000c3500000c3110000000000000017401762361231644dae69b1d58f83ac1795d89062f1d20f1e18bc58a38beeab8a143d500000000000854d0000c3500000c311000000000000001750177fc410231f56ded5634a2b827172d7ff54326b4d9ba55aff6f76dc0c1d8abe73400000000000854d0000c3500000c311000000000000001760178a3ce42369319689c90263bcf693655b2b5f73426e4299bb3edd27a61fcea84d600000000000854d0000c3500000c311000000000000001770179529f4cfceb408d8e4db8a93a81948458df679039b6b286ef8e2d6b392e584f7b00000000000854d0000c3500000c31100000000000000178017a652a19f302298f2ddedc5bdde42af0c1617f981d5942d33bd7bb436780ef950500000000000854d0000c3500000c31100000000000000179017b78799b9c840639e4d0d50fd3e6e4728c62c0bd0378096552acbbd4096572acb800000000000854d0000c3500000c3110000000000000017a017c8eb8760d4cf5b9077c2871390b10c3af43aa24de7873c145ee0efb6549c7d6ec00000000000854d0000c3500000c3110000000000000017b017d662d3d9dff9fc1daee14bac71957f258d155d3cf5351507f7f9c75f9fdf9750100000000000854d0000c3500000c3110000000000000017c017ee0dd1b9635b59c05c79911a8d80bff18b7575e6429c8d709a869e6a06e04c30b00000000000854d0000c3500000c3110000000000000017d017ffc1ae8163736881a06d397844694f4c1b611b2ec421e3e85830e6a0b06e7626400000000000854d0000c3500000c3110000000000000017e0180103f4ec21762e94c3a6176166b0112f1dec7ed9cec100950fed6956966a114a700000000000854d0000c3500000c3110000000000000017f018147369da11f17dbfdebe6329cef69ae7150b9003b2a03efda39443b9958f8154c00000000000854d0000c3500000c31100000000000000180018293127c2a56004f8d0a7a8fe5866174cea89697bf41df5e122ce7a23215e2d60400000000000854d0000c3500000c31100000000000000181018325e789b4b54bce1b239c8f012e156269e109a865bb46cf6d615412a6bd1e060a00000000000854d0000c3500000c31100000000000000182018415d04d386172988a10fb74a66f2edb3798cae4ae180cbf1b110e1ce447d1cded00000000000854d0000c3500000c311000000000000001830185d14b28e04b1008b7716368751b7281fd1c56efb14c4bda843b2e3e56a6dd167b00000000000854d0000c3500000c31100000000000000184018608135c514ba440467ae403e0a65e81f359ae4241789416377f7502ce739f6c3000000000000854d0000c3500000c3110000000000000018501874d1153cb1b5b24b1d021059395131e760ff239f5ac04487fabed4265acb7f43a00000000000854d0000c3500000c31100000000000000186018859c506c7f1424e8f6a02cbb4b0488a8253ed13971bd35d9857273fc03d9c2e3e00000000000854d0000c3500000c311000000000000001870189defb781b52260a9d6fd63130f1b8d6d603e4247eaae108d635e2e5bc04c5382e00000000000854d0000c3500000c31100000000000000188018a5335d54f7383e77089b8fdd6652f3fef12fb0f70fb2a84d2affb193bb1b7095f00000000000854d0000c3500000c31100000000000000189018bbf3e5a3c2868086d150869804d22d5da63dba8992833908943ce66f90dad171a00000000000854d0000c3500000c3110000000000000018a018cfed9687c86989821940a117f7fc601ff678677a12178775caca21a09249da22a00000000000854d0000c3500000c3110000000000000018b018df8ce826220eef839cd84d2b9df12a0f0ee5806b4aa19c173a92308a9f084543100000000000854d0000c3500000c3110000000000000018c018e1921efda69bc2cf34f1e47081b44b6400291eb927f4980a94f3134b111a2523200000000000854d0000c3500000c3110000000000000018d018f37c353aaaec137e0c9db2d33427135e6f8473ea35384b6a3eb206af450916ed100000000000854d0000c3500000c3110000000000000018e0190f3a2a2e0cebabac99e3d67147da573f39fcab1746d2a05d2e22a060af92efe9600000000000854d0000c3500000c3110000000000000018f01919375113bf780682741b62602e90cb9b3a50132ef3443924c0b93b7f3c0ea80a600000000000854d0000c3500000c311000000000000001900192b5085704b4b6b823059acda09858529e4e9fb542b52c668b392bc2900378b07900000000000854d0000c3500000c311000000000000001910193da08774a6ff7e6968eddad0fe921d39051caaf3c2bc2aa047102baa574852e7e00000000000854d0000c3500000c311000000000000001920194e6006b57c070a434ba17e69fc30dfdd88fc3faf7c00465df22f22fc575051dea00000000000854d0000c3500000c311000000000000001930195eb004427e0c3795eeb74ae52d8eb908d6395d93e90d93f113a941d728c7a186600000000000854d0000c3500000c31100000000000000194019625cce4babf6d48d362e642b15f5234e346d0ad56e01eff852be1d4bad5da4d9f00000000000854d0000c3500000c3110000000000000019501977de1734b52094a5045f4d3a962b0811c40148ab37a8cb8949ada98b9bb2ef98e00000000000854d0000c3500000c311000000000000001960198f6bb35d733dafb8788f89a35fc734c6fae0c79c7ef79d9ab79a619331d5991be00000000000854d0000c3500000c311000000000000001970199f608ebfa1bda8ea7044d7614459001856762aea6fa2a8da8a84f63b35b784aa900000000000854d0000c3500000c31100000000000000198019a5efd4cd712e8d819797cbdd2e5eb14ab8479581992c5cb4fefb71acd21259c9800000000000854d0000c3500000c31100000000000000199019be469abb620208408b16c28a5b22a408e86745059c187e533738786c8321e62d600000000000854d0000c3500000c3110000000000000019a019c55b7d42391295649268e33972b9b05e3863b06e927b1b6821f6be5c22cd5b32300000000000854d0000c3500000c3110000000000000019b019da71224eb2c47f79b4b7897332b4edf21831b271dd31265ea82a56ee423fbc51d00000000000854d0000c3500000c3110000000000000019c019e625238678138161a8a222110d7fae260b0d53dd718d8f0bbebc6dbf9f7c603d000000000000854d0000c3500000c3110000000000000019d019f998478e02c1ccb514f538e60d2d42bead26565df3239926c7ab2ba9e2556375c00000000000854d0000c3500000c3110000000000000019e01a0cadb273ea32290f6b387130461ebd022c6b559d4485f2dded4dc866794b1952e00000000000854d0000c3500000c3110000000000000019f01a1dcc463d2f12315dd333110aec4216d3ad68442ea011ebbf65cc03d6fc4def28d00000000000854d0000c3500000c311000000000000001a001a226af9c7b0ad6986d2014ee1c344720f7e5ecb5912d4533e3db9cec5abf3766fc00000000000854d0000c3500000c311000000000000001a101a337b0243c9147d7cdb24ab677cb997793184b4c6cbe078a2339c7050553debf1000000000000854d0000c3500000c311000000000000001a201a40b65bf566728aa8f0bd3b5f8b03efad99fe4d86570c3792a009bbd23aed8851600000000000854d0000c3500000c311000000000000001a301a57c0f33fb634f5b1cfad7cc130da55119a2fc282e4975d40204af4084a9df275300000000000854d0000c3500000c311000000000000001a401a610d4665d3a1c11e4bd872b0b47d822fefc75e8010f2766febba4206548da19e400000000000854d0000c3500000c311000000000000001a501a7e5998fdf3f51aedac8c428ebbe458f8039f8da6d996381998888846d7d7eac9300000000000854d0000c3500000c311000000000000001a601a84aa1e7b220a2ccb3d28ae1fc383aa4319dcb8df1a554f98e360ccb1b630674b000000000000854d0000c3500000c311000000000000001a701a90296a7c4204e0380ac993850b4041042fcff74542ba713574070c2982e5fe06d00000000000854d0000c3500000c311000000000000001a801aa1bcebf698d2ebc669f1e2e3ebbdb2bc9745616b0dddf8503965e9ce1bfbfbd4300000000000854d0000c3500000c311000000000000001a901ab17ca3153cfcf2c91ab678671c0193785a38fd13243f23bacf712297c1c3161df00000000000854d0000c3500000c311000000000000001aa01ac42aa6f3b6b844e832587ca38676960f99da8a7bf1cd0861f9476e8148056dc7b00000000000854d0000c3500000c311000000000000001ab01ad1677bf1f8138392b8a896be86688d8e2a852c94abed179d515ea75cbb0f0114400000000000854d0000c3500000c311000000000000001ac01ae5d07f5c149c59191cf2e949908b485c269c7237df230bcc66bff9d221b912af100000000000854d0000c3500000c311000000000000001ad01af4af01101f2fadf75503b90fa5519fabc113be039f7cac24d6a425ff6f4d46c9100000000000854d0000c3500000c311000000000000001ae01b0b75ae35825e628b1d386a81c380eac87e5063ea2fb581058a44e67647606faec00000000000854d0000c3500000c311000000000000001af01b1b3ace4f6e0b4d2504edf42d9fa09490ca53c59401c5ac8eae407a0f0347dc2b400000000000854d0000c3500000c311000000000000001b001b2da0319bb42c74cb71cc24608d303045b7c66b21c9cad1bcefa23190ea9008afd00000000000854d0000c3500000c311000000000000001b101b3e61dacb664d45941c172a551ede9e667bcf095113c15bac9217e1069c4549d3a00000000000854d0000c3500000c311000000000000001b201b46fea6aac4b9e2c07142170421a6f4f81ba464c8ddc49887d9f0f7826314b772e00000000000854d0000c3500000c311000000000000001b301b51df4dfc3f008e8b2348c48574f1c8e555a987162ec8347d793d0ceda160c952800000000000854d0000c3500000c311000000000000001b401b6e793bdc7a9b90cfd1a09081912fe24f588947146e99c46f58754cb16f81f6cd900000000000854d0000c3500000c311000000000000001b501b7ce19f510bda9ca936f1ce98af2c35aed4c87e98c38927a095b9b94f741305f9400000000000854d0000c3500000c311000000000000001b601b8c7930f425336449c2c3208d0e0b74d37e1eb5c1c0311e9a77bf00bf103daa2df00000000000854d0000c3500000c311000000000000001b701b9c7d9c3fecba42d41c4ceb332133da641fb6cdc531897be0caabbc3261cac5c5900000000000854d0000c3500000c311000000000000001b801ba871ca2ac3fcb9449b1cd3903798aa08e0e751d505d9aadec2d2b912094dd05c500000000000854d0000c3500000c311000000000000001b901bbd1dac0d405505faf085050662a9b20fe2e3398fd01a9b011dda41a55c740485c00000000000854d0000c3500000c311000000000000001ba01bc8aca06e52f2ee6f62f413b23f3bac9b4da3e93a2fb788f34c085be0128a293e600000000000854d0000c3500000c311000000000000001bb01bd50562444dd139b2bc45aa2064e6f945e1b4ff89ceef30842195d7e3766b5a25400000000000854d0000c3500000c311000000000000001bc01bea3112af2adcf6c56ac46a4cbf93312e2b6b7ea1848185a85273a7b11251c217900000000000854d0000c3500000c311000000000000001bd01bffa802b543ca6922c0656320b0761b883b9c1d75228ac9a7e9626ec76a7c8482000000000000854d0000c3500000c311000000000000001be01c0b2fcc13e833dd5bdd5d3c8ecb1745f50d831619c895ba3dcd24597bc52e0f67500000000000854d0000c3500000c311000000000000001bf01c11ddb4c5c55ed2746c69088f47da9cf8450cae2cba98e8a997381709e234c811500000000000854d0000c3500000c311000000000000001c001c2f88327f3873e70e5742dba0a8a41ab4555ef75ccbd49812534d625a38086465c00000000000854d0000c3500000c311000000000000001c101c3877f5cb86d90f3acfe194d244ff85604036251e73be7453d043470e8e87d1df100000000000854d0000c3500000c311000000000000001c201c41b6468631457a999e13d8556d28875f80d475e4080a822606e53ab4521e612fa00000000000854d0000c3500000c311000000000000001c301c5e27506829355cd7edd93156f5d3d78038a3cfa19d1b94502097914558d7a99d300000000000854d0000c3500000c311000000000000001c401c6e922eb6004f428a319dce0b00be94d9d65e8a891e0da8092cd17eb57f42719d700000000000854d0000c3500000c311000000000000001c501c72cf19e8b9cdbbce8788a37b61803d8c5ac9b705b451a16cf3fd4e44c98a6978400000000000854d0000c3500000c311000000000000001c601c86d4c389a327e75e07e26dde46d96ec5f9cbe652798c6e4c4f071e518ca431ae400000000000854d0000c3500000c311000000000000001c701c9ea874c9bfb2c727a572b1bb77a3911ddfe4890f0f4cc152b67e4490953fa741300000000000854d0000c3500000c311000000000000001c801ca3078da4bda050f5f723492d3f63a9f465a3f8352414b9b24747052268e8fd9f000000000000854d0000c3500000c311000000000000001c901cb0cbf24109afca71dc10ec95847ccd30693a340ac17a1c438de32c9d5ec779ce600000000000854d0000c3500000c311000000000000001ca01ccd1eebb68e4560bfc706060c2557d03e5fe55e5c76f1b0ef8e8b9102af101e1b600000000000854d0000c3500000c311000000000000001cb01cd60f5505127a1c513a7bf51a506bbd38413db23366690dde3b7a64ada8b38c3bf00000000000854d0000c3500000c311000000000000001cc01ce5f2a77c993b3c8c2860b9b6f661657e2ed9c3fb8a7f07b9f86e6908ff61e504000000000000854d0000c3500000c311000000000000001cd01cfbeeced096c0d29cb3117bd673c5a13fcc06788b4db4d7d24a2bb143cd675772200000000000854d0000c3500000c311000000000000001ce01d093861f3b9c8f22a3fc7f39be4019cbee78624119caa0ac94b509b691e516835c00000000000854d0000c3500000c311000000000000001cf01d13083a24fa948cd62be1d683b9cacaba9117c6a95ba3af20f8df2bedefbe3456300000000000854d0000c3500000c311000000000000001d001d2d12e24a4fc1c9116fc4eeead881d06ea1a7ecfbe5b50a60da676d03906784c3500000000000854d0000c3500000c311000000000000001d101d35c572a6639f35bec2e4ac4d0afad8f0b1a5b56f379ba18bb0f5a2239bed5684500000000000854d0000c3500000c311000000000000001d201d4fe0dd02a29705d5066285c49d0f62b8b7703247af1ca3f3ef80c72d2e73d4b0900000000000854d0000c3500000c311000000000000001d301d5270a3d63c0ee9b56b60d8422820e7d421d7cb5edd90bd36f171f3a248ca8ba2200000000000854d0000c3500000c311000000000000001d401d6a0ad7dd4c1094a8d2cad3b5f58c2f6521a82fcda6145f50a67031c99fac5ee3300000000000854d0000c3500000c311000000000000001d501d7bbc593f697857610ae607fcc8aaadfbfa9ccc8b666c1610fe58638c4b81b39d600000000000854d0000c3500000c311000000000000001d601d82ef8ef79e3f3125048e96e178148b987ad8ce75ddb86f226c9422f88311a1e5400000000000854d0000c3500000c311000000000000001d701d9584d870aff1afb86ba69ddc59fe8f846915e32928ec2b41ecfdcec2ddf6e034800000000000854d0000c3500000c311000000000000001d801da3f220ce62505abf5bd046d810d0a73121491fc5845984c6fc49d95fff620acf000000000000854d0000c3500000c311000000000000001d901db239fd782432147be7b01479d1bbf685ad71220496c5fbe49751f43661ad97ffa00000000000854d0000c3500000c311000000000000001da01dc452dbbf443cb66f47d94219064c1e11d02a35eee965b7f853b387676a12fc1bb00000000000854d0000c3500000c311000000000000001db01ddc92ca5e3fea2af39b5290131d48ae5ac6b8b3da7f8939f3416427aa7bf23c49500000000000854d0000c3500000c311000000000000001dc01de532077ccb94f30ba0e14ae25792e182fd3a9c89a136112a7875a582877d067c900000000000854d0000c3500000c311000000000000001dd01df994fd79bcbb666c872f3cf82331b4890fd26895337b42a7f957e3da716d9dede00000000000854d0000c3500000c311000000000000001de01e0b9563d83c0644780a2f7dc8441b391e23ad1a9d86a23b965931c13d66e41314e00000000000854d0000c3500000c311000000000000001df01e1a356c15452b8070d12716b40768018b3315c33b00a40c54fc825a77e16189b5800000000000854d0000c3500000c311000000000000001e001e250d2f5e73eeac5e1263a0df8a53ba2b1deb5b8e7815c61bd8d70015ed1912fa200000000000854d0000c3500000c311000000000000001e101e37498c3f78d908282db2e0ad1a117dad769c93569014fa2500d266959dc58f9d700000000000854d0000c3500000c311000000000000001e2", + "receiver_txid": "31d842bb6d8a1529dd54d013c7a439827864c4ff4867a8af5080a68b0f25aed4", + "settlement_txid": "b772ab617557851d0f05d96026f51ff9b7baf43c6ef3ee72153495c41c8c2b09" + }, + { + "scenario": "D.6", + "init_hash": "d290b308abba2f4346a5fb0e2477d54eada86eb455d298a14570a8c90aae4d7d", + "setup_hash": "eb4e7e4a56d8612ce7dddd7ed5b8b31d22a9c394560909d5f0a8a7cfc6fb5a71", + "book_hash": "dc2a6687c21e580e3cacc642c8f877ded7278fc9b87e7b388ecee30142ecebb7", + "commitment_hash": "8f19b491169952d5c5852cb9c722ec7f9e52b4bf1627f2c80377f0e7bdbe9287", + "activation_hash": "65129deb07cd484e12e9badcbc11dace2e724e9a1ccba6b40ec92d57dbaaf028", + "init_wire": "d6d9bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848975ac6c4567bb853d69517b2fe7660700e83262c129fb80c5f3e7b8705d2b2f930400000000000f4240000100000000000854d0000c3110000c3500000003e80000138800000000000000000000090800000000000f4240e0e99ff77a29e85142624109ed949a2beb6a35de044c32f884e22a67f72308526065b8e983625c675361332e2a10fa506af6b402b069471921b4a366765edf7f", + "accept_wire": "d6dbbef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848975ac6c4567bb853d69517b2fe7660700e83262c129fb80c5f3e7b8705d2b2f93000000000000002a012064c356aaa2f7789a1ddf82024f18b37dfc22b4fb3ec7583ab7b09f8a9ad1312a07080000000000000000090800000000000f42400b20d290b308abba2f4346a5fb0e2477d54eada86eb455d298a14570a8c90aae4d7d5ff3c9076c82f18847c629529938cb5d03cf3136d8b87be432f9ed0bc81a40e231a83a0146d5c1d0473cc11990623ca31ab3defa22e9c96142fc6340e55aac23", + "activate_wire": "d705bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848975ac6c4567bb853d69517b2fe7660700e83262c129fb80c5f3e7b8705d2b2f93eb4e7e4a56d8612ce7dddd7ed5b8b31d22a9c394560909d5f0a8a7cfc6fb5a71dc2a6687c21e580e3cacc642c8f877ded7278fc9b87e7b388ecee30142ecebb78f19b491169952d5c5852cb9c722ec7f9e52b4bf1627f2c80377f0e7bdbe9287000c0df09b032fc561367edc1f08074a67a670db9d5220826d2f5ea2768ea0ae8485bf9367549039a160219ba6393387728aeb66cdf27647fe07d3d201b0c7d2df813051", + "ack_wire": "d707bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848975ac6c4567bb853d69517b2fe7660700e83262c129fb80c5f3e7b8705d2b2f9365129deb07cd484e12e9badcbc11dace2e724e9a1ccba6b40ec92d57dbaaf0281b127837288c4ae30686b772f13791954b42dafb2b318eba7f91545e3a09089543a8e85aedf462ecd5a1338602329a036c49c41473420ac1f2d2b911898baacc", + "book": "75ac6c4567bb853d69517b2fe7660700e83262c129fb80c5f3e7b8705d2b2f9304010001000164c356aaa2f7789a1ddf82024f18b37dfc22b4fb3ec7583ab7b09f8a9ad1312a00000000000f4240000c3500000c31100000000000000000", + "receiver_txid": "5a294b75a1b592d11670560d0e7976fabaac0779292f2aaa4096e81f73e591c7", + "settlement_txid": "d76e272a1353cd34eb8ee2eeefe8783f24b77a0d5424ffb253961af387d7de82" + } +] diff --git a/crates/ffor-protocol/tests/data/extract_appendix_d.py b/crates/ffor-protocol/tests/data/extract_appendix_d.py new file mode 100644 index 0000000000..215a8be4bf --- /dev/null +++ b/crates/ffor-protocol/tests/data/extract_appendix_d.py @@ -0,0 +1,94 @@ +"""Extract pinned public Appendix D fixtures, reconstructing abbreviated D.5 bytes. + +Usage: python3 extract_appendix_d.py /path/to/ffor-variant-d-vectors.md +Writes appendix-d.json beside this script. Every reconstruction is checked against +the independently published hashes, and uses published signatures without signing. +""" + +import hashlib +import json +import pathlib +import re +import struct +import sys + + +def sha(data): + return hashlib.sha256(data).digest() + + +def field(section, name): + return re.search(r"\| `" + re.escape(name) + r"` \| `([0-9a-f]+)`", section)[1] + + +def tlv(kind, value): + length = len(value) + encoded_length = bytes([length]) if length < 253 else b"\xfd" + struct.pack(">H", length) + return bytes([kind]) + encoded_length + value + + +def extract(source): + fixtures = [] + for number in range(1, 7): + section = source.split(f"## D.{number} ")[1].split("\n## D.")[0] + fixture = {"scenario": f"D.{number}"} + for label, name in [ + ("init_hash", "T_init"), ("setup_hash", "T_setup"), + ("book_hash", "H_book"), ("commitment_hash", "H_commit"), + ("activation_hash", "H_act"), + ]: + fixture[label] = field(section, name) + for label, name in [ + ("init_wire", "ff_init"), ("accept_wire", "ff_accept"), + ("activate_wire", "ff_activate"), ("ack_wire", "ff_activate_ack"), + ]: + message = section.split(f"**`{name}` (type ")[1].split("**`ff_")[0] + if number == 5 and name in ("ff_init", "ff_accept"): + header = bytes.fromhex(fixtures[0]["init_wire"])[2:34] + epoch = bytes.fromhex(field(section, "epoch_id")) + amounts = struct.pack(">Q", 546000) * 483 + hashes = b"".join( + sha(sha(b"ffor/vector/D.5/preimage" + struct.pack(">H", k))) + for k in range(1, 484) + ) + if name == "ff_init": + wire = struct.pack(">H", 55001) + header + epoch + wire += struct.pack(">BQHQIIIIQH", 4, 263718000, 483, 546000, + 798992, 800000, 1000, 5000, 0, 0) + wire += tlv(9, amounts) + else: + wire = struct.pack(">H", 55003) + header + epoch + struct.pack(">Q", 42) + wire += tlv(1, hashes) + tlv(7, struct.pack(">Q", 0)) + wire += tlv(9, amounts) + tlv(11, bytes.fromhex(fixture["init_hash"])) + signature = re.search(r"signature \(final 64 bytes\) \| `([0-9a-f]+)`", message)[1] + wire += bytes.fromhex(signature) + else: + wire = bytes.fromhex(re.search(r"Wire bytes:\s*```\s*([0-9a-f]+)\s*```", message)[1]) + expected = re.search(r"SHA256\(wire bytes\) \| `([0-9a-f]+)`", message)[1] + assert sha(wire).hex() == expected, (number, name) + fixture[label] = wire.hex() + if number == 5: + book = epoch + struct.pack(">BBH", 4, 1, 483) + for k in range(1, 484): + payment_hash = hashes[(k - 1) * 32:k * 32] + book += struct.pack(">H", k) + payment_hash + book += struct.pack(">QIIQ", 546000, 800000, 798992, k - 1) + else: + book_section = section.split(f"### D.{number}.4 ")[1].split(f"### D.{number}.5 ")[0] + book = bytes.fromhex(re.search(r"```\s*([0-9a-f]+)\s*```", book_section)[1]) + assert sha(b"ffor/book" + book).hex() == fixture["book_hash"] + fixture["book"] = book.hex() + txids = re.findall( + r"\| `n_[RS]\^act` / `txid\(C\^[RS]\)` internal byte order \| 43 / `([0-9a-f]+)`", section + ) + assert len(txids) == 2 + fixture["receiver_txid"], fixture["settlement_txid"] = txids + fixtures.append(fixture) + return fixtures + + +if __name__ == "__main__": + fixture_data = extract(pathlib.Path(sys.argv[1]).read_text()) + pathlib.Path(__file__).with_name("appendix-d.json").write_text( + json.dumps(fixture_data, indent=2) + "\n" + ) diff --git a/crates/ffor-protocol/tests/transcript.rs b/crates/ffor-protocol/tests/transcript.rs new file mode 100644 index 0000000000..f15b5e83d2 --- /dev/null +++ b/crates/ffor-protocol/tests/transcript.rs @@ -0,0 +1,96 @@ +use bitcoin::secp256k1::ecdsa::Signature; +use bitcoin::secp256k1::{Message, PublicKey, Secp256k1}; +use ffor_protocol::transcript; +use proptest::prelude::*; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Fixture { + scenario: String, + init_wire: String, + accept_wire: String, + activate_wire: String, + ack_wire: String, + book: String, + init_hash: String, + setup_hash: String, + book_hash: String, + commitment_hash: String, + activation_hash: String, + receiver_txid: String, + settlement_txid: String, +} + +fn hex(value: &str) -> Vec { + assert_eq!(value.len() % 2, 0); + (0..value.len()).step_by(2).map(|i| u8::from_str_radix(&value[i..i + 2], 16).unwrap()).collect() +} + +fn digest(value: &str) -> [u8; 32] { + hex(value).try_into().unwrap() +} + +#[test] +fn appendix_d_all_six_transcripts_match_published_bytes() { + let fixtures: Vec = + serde_json::from_str(include_str!("data/appendix-d.json")).unwrap(); + assert_eq!(fixtures.len(), 6); + let receiver_key = PublicKey::from_slice(&hex( + "039fca7f8157aa768708894ffd92550fe970edd18526a5f936583ea3b54dab3228", + )) + .unwrap(); + let settlement_key = PublicKey::from_slice(&hex( + "02087b7d1b4789170f6e374f0a0e58a1b7a899e34929795314ab6964e69609e9c0", + )) + .unwrap(); + let secp = Secp256k1::verification_only(); + for fixture in fixtures { + let init = transcript::init_hash(&hex(&fixture.init_wire)); + assert_eq!(init, digest(&fixture.init_hash), "{}", fixture.scenario); + let setup = transcript::setup_hash(&init, &hex(&fixture.accept_wire)); + assert_eq!(setup, digest(&fixture.setup_hash)); + let book = transcript::book_hash(&hex(&fixture.book)); + assert_eq!(book, digest(&fixture.book_hash)); + let commitment = transcript::commitment_hash( + 43, + &digest(&fixture.receiver_txid), + 43, + &digest(&fixture.settlement_txid), + ); + assert_eq!(commitment, digest(&fixture.commitment_hash)); + assert_eq!( + transcript::activation_hash(&setup, &book, &commitment, 790_000), + digest(&fixture.activation_hash) + ); + for (encoded, key) in [ + (&fixture.init_wire, receiver_key), + (&fixture.accept_wire, settlement_key), + (&fixture.activate_wire, receiver_key), + (&fixture.ack_wire, settlement_key), + ] { + let wire = hex(encoded); + let signature_start = wire.len() - 64; + let signature = Signature::from_compact(&wire[signature_start..]).unwrap(); + let mut normalized = signature; + normalized.normalize_s(); + assert_eq!(signature, normalized); + let kind = u16::from_be_bytes(wire[..2].try_into().unwrap()); + let unsigned = &wire[2..signature_start]; + let message = Message::from_digest(transcript::message_digest(kind, unsigned)); + secp.verify_ecdsa(&message, &signature, &key).unwrap(); + let wrong_type = Message::from_digest(transcript::message_digest(kind + 2, unsigned)); + assert!(secp.verify_ecdsa(&wrong_type, &signature, &key).is_err()); + } + } +} + +proptest! { + #[test] + fn transcript_fields_and_domains_are_bound(bytes in prop::collection::vec(any::(), 0..2048), height in 0_u32..u32::MAX) { + let init = transcript::init_hash(&bytes); + let book = transcript::book_hash(&bytes); + prop_assert_ne!(init, book); + prop_assert_ne!(transcript::message_digest(55001, &bytes), transcript::message_digest(55003, &bytes)); + prop_assert_ne!(transcript::activation_hash(&init, &book, &init, height), transcript::activation_hash(&init, &book, &init, height + 1)); + } +} diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 241ee1cde5..98a6f00d55 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2424,6 +2424,8 @@ impl ChangeDestinationSource for WalletKeysManager { #[cfg(test)] mod tests { + use std::str::FromStr; + use bdk_wallet_aggregate::UtxoPsbtInfo; use bitcoin::{psbt, OutPoint, TxIn, Weight}; @@ -2516,7 +2518,7 @@ mod tests { .assume_checked(); let fee_rate = bitcoin::FeeRate::from_sat_per_kwu(250); assert_eq!( - checked_payment_target(u64::MAX, recipient.script_pubkey(), None, fee_rate), + checked_payment_target(u64::MAX, &recipient.script_pubkey(), None, fee_rate), Err(Error::InsufficientFunds) ); let utxo = UtxoPsbtInfo { @@ -2526,7 +2528,7 @@ mod tests { is_primary: true, }; assert_eq!( - checked_payment_target(u64::MAX, recipient.script_pubkey(), Some(&[utxo]), fee_rate), + checked_payment_target(u64::MAX, &recipient.script_pubkey(), Some(&[utxo]), fee_rate), Err(Error::InsufficientFunds) ); } From 1d2adb6b9ac30efeba919718d36be7b576441002 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Sun, 20 Sep 2026 15:54:28 -0400 Subject: [PATCH 02/23] docs(ffor): pin implementation references --- crates/ffor-protocol/README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/ffor-protocol/README.md b/crates/ffor-protocol/README.md index 3c056dac65..51aaa22fba 100644 --- a/crates/ffor-protocol/README.md +++ b/crates/ffor-protocol/README.md @@ -35,6 +35,29 @@ in this crate. Parser fuzzing, crash injection, monitor recovery, measured cover and cross-engine regtest remain requirements for the engine port. Passing these tests is not evidence of offline payment settlement or recovery. +## Implementation references + +The receiver and settlement port should be compared with these immutable source +revisions, in addition to the normative specification: + +- [Beignet 0.21.10, `8aee31d18e596fe49a0d195b325a6e757d7a009b`](https://github.com/coreyphillips/beignet/tree/8aee31d18e596fe49a0d195b325a6e757d7a009b). + `src/lightning/channel/channel.ts` owns voucher matching, both-view commitment + verification, activation, freeze and drain. `src/lightning/ffor/` contains the + wire, transcript and witness code. The Variant D setup and settlement tests + cover acknowledgement loss, restart, rejected updates and cooperative return. +- [beignet-umbrel, `12d483462ca2eabd8ae9cc105e69affa420459f9`](https://github.com/coreyphillips/beignet-umbrel/tree/12d483462ca2eabd8ae9cc105e69affa420459f9). + `manager/ui/src/pages/tabs/ReceiveTab.jsx` and the receive routes demonstrate + explicit offline intent, stable request identity, existing-channel capacity + and rejecting a response that is not explicitly offline-capable. The + `scripts/lfbw-regtest/` FFOR scenarios exercise process-stopped receiving and + return through the manager and daemon APIs. + +These sources implement their own channel engine. They do not supply FFOR APIs +to rust-lightning or LND. Port the protocol invariants into each engine's own +commitment and persistence boundary. Do not copy deployment defaults such as +channel headroom or invoice lifetime into Bitkit as protocol guarantees. Passing +reference tests alone cannot qualify the native port. + ## Trust boundaries - Amounts, peer data and public policy are untrusted. Overflow, underpayment, From 891dc1ab55c4bafcdd2d0ce531263b0a414701c1 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Sun, 20 Sep 2026 19:24:49 -0400 Subject: [PATCH 03/23] feat(ffor): validate signed variant d messages --- .github/workflows/ffor-protocol.yml | 13 + crates/ffor-protocol/README.md | 61 ++- crates/ffor-protocol/fuzz/.gitignore | 5 + crates/ffor-protocol/fuzz/Cargo.toml | 23 + crates/ffor-protocol/fuzz/README.md | 22 + .../ffor-protocol/fuzz/fuzz_targets/wire.rs | 45 ++ crates/ffor-protocol/fuzz/seed_corpus.py | 21 + crates/ffor-protocol/src/lib.rs | 2 + crates/ffor-protocol/src/setup.rs | 316 +++++++++++ crates/ffor-protocol/src/transcript.rs | 60 +++ crates/ffor-protocol/src/wire/codec.rs | 81 +++ crates/ffor-protocol/src/wire/lifecycle.rs | 119 +++++ crates/ffor-protocol/src/wire/mod.rs | 158 ++++++ crates/ffor-protocol/src/wire/setup.rs | 192 +++++++ crates/ffor-protocol/src/wire/tlv.rs | 168 ++++++ crates/ffor-protocol/src/wire/types.rs | 126 +++++ .../tests/data/beignet-lifecycle.json | 35 ++ .../tests/data/generate_beignet_lifecycle.cjs | 41 ++ .../tests/reference_lifecycle.rs | 37 ++ crates/ffor-protocol/tests/setup.rs | 337 ++++++++++++ crates/ffor-protocol/tests/transcript.rs | 87 ++- crates/ffor-protocol/tests/wire.rs | 502 ++++++++++++++++++ 22 files changed, 2429 insertions(+), 22 deletions(-) create mode 100644 crates/ffor-protocol/fuzz/.gitignore create mode 100644 crates/ffor-protocol/fuzz/Cargo.toml create mode 100644 crates/ffor-protocol/fuzz/README.md create mode 100644 crates/ffor-protocol/fuzz/fuzz_targets/wire.rs create mode 100644 crates/ffor-protocol/fuzz/seed_corpus.py create mode 100644 crates/ffor-protocol/src/setup.rs create mode 100644 crates/ffor-protocol/src/wire/codec.rs create mode 100644 crates/ffor-protocol/src/wire/lifecycle.rs create mode 100644 crates/ffor-protocol/src/wire/mod.rs create mode 100644 crates/ffor-protocol/src/wire/setup.rs create mode 100644 crates/ffor-protocol/src/wire/tlv.rs create mode 100644 crates/ffor-protocol/src/wire/types.rs create mode 100644 crates/ffor-protocol/tests/data/beignet-lifecycle.json create mode 100644 crates/ffor-protocol/tests/data/generate_beignet_lifecycle.cjs create mode 100644 crates/ffor-protocol/tests/reference_lifecycle.rs create mode 100644 crates/ffor-protocol/tests/setup.rs create mode 100644 crates/ffor-protocol/tests/wire.rs diff --git a/.github/workflows/ffor-protocol.yml b/.github/workflows/ffor-protocol.yml index 08558a74a7..e8438d29bf 100644 --- a/.github/workflows/ffor-protocol.yml +++ b/.github/workflows/ffor-protocol.yml @@ -23,3 +23,16 @@ jobs: - run: cargo fmt -p ffor-protocol -- --check - run: cargo test -p ffor-protocol --locked - run: cargo clippy -p ffor-protocol --all-targets --locked -- -D warnings + + fuzz: + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: crates/ffor-protocol + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + - run: cargo install cargo-fuzz --version 0.13.2 --locked + - run: python3 fuzz/seed_corpus.py + - run: cargo fuzz run wire -- -max_len=131072 -max_total_time=30 diff --git a/crates/ffor-protocol/README.md b/crates/ffor-protocol/README.md index 51aaa22fba..473946ec1f 100644 --- a/crates/ffor-protocol/README.md +++ b/crates/ffor-protocol/README.md @@ -1,12 +1,21 @@ # FFOR protocol foundation This unpublished crate implements checked Variant D amount and anchor-channel book -calculations, plus the domain-separated transcript hashes from draft v0.9.4. It is -not connected to `Node`, the payment handlers, custom peer messages or UniFFI. +calculations, signed setup and lifecycle codecs, canonical books and authenticated +transcript checks from draft v0.9.4. It is not connected to `Node`, the payment +handlers, custom peer messages or UniFFI. It cannot prepare an offline invoice or receive a payment. Work is tracked in [ldk-node #117](https://github.com/synonymdev/ldk-node/issues/117). +## Channel-engine baseline + +The receiver port targets rust-lightning **v0.2.5**, pinned to +[`5bc1dc84b3a1b084f84de4b7ece3d978b678d894`](https://github.com/lightningdevkit/rust-lightning/commit/5bc1dc84b3a1b084f84de4b7ece3d978b678d894). +This retains the current LDK Node dependency generation and its maintenance fixes. +The Synonym fork's `main` snapshot uses `0.3.0+git` and requires a separate API +migration. It is not the baseline for this port. + ## Reference and verification scope The normative source is FFOR at @@ -30,10 +39,37 @@ limits, deadlines, overflow and public-channel fee selection. Property tests com arithmetic with a wide-integer oracle and mutate budgets, amounts and transcript domains. They do not construct or broadcast commitment transactions. -There is no wire parser, signer, persistence implementation or channel state machine -in this crate. Parser fuzzing, crash injection, monitor recovery, measured coverage -and cross-engine regtest remain requirements for the engine port. Passing these -tests is not evidence of offline payment settlement or recovery. +The wire parser supports `ff_init`, `ff_accept`, `ff_activate`, `ff_activate_ack`, +`ff_abort`, `ff_close` and `ff_close_ack`. It bounds message sizes and collection +counts, rejects noncanonical BigSize values, unknown mandatory TLVs and invalid +compressed points, and preserves unknown optional fields in the signed bytes. +Low-S compact signatures are verified against expected channel peer identities. +Unsigned digest construction does not require a placeholder valid signature. + +`AuthenticatedSetup` derives the book exclusively from authenticated setup messages. +It checks fee bounds, unique hashes, requested hash chains, activation transcripts, +height agreement and close preimages, including prefix settlement for chained books. +It is immutable protocol data, not an epoch state machine or proof of live capacity. + +`tests/data/beignet-lifecycle.json` adds five signed reference lifecycle fixtures, +including both absent and explicitly empty preimage TLVs when no payment settled. +Regenerate them with `generate_beignet_lifecycle.cjs` against the pinned Beignet +checkout, using that checkout's `ts-node/register`. Both encodings are preserved +exactly so authentication never depends on normalizing received signed bytes. + +The standalone [fuzz target](fuzz/README.md) exercises parsing, canonical round trips, +signatures and authenticated setup with real signed fixture seeds. A bounded local +run completed 6,359,059 inputs in 121 seconds without a crash. The current suite has +61 tests and three doctests. Nightly LLVM instrumentation measured 838/859 source +lines (97.56%) and 196/198 branches (98.99%) covered across this crate. Uncovered +code includes diagnostic formatting and defensive paths whose preconditions are +excluded by prior validated bounds. These are measured results, not exhaustive +proofs of correctness. + +There is no signer, persistence implementation or channel state machine in this +crate. Crash injection, monitor recovery and cross-engine regtest remain requirements +for the engine port. Passing pure protocol tests is not evidence of offline payment +settlement or recovery. ## Implementation references @@ -74,14 +110,17 @@ reference tests alone cannot qualify the native port. - A valid book is a proposed reservation, not received money. Neither amount checks nor matching transcript hashes imply durable activation or invoice readiness. -There is no new unsafe code, secret handling, nonce generation or custom crypto. -SHA256 uses the existing Bitcoin dependency. Signature verification in the fixture -tests uses its secp256k1 implementation. No production signing API is introduced. +There is no new unsafe code, secret storage, nonce generation or custom crypto. +SHA256 and signature verification use the existing Bitcoin dependency and its +secp256k1 implementation. No production signing API is introduced. ## Required integration -The registry `lightning 0.2.5` used by ldk-node has no FFOR receiver support. The -next engine changes must own voucher recognition, both-view commitment completion, +The registry `lightning 0.2.5` used by ldk-node has no FFOR receiver support. +[rust-lightning #4](https://github.com/synonymdev/rust-lightning/pull/4) adds a +point-in-time verifier for actual committed vouchers and monitor claim signatures +on that baseline. It is not wired into this crate. The next engine changes must +own voucher recognition, both-view commitment completion, persistent ACTIVE freeze, activation acknowledgement replay, cooperative drain and on-chain enforcement inside rust-lightning. A second channel state machine in ldk-node would duplicate signing authority and is not an acceptable substitute. diff --git a/crates/ffor-protocol/fuzz/.gitignore b/crates/ffor-protocol/fuzz/.gitignore new file mode 100644 index 0000000000..a0e83e5bfa --- /dev/null +++ b/crates/ffor-protocol/fuzz/.gitignore @@ -0,0 +1,5 @@ +/target/ +/corpus/ +/artifacts/ +/coverage/ +/Cargo.lock diff --git a/crates/ffor-protocol/fuzz/Cargo.toml b/crates/ffor-protocol/fuzz/Cargo.toml new file mode 100644 index 0000000000..5cb8dafb1e --- /dev/null +++ b/crates/ffor-protocol/fuzz/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ffor-protocol-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +bitcoin = "0.32.7" +ffor-protocol = { path = ".." } + +[workspace] +members = ["."] + +[[bin]] +name = "wire" +path = "fuzz_targets/wire.rs" +test = false +doc = false +bench = false diff --git a/crates/ffor-protocol/fuzz/README.md b/crates/ffor-protocol/fuzz/README.md new file mode 100644 index 0000000000..8cc2d0c63b --- /dev/null +++ b/crates/ffor-protocol/fuzz/README.md @@ -0,0 +1,22 @@ +# Wire parser and setup fuzzing + +The `wire` target checks the 65,535-byte envelope bound, canonical decode/encode round trips, +signature verification using the Appendix D public keys, and authenticated setup construction. +It accepts either one raw message or `[u16 init_length][init][accept]` so valid signed setup +pairs can seed the authenticated boundary. It contains no signing operation or private keys. + +From this directory, run: + +```sh +python3 seed_corpus.py +cargo +nightly fuzz run wire -- -max_len=131072 -max_total_time=60 +``` + +The seed script writes the six Appendix D scenarios' messages and length-prefixed setup +pairs from `../tests/data/appendix-d.json`, plus the Beignet lifecycle fixtures. Generated +corpora, crash artifacts, coverage data and the standalone workspace lockfile are ignored. + +A bounded run detects regressions but does not prove exhaustive parser coverage. The parent +crate's property tests exercise bounded arbitrary bytes, extension lengths, and settled +bitmaps independently of this target. Live network partitions, persistence and channel +transitions are outside this pure codec and require later engine integration tests. diff --git a/crates/ffor-protocol/fuzz/fuzz_targets/wire.rs b/crates/ffor-protocol/fuzz/fuzz_targets/wire.rs new file mode 100644 index 0000000000..ffc89fcf56 --- /dev/null +++ b/crates/ffor-protocol/fuzz/fuzz_targets/wire.rs @@ -0,0 +1,45 @@ +#![no_main] + +use bitcoin::secp256k1::PublicKey; +use ffor_protocol::setup::AuthenticatedSetup; +use ffor_protocol::wire::{Message, MAX_MESSAGE_LEN}; +use libfuzzer_sys::fuzz_target; + +fn keys() -> (PublicKey, PublicKey) { + let receiver = [ + 3, 159, 202, 127, 129, 87, 170, 118, 135, 8, 137, 79, 253, 146, 85, 15, 233, 112, 237, 209, + 133, 38, 165, 249, 54, 88, 62, 163, 181, 77, 171, 50, 40, + ]; + let settlement = [ + 2, 8, 123, 125, 27, 71, 137, 23, 15, 110, 55, 79, 10, 14, 88, 161, 183, 168, 153, 227, 73, + 41, 121, 83, 20, 171, 105, 100, 230, 150, 9, 233, 192, + ]; + (PublicKey::from_slice(&receiver).unwrap(), PublicKey::from_slice(&settlement).unwrap()) +} + +fuzz_target!(|data: &[u8]| { + if data.len() > 2 * MAX_MESSAGE_LEN + 2 { + return; + } + let (receiver, settlement) = keys(); + if let Ok(message) = Message::decode(data) { + assert_eq!(message.encode().unwrap(), data); + let _ = message.verify_signature(&receiver); + let _ = message.verify_signature(&settlement); + } + // Two-byte first-message length also permits real signed setup pairs in the corpus. + if data.len() < 2 { + return; + } + let split = usize::from(u16::from_be_bytes([data[0], data[1]])); + let Some(first) = data.get(2..2 + split) else { + return; + }; + let Some(second) = data.get(2 + split..) else { + return; + }; + if let (Ok(init), Ok(accept)) = (Message::decode(first), Message::decode(second)) { + let _ = AuthenticatedSetup::new(&init, &accept, receiver, settlement); + let _ = AuthenticatedSetup::new(&init, &accept, settlement, receiver); + } +}); diff --git a/crates/ffor-protocol/fuzz/seed_corpus.py b/crates/ffor-protocol/fuzz/seed_corpus.py new file mode 100644 index 0000000000..a0be8152a3 --- /dev/null +++ b/crates/ffor-protocol/fuzz/seed_corpus.py @@ -0,0 +1,21 @@ +"""Seed protocol fuzzing with pinned public signed fixtures, without a signing key.""" + +import json +from pathlib import Path + +crate = Path(__file__).resolve().parents[1] +corpus = crate / "fuzz" / "corpus" / "wire" +corpus.mkdir(parents=True, exist_ok=True) +fixtures = json.loads((crate / "tests/data/appendix-d.json").read_text()) +for index, fixture in enumerate(fixtures): + for field in ("init_wire", "accept_wire", "activate_wire", "ack_wire"): + (corpus / f"appendix-{index}-{field}").write_bytes(bytes.fromhex(fixture[field])) + init = bytes.fromhex(fixture["init_wire"]) + accept = bytes.fromhex(fixture["accept_wire"]) + (corpus / f"appendix-{index}-pair").write_bytes(len(init).to_bytes(2, "big") + init + accept) + +reference = json.loads((crate / "tests/data/beignet-lifecycle.json").read_text()) +for fixture in reference["fixtures"]: + (corpus / fixture["name"]).write_bytes(bytes.fromhex(fixture["wire"])) + +print("Seeded 35 signed reference messages and setup pairs") diff --git a/crates/ffor-protocol/src/lib.rs b/crates/ffor-protocol/src/lib.rs index a743e8aaec..1befe62478 100644 --- a/crates/ffor-protocol/src/lib.rs +++ b/crates/ffor-protocol/src/lib.rs @@ -11,4 +11,6 @@ pub mod amounts; pub mod book; +pub mod setup; pub mod transcript; +pub mod wire; diff --git a/crates/ffor-protocol/src/setup.rs b/crates/ffor-protocol/src/setup.rs new file mode 100644 index 0000000000..ada394387b --- /dev/null +++ b/crates/ffor-protocol/src/setup.rs @@ -0,0 +1,316 @@ +//! Authenticated Variant D setup transcripts and their canonical voucher books. +//! +//! These are immutable protocol data, not a second channel state machine. Construction checks +//! node signatures and signed agreement. Live capacity, revealed commitment secrets, voucher +//! parking, persisted claim material, freeze and invoice exposure remain engine responsibilities. + +use std::collections::BTreeSet; +use std::fmt; + +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::secp256k1::PublicKey; + +use crate::amounts::FeePolicy; +use crate::book::BookTerms; +use crate::transcript::{self, Digest}; +use crate::wire::{Header, Init, Message, Payload, WireError}; + +/// A signed statement is inconsistent with the agreed setup or expected peer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SetupError { + /// The envelope is invalid or its expected peer's signature does not verify. + Wire(WireError), + /// Receiver and settlement peer must be distinct authenticated nodes. + Roles, + /// This operation received the wrong message type. + MessageType, + /// The channel or epoch identifier differs from the authenticated setup. + Identity, + /// A payment hash was assigned to more than one voucher. + DuplicateHash, + /// Requested chained vouchers do not satisfy the signed hash-chain relation. + HashChain, + /// A signed voucher's fee product or upstream gross amount exceeds u64. + FeeOverflow, + /// The claimed transcript, book, activation or actual commitment digest differs. + Transcript, + /// The activation height is too far from the local tip or admission has ended. + Height, + /// A close acknowledgement does not describe exactly this book's slots and hashes. + CloseBook, +} + +impl fmt::Display for SetupError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "invalid FFOR setup statement: {self:?}") + } +} + +impl std::error::Error for SetupError {} + +impl From for SetupError { + fn from(error: WireError) -> Self { + Self::Wire(error) + } +} + +/// One entry in an authenticated, fixed-amount Variant D book. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Voucher { + /// One-based position in the signed payment hash list. + pub slot: u16, + /// Settlement-generated hash; no preimage is part of setup. + pub payment_hash: [u8; 32], + /// Exact promised payee amount, with forwarding fees charged separately. + pub amount_msat: u64, + /// Absolute HTLC expiry shared by the entire book. + pub expiry: u32, + /// Admission deadline shared by the entire book. + pub deadline: u32, + /// Settlement peer's actual outgoing HTLC identifier to use for this voucher. + pub htlc_id: u64, +} + +/// Immutable signed agreement and canonical book derived exclusively from init and accept. +/// +/// The public messages used to construct this value are copied. Mutating them afterwards cannot +/// alter its hashes or vouchers. This value neither owns a channel nor authorizes activation. +#[derive(Clone, Debug)] +pub struct AuthenticatedSetup { + init: Message, + accept: Message, + receiver: PublicKey, + settlement: PublicKey, + vouchers: Vec, + setup_hash: Digest, + book_hash: Digest, + canonical_book: Vec, +} + +impl AuthenticatedSetup { + /// Verify both signatures and signed agreement, then derive the canonical slot book. + /// + /// The node keys must come from the channel's authenticated identities. Check channel/epoch + /// uniqueness and reject payment hashes matching disclosed commitment secrets in the engine. + /// This constructor has no I/O and cannot make an invoice ready. + pub fn new( + init: &Message, accept: &Message, receiver: PublicKey, settlement: PublicKey, + ) -> Result { + if receiver == settlement { + return Err(SetupError::Roles); + } + let (Payload::Init(terms), Payload::Accept(accepted)) = (&init.payload, &accept.payload) + else { + return Err(SetupError::MessageType); + }; + init.verify_signature(&receiver)?; + accept.verify_signature(&settlement)?; + accept.validate_accept(init)?; + let mut seen = BTreeSet::new(); + let mut vouchers = Vec::with_capacity(terms.amounts_msat.len()); + let fees = FeePolicy { + base_msat: terms.fee_base_msat, + proportional_millionths: terms.fee_proportional_millionths, + }; + for (index, (&amount, &hash)) in + terms.amounts_msat.iter().zip(&accepted.payment_hashes).enumerate() + { + fees.gross_msat(amount).map_err(|_| SetupError::FeeOverflow)?; + if !seen.insert(hash) { + return Err(SetupError::DuplicateHash); + } + if terms.hash_chain + && index > 0 && sha256::Hash::hash(&hash).to_byte_array() + != accepted.payment_hashes[index - 1] + { + return Err(SetupError::HashChain); + } + // Encoding validated the 483-slot bound and the entire HTLC id interval. + vouchers.push(Voucher { + slot: index as u16 + 1, + payment_hash: hash, + amount_msat: amount, + expiry: terms.voucher_expiry, + deadline: terms.settlement_deadline, + htlc_id: accepted.s_htlc_id_base + index as u64, + }); + } + let canonical_book = encode_book(init.header.epoch_id, &vouchers); + let init_hash = transcript::init_hash(&init.encode()?); + Ok(Self { + init: init.clone(), + accept: accept.clone(), + receiver, + settlement, + vouchers, + setup_hash: transcript::setup_hash(&init_hash, &accept.encode()?), + book_hash: transcript::book_hash(&canonical_book), + canonical_book, + }) + } + + /// The common channel and epoch identifiers authenticated by both nodes. + pub fn header(&self) -> Header { + self.init.header + } + + /// Original init, including all signed optional extensions. + pub fn init(&self) -> &Message { + &self.init + } + + /// Original accept, including all signed optional extensions. + pub fn accept(&self) -> &Message { + &self.accept + } + + /// Canonical slot order, with one exact payment hash and amount per HTLC identifier. + pub fn vouchers(&self) -> &[Voucher] { + &self.vouchers + } + + /// Section 7.5.3 bytes with variant 4 and fixed-amount profile 1. + pub fn canonical_book(&self) -> &[u8] { + &self.canonical_book + } + + /// T_setup over the complete signed init and accept. + pub fn setup_hash(&self) -> Digest { + self.setup_hash + } + + /// H_book over the canonical voucher entries, without a wire-message wrapper. + pub fn book_hash(&self) -> Digest { + self.book_hash + } + + /// Signed arithmetic terms, to validate against limits read from the actual channel. + pub fn terms(&self) -> BookTerms { + let init = self.init_terms(); + BookTerms { + amounts_msat: init.amounts_msat.clone(), + budget_msat: init.budget_msat, + minimum_payment_msat: init.min_payment_msat, + fees: FeePolicy { + base_msat: init.fee_base_msat, + proportional_millionths: init.fee_proportional_millionths, + }, + settlement_deadline: init.settlement_deadline, + voucher_expiry: init.voucher_expiry, + } + } + + /// Authenticate activation against this setup and the channel engine's actual H_commit. + /// + /// The caller must obtain `commitment_hash` from both fully signed, irrevocably committed + /// transaction views under quiescence. A peer-supplied digest is not acceptable evidence. + /// The returned H_act is only a digest: persist the complete epoch and enforce the channel + /// freeze before acknowledging activation or exposing an invoice. + pub fn validate_activation( + &self, message: &Message, commitment_hash: Digest, current_height: u32, + ) -> Result { + self.authenticate(message, &self.receiver)?; + let Payload::Activate(activation) = &message.payload else { + return Err(SetupError::MessageType); + }; + if activation.setup_hash != self.setup_hash + || activation.book_hash != self.book_hash + || activation.commit_hash != commitment_hash + { + return Err(SetupError::Transcript); + } + let deadline = self.init_terms().settlement_deadline; + if activation.epoch_start_height.abs_diff(current_height) > 6 + || current_height >= deadline + || activation.epoch_start_height >= deadline + { + return Err(SetupError::Height); + } + Ok(transcript::activation_hash( + &self.setup_hash, + &self.book_hash, + &commitment_hash, + activation.epoch_start_height, + )) + } + + /// Verify the settlement peer's acknowledgement of the locally validated H_act. + /// + /// An authenticated acknowledgement does not establish persistence or recovery readiness. + pub fn validate_activation_ack( + &self, message: &Message, activation_hash: Digest, + ) -> Result<(), SetupError> { + self.authenticate(message, &self.settlement)?; + match message.payload { + Payload::ActivateAck(hash) if hash == activation_hash => Ok(()), + Payload::ActivateAck(_) => Err(SetupError::Transcript), + _ => Err(SetupError::MessageType), + } + } + + /// Verify close accounting and every disclosed preimage against this signed book. + /// + /// An unset bit never permits discarding a preimage learned from another source. Retain + /// contradictory evidence and fulfil every known claim through the engine's drain path. + pub fn validate_close_ack( + &self, message: &Message, activation_hash: Digest, + ) -> Result<(), SetupError> { + self.authenticate(message, &self.settlement)?; + let Payload::CloseAck(close) = &message.payload else { + return Err(SetupError::MessageType); + }; + if close.activation_hash != activation_hash { + return Err(SetupError::Transcript); + } + if usize::from(close.num_slots) != self.vouchers.len() { + return Err(SetupError::CloseBook); + } + if self.init_terms().hash_chain + && close + .preimages + .iter() + .enumerate() + .any(|(index, preimage)| usize::from(preimage.slot) != index + 1) + { + return Err(SetupError::CloseBook); + } + for preimage in &close.preimages { + let voucher = &self.vouchers[usize::from(preimage.slot) - 1]; + if sha256::Hash::hash(&preimage.value).to_byte_array() != voucher.payment_hash { + return Err(SetupError::CloseBook); + } + } + Ok(()) + } + + fn authenticate(&self, message: &Message, signer: &PublicKey) -> Result<(), SetupError> { + message.verify_signature(signer)?; + if message.header != self.header() { + return Err(SetupError::Identity); + } + Ok(()) + } + + fn init_terms(&self) -> &Init { + match &self.init.payload { + Payload::Init(init) => init, + _ => unreachable!("invariant: constructor validated immutable init payload"), + } + } +} + +fn encode_book(epoch_id: [u8; 32], vouchers: &[Voucher]) -> Vec { + let mut bytes = Vec::with_capacity(36 + 58 * vouchers.len()); + bytes.extend_from_slice(&epoch_id); + bytes.extend_from_slice(&[4, 1]); + bytes.extend_from_slice(&(vouchers.len() as u16).to_be_bytes()); + for voucher in vouchers { + bytes.extend_from_slice(&voucher.slot.to_be_bytes()); + bytes.extend_from_slice(&voucher.payment_hash); + bytes.extend_from_slice(&voucher.amount_msat.to_be_bytes()); + bytes.extend_from_slice(&voucher.expiry.to_be_bytes()); + bytes.extend_from_slice(&voucher.deadline.to_be_bytes()); + bytes.extend_from_slice(&voucher.htlc_id.to_be_bytes()); + } + bytes +} diff --git a/crates/ffor-protocol/src/transcript.rs b/crates/ffor-protocol/src/transcript.rs index 0a53995a70..207f8a494f 100644 --- a/crates/ffor-protocol/src/transcript.rs +++ b/crates/ffor-protocol/src/transcript.rs @@ -4,10 +4,70 @@ //! signatures before using these digests. Hashing alone authenticates nothing. use bitcoin::hashes::{sha256, Hash, HashEngine}; +use bitcoin::secp256k1::ecdsa::Signature; +use bitcoin::secp256k1::{Message, PublicKey, Secp256k1}; +use std::fmt; /// A 32-byte SHA256 transcript digest in raw byte order. pub type Digest = [u8; 32]; +/// Failure to authenticate a compact node-key signature from FFOR section 7. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SignatureError { + /// The signature is not a valid compact ECDSA encoding. + Malformed, + /// The signature uses a noncanonical high-S scalar. + HighS, + /// The expected node did not sign this message type and exact unsigned body. + Invalid, +} + +impl fmt::Display for SignatureError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "invalid FFOR message signature: {self:?}") + } +} + +impl std::error::Error for SignatureError {} + +/// Authenticate the exact unsigned body under the expected peer's node key. +/// +/// Uses the single SHA256 digest and low-S compact ECDSA encoding required by section 7. +/// The body includes the channel and epoch identifiers and every TLV, without a message type +/// prefix or trailing signature. Do not reconstruct it while discarding unknown odd TLVs. +/// The expected key must come from the authenticated channel peer, not the message itself. +/// +/// This verifies identity and byte integrity only. The caller must still decode a canonical +/// supported message, check the sender's role, validate the transcript and enforce epoch state. +/// No signing key is used or exposed. +/// +/// ``` +/// use bitcoin::secp256k1::PublicKey; +/// use ffor_protocol::transcript::verify_message_signature; +/// let node = PublicKey::from_slice(&[ +/// 2, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, +/// 0x95, 0xce, 0x87, 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, +/// 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, 0xf8, 0x17, 0x98, +/// ]).unwrap(); +/// assert!(verify_message_signature(55001, &[0; 64], &[0; 64], &node).is_err()); +/// ``` +pub fn verify_message_signature( + message_type: u16, unsigned_body: &[u8], compact_signature: &[u8; 64], + expected_signer: &PublicKey, +) -> Result<(), SignatureError> { + let signature = + Signature::from_compact(compact_signature).map_err(|_| SignatureError::Malformed)?; + let mut normalized = signature; + normalized.normalize_s(); + if normalized != signature { + return Err(SignatureError::HighS); + } + let message = Message::from_digest(message_digest(message_type, unsigned_body)); + Secp256k1::verification_only() + .verify_ecdsa(&message, &signature, expected_signer) + .map_err(|_| SignatureError::Invalid) +} + fn hash_parts(tag: &[u8], parts: &[&[u8]]) -> Digest { let mut engine = sha256::Hash::engine(); engine.input(tag); diff --git a/crates/ffor-protocol/src/wire/codec.rs b/crates/ffor-protocol/src/wire/codec.rs new file mode 100644 index 0000000000..d798a8c6e9 --- /dev/null +++ b/crates/ffor-protocol/src/wire/codec.rs @@ -0,0 +1,81 @@ +use bitcoin::secp256k1::ecdsa::Signature; + +use super::tlv::{check_extensions, read_tlvs, write_tlvs, Reader, Writer}; +use super::{lifecycle, setup, Header, Message, Payload, WireError, MAX_MESSAGE_LEN}; + +pub(super) fn decode(wire: &[u8]) -> Result { + if wire.len() > MAX_MESSAGE_LEN { + return Err(WireError::SizeLimit); + } + if wire.len() < 2 + 64 + 64 { + return Err(WireError::Truncated); + } + let signature_start = wire.len() - 64; + let signature = wire[signature_start..].try_into().map_err(|_| WireError::Truncated)?; + validate_signature_encoding(&signature)?; + let mut reader = Reader::new(&wire[..signature_start]); + let kind = reader.u16()?; + let header = Header { channel_id: reader.array()?, epoch_id: reader.array()? }; + let (payload, extensions) = match kind { + 55001 => setup::read_init(&mut reader)?, + 55003 => setup::read_accept(&mut reader)?, + 55045 | 55047 | 55049 | 55051 | 55053 => lifecycle::read(kind, &mut reader)?, + _ => return Err(WireError::UnsupportedMessage(kind)), + }; + check_extensions(&extensions)?; + Ok(Message { header, payload, extensions, signature }) +} + +pub(super) fn encode(message: &Message) -> Result, WireError> { + validate_signature_encoding(&message.signature)?; + let mut writer = Writer(encode_unsigned(message)?); + writer.put(&message.signature)?; + Ok(writer.0) +} + +pub(super) fn encode_unsigned(message: &Message) -> Result, WireError> { + check_extensions(&message.extensions)?; + let mut writer = Writer::new(); + writer.u16(message.message_type())?; + writer.put(&message.header.channel_id)?; + writer.put(&message.header.epoch_id)?; + let known = match &message.payload { + Payload::Init(init) => setup::write_init(init, &mut writer)?, + Payload::Accept(accept) => setup::write_accept(accept, &mut writer)?, + payload => lifecycle::write(payload, &mut writer)?, + }; + if matches!(message.payload, Payload::Init(_)) { + for tlv in &message.extensions { + if matches!(tlv.kind, 1 | 3 | 5 | 9 | 13 | 15) { + return Err(WireError::InvalidTlv(tlv.kind)); + } + } + } + if matches!(message.payload, Payload::CloseAck(_)) + && message.extensions.iter().any(|tlv| tlv.kind == 1) + { + return Err(WireError::InvalidTlv(1)); + } + write_tlvs(&mut writer, known, &message.extensions)?; + if writer.0.len() > MAX_MESSAGE_LEN - 64 { + return Err(WireError::SizeLimit); + } + Ok(writer.0) +} + +fn validate_signature_encoding(bytes: &[u8; 64]) -> Result<(), WireError> { + if bytes[..32].iter().all(|byte| *byte == 0) || bytes[32..].iter().all(|byte| *byte == 0) { + return Err(WireError::InvalidSignature); + } + let signature = Signature::from_compact(bytes).map_err(|_| WireError::InvalidSignature)?; + let mut normalized = signature; + normalized.normalize_s(); + if normalized != signature { + return Err(WireError::InvalidSignature); + } + Ok(()) +} + +pub(super) fn remaining_tlvs(reader: &mut Reader<'_>) -> Result, WireError> { + read_tlvs(reader) +} diff --git a/crates/ffor-protocol/src/wire/lifecycle.rs b/crates/ffor-protocol/src/wire/lifecycle.rs new file mode 100644 index 0000000000..f58a65bad7 --- /dev/null +++ b/crates/ffor-protocol/src/wire/lifecycle.rs @@ -0,0 +1,119 @@ +use super::codec::remaining_tlvs; +use super::setup::check_count; +use super::tlv::{optional, Reader, Writer}; +use super::{Abort, Activate, CloseAck, Payload, Preimage, Tlv, WireError, MAX_MESSAGE_LEN}; + +pub(super) fn read(kind: u16, reader: &mut Reader<'_>) -> Result<(Payload, Vec), WireError> { + let payload = match kind { + 55045 => Payload::Activate(Activate { + setup_hash: reader.array()?, + book_hash: reader.array()?, + commit_hash: reader.array()?, + epoch_start_height: reader.u32()?, + }), + 55047 => Payload::ActivateAck(reader.array()?), + 55049 => { + let transcript_hash = reader.array()?; + let reason = reader.u16()?; + if reason > 7 { + return Err(WireError::InvalidField); + } + let data_len = usize::from(reader.u16()?); + Payload::Abort(Abort { transcript_hash, reason, data: reader.take(data_len)?.to_vec() }) + }, + 55051 => Payload::Close(reader.array()?), + 55053 => return read_close_ack(reader), + _ => return Err(WireError::UnsupportedMessage(kind)), + }; + Ok((payload, remaining_tlvs(reader)?)) +} + +fn read_close_ack(reader: &mut Reader<'_>) -> Result<(Payload, Vec), WireError> { + let activation_hash = reader.array()?; + let num_slots = reader.u16()?; + check_count(usize::from(num_slots))?; + let settled = reader.take(usize::from(num_slots).div_ceil(8))?.to_vec(); + let mut tlvs = remaining_tlvs(reader)?; + let value = optional(&mut tlvs, 1); + let preimages_tlv_present = value.is_some(); + let bytes = value.unwrap_or_default(); + if bytes.len() % 34 != 0 || bytes.len() / 34 > usize::from(num_slots) { + return Err(WireError::InvalidTlv(1)); + } + let mut preimages = Vec::new(); + let mut records = Reader::new(&bytes); + while !records.is_empty() { + preimages.push(Preimage { slot: records.u16()?, value: records.array()? }); + } + let ack = CloseAck { activation_hash, num_slots, settled, preimages, preimages_tlv_present }; + validate_close_ack(&ack)?; + Ok((Payload::CloseAck(ack), tlvs)) +} + +pub(super) fn write(payload: &Payload, writer: &mut Writer) -> Result, WireError> { + match payload { + Payload::Activate(activate) => { + writer.put(&activate.setup_hash)?; + writer.put(&activate.book_hash)?; + writer.put(&activate.commit_hash)?; + writer.u32(activate.epoch_start_height)?; + }, + Payload::ActivateAck(hash) | Payload::Close(hash) => writer.put(hash)?, + Payload::Abort(abort) => { + if abort.reason > 7 { + return Err(WireError::InvalidField); + } + if abort.data.len() > MAX_MESSAGE_LEN { + return Err(WireError::SizeLimit); + } + writer.put(&abort.transcript_hash)?; + writer.u16(abort.reason)?; + writer.u16(abort.data.len() as u16)?; + writer.put(&abort.data)?; + }, + Payload::CloseAck(ack) => return write_close_ack(ack, writer), + _ => return Err(WireError::InvalidField), + } + Ok(Vec::new()) +} + +fn write_close_ack(ack: &CloseAck, writer: &mut Writer) -> Result, WireError> { + validate_close_ack(ack)?; + writer.put(&ack.activation_hash)?; + writer.u16(ack.num_slots)?; + writer.put(&ack.settled)?; + let mut preimages = Writer::new(); + for record in &ack.preimages { + preimages.u16(record.slot)?; + preimages.put(&record.value)?; + } + Ok(if ack.preimages_tlv_present { + vec![Tlv { kind: 1, value: preimages.0 }] + } else { + Vec::new() + }) +} + +fn validate_close_ack(ack: &CloseAck) -> Result<(), WireError> { + let count = usize::from(ack.num_slots); + check_count(count)?; + if ack.settled.len() != count.div_ceil(8) { + return Err(WireError::InvalidField); + } + // Bits outside the book cannot describe slots and must not create alternate encodings. + let used_bits = count % 8; + if used_bits != 0 && ack.settled[count / 8] >> used_bits != 0 { + return Err(WireError::InvalidField); + } + let expected = (1..=ack.num_slots).filter(|slot| { + let bit = usize::from(slot - 1); + ack.settled[bit / 8] & (1 << (bit % 8)) != 0 + }); + if !expected.eq(ack.preimages.iter().map(|record| record.slot)) { + return Err(WireError::InvalidTlv(1)); + } + if !ack.preimages_tlv_present && !ack.preimages.is_empty() { + return Err(WireError::MissingTlv(1)); + } + Ok(()) +} diff --git a/crates/ffor-protocol/src/wire/mod.rs b/crates/ffor-protocol/src/wire/mod.rs new file mode 100644 index 0000000000..a1852d26f0 --- /dev/null +++ b/crates/ffor-protocol/src/wire/mod.rs @@ -0,0 +1,158 @@ +//! Bounded canonical encodings for the signed Variant D setup and lifecycle messages. +//! +//! Peer bytes are untrusted. Decoding checks structure, low-S signature encoding and +//! message-local invariants, but does not authenticate the sender or establish channel +//! readiness. Call [`Message::verify_signature`] with the expected channel peer before +//! acting, then validate the transcript and live channel state. Unknown odd TLVs are +//! retained byte-for-byte; unknown even TLVs are rejected. No signing keys are accepted. +//! +//! ``` +//! use ffor_protocol::wire::{Message, WireError}; +//! assert_eq!(Message::decode(&[]), Err(WireError::Truncated)); +//! ``` + +mod codec; +mod lifecycle; +mod setup; +mod tlv; +mod types; + +use std::fmt; + +use bitcoin::secp256k1::PublicKey; +pub use types::{Abort, Accept, Activate, CloseAck, Header, Init, Payload, Preimage, Tlv}; + +use crate::transcript::{self, Digest}; + +/// BOLT 8 maximum plaintext peer message, including its two-byte type. +pub const MAX_MESSAGE_LEN: usize = 65_535; +/// Maximum number of TLVs that can fit a peer message even with empty values. +pub const MAX_TLV_COUNT: usize = MAX_MESSAGE_LEN / 2; + +/// Structural or authentication failure. No error authorizes a channel transition. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WireError { + /// The message, field, or BigSize value ended early. + Truncated, + /// A message or bounded collection exceeds its protocol limit. + SizeLimit, + /// This message type is not implemented by this Variant D codec. + UnsupportedMessage(u16), + /// The init selected a variant other than D. + UnsupportedVariant(u8), + /// A BigSize value used a longer-than-minimal representation. + NonCanonicalBigSize, + /// TLV types were duplicated or not strictly increasing. + TlvOrder, + /// An unknown mandatory even TLV appeared. + UnknownEvenTlv(u64), + /// A required known TLV is absent. + MissingTlv(u64), + /// A known TLV has an invalid value length or is forbidden for this message. + InvalidTlv(u64), + /// A fixed field or count violates the message's local invariants. + InvalidField, + /// A compressed secp256k1 point is invalid or is not compressed. + InvalidPoint, + /// The compact signature is malformed, high-S, or fails authentication. + InvalidSignature, + /// An accept does not bind the supplied init's header, amounts, or transcript. + SetupMismatch, +} + +impl fmt::Display for WireError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "invalid FFOR wire message: {self:?}") + } +} + +impl std::error::Error for WireError {} + +/// A parsed signed envelope. Public fields remain untrusted until signature verification. +/// +/// Encoding revalidates all fields and produces canonical bytes. It never signs or repairs +/// a signature: changing any header, payload or extension requires a new external signature. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Message { + /// Channel and epoch to which the signed message belongs. + pub header: Header, + /// Known message fields and known TLVs. + pub payload: Payload, + /// Unknown odd TLVs, in strictly increasing type order. + pub extensions: Vec, + /// Unverified compact low-S ECDSA signature in the final 64 wire bytes. + pub signature: [u8; 64], +} + +impl Message { + /// Decode one complete peer message with no trailing framing bytes. + /// + /// All input-dependent allocation is bounded by [`MAX_MESSAGE_LEN`]. Successful + /// decoding does not verify the signature, live limits, or lifecycle ordering. + pub fn decode(wire: &[u8]) -> Result { + codec::decode(wire) + } + + /// Encode canonical wire bytes after rechecking message-local invariants. + /// + /// An existing signature is retained unchanged, even if fields were modified. Call + /// [`Self::verify_signature`] before transmitting or acting on a reconstructed message. + pub fn encode(&self) -> Result, WireError> { + codec::encode(self) + } + + /// The assigned two-byte message type, without authenticating the message. + pub fn message_type(&self) -> u16 { + match self.payload { + Payload::Init(_) => 55001, + Payload::Accept(_) => 55003, + Payload::Activate(_) => 55045, + Payload::ActivateAck(_) => 55047, + Payload::Abort(_) => 55049, + Payload::Close(_) => 55051, + Payload::CloseAck(_) => 55053, + } + } + + /// The existing transcript module's domain-separated digest over the unsigned body. + /// + /// The signature field is intentionally ignored so the channel's existing signer can + /// sign newly constructed terms. All unsigned fields and the final envelope size are checked. + pub fn signature_digest(&self) -> Result { + let wire = codec::encode_unsigned(self)?; + Ok(transcript::message_digest(self.message_type(), &wire[2..])) + } + + /// Authenticate the canonical envelope with the expected channel peer's node key. + /// + /// This proves only the signed bytes, not the correctness of the peer's claims. + pub fn verify_signature(&self, expected_signer: &PublicKey) -> Result<(), WireError> { + let wire = self.encode()?; + transcript::verify_message_signature( + self.message_type(), + &wire[2..wire.len() - 64], + &self.signature, + expected_signer, + ) + .map_err(|_| WireError::InvalidSignature) + } + + /// Check this accept against the exact canonical signed init it answers. + /// + /// Verify both signatures first. Live channel capacity, negotiated limits, deadline + /// margins and previously disclosed commitment secrets remain the engine's checks. + pub fn validate_accept(&self, init: &Message) -> Result<(), WireError> { + let (Payload::Accept(accept), Payload::Init(terms)) = (&self.payload, &init.payload) else { + return Err(WireError::SetupMismatch); + }; + self.encode()?; + let init_wire = init.encode()?; + if self.header != init.header + || accept.amounts_msat != terms.amounts_msat + || accept.init_hash != transcript::init_hash(&init_wire) + { + return Err(WireError::SetupMismatch); + } + Ok(()) + } +} diff --git a/crates/ffor-protocol/src/wire/setup.rs b/crates/ffor-protocol/src/wire/setup.rs new file mode 100644 index 0000000000..3b8b12af4a --- /dev/null +++ b/crates/ffor-protocol/src/wire/setup.rs @@ -0,0 +1,192 @@ +use bitcoin::secp256k1::PublicKey; + +use super::codec::remaining_tlvs; +use super::tlv::{fixed, optional, required, Reader, Writer}; +use super::{Accept, Init, Payload, Tlv, WireError, MAX_MESSAGE_LEN}; +use crate::book::MAX_VOUCHERS; + +pub(super) fn read_init(reader: &mut Reader<'_>) -> Result<(Payload, Vec), WireError> { + let variant = reader.u8()?; + if variant != 4 { + return Err(WireError::UnsupportedVariant(variant)); + } + let budget_msat = reader.u64()?; + let count = usize::from(reader.u16()?); + check_count(count)?; + let min_payment_msat = reader.u64()?; + let settlement_deadline = reader.u32()?; + let voucher_expiry = reader.u32()?; + let fee_base_msat = reader.u32()?; + let fee_proportional_millionths = reader.u32()?; + if reader.u64()? != 0 || reader.u16()? != 0 { + return Err(WireError::InvalidField); + } + let mut tlvs = remaining_tlvs(reader)?; + if let Some(forbidden) = tlvs.iter().find(|entry| matches!(entry.kind, 1 | 3 | 5)) { + return Err(WireError::InvalidTlv(forbidden.kind)); + } + let amounts_msat = read_amounts(required(&mut tlvs, 9)?)?; + if amounts_msat.len() != count { + return Err(WireError::InvalidTlv(9)); + } + let witness_peers = optional(&mut tlvs, 13).map(read_points).transpose()?; + let hash_chain = match optional(&mut tlvs, 15) { + None => false, + Some(value) if value == [1] => true, + Some(_) => return Err(WireError::InvalidTlv(15)), + }; + let init = Init { + budget_msat, + min_payment_msat, + settlement_deadline, + voucher_expiry, + fee_base_msat, + fee_proportional_millionths, + amounts_msat, + witness_peers, + hash_chain, + }; + validate_init(&init)?; + Ok((Payload::Init(init), tlvs)) +} + +pub(super) fn read_accept(reader: &mut Reader<'_>) -> Result<(Payload, Vec), WireError> { + let s_commitment_number = reader.u64()?; + let mut tlvs = remaining_tlvs(reader)?; + let hashes = required(&mut tlvs, 1)?; + if hashes.len() % 32 != 0 { + return Err(WireError::InvalidTlv(1)); + } + check_count(hashes.len() / 32)?; + let payment_hashes = hashes + .chunks_exact(32) + .map(|hash| hash.try_into().map_err(|_| WireError::InvalidTlv(1))) + .collect::>()?; + let s_htlc_id_base = u64::from_be_bytes(fixed(required(&mut tlvs, 7)?, 7)?); + let amounts_msat = read_amounts(required(&mut tlvs, 9)?)?; + let init_hash = fixed(required(&mut tlvs, 11)?, 11)?; + let accept = + Accept { s_commitment_number, payment_hashes, s_htlc_id_base, amounts_msat, init_hash }; + validate_accept(&accept)?; + Ok((Payload::Accept(accept), tlvs)) +} + +pub(super) fn write_init(init: &Init, writer: &mut Writer) -> Result, WireError> { + validate_init(init)?; + writer.put(&[4])?; + writer.u64(init.budget_msat)?; + writer.u16(init.amounts_msat.len() as u16)?; + writer.u64(init.min_payment_msat)?; + writer.u32(init.settlement_deadline)?; + writer.u32(init.voucher_expiry)?; + writer.u32(init.fee_base_msat)?; + writer.u32(init.fee_proportional_millionths)?; + writer.u64(0)?; + writer.u16(0)?; + let mut tlvs = vec![Tlv { kind: 9, value: amount_bytes(&init.amounts_msat) }]; + if let Some(peers) = &init.witness_peers { + if peers.len() > MAX_MESSAGE_LEN / 33 { + return Err(WireError::SizeLimit); + } + let mut points = Writer::new(); + points.u16(peers.len() as u16)?; + for peer in peers { + points.put(&peer.serialize())?; + } + tlvs.push(Tlv { kind: 13, value: points.0 }); + } + if init.hash_chain { + tlvs.push(Tlv { kind: 15, value: vec![1] }); + } + Ok(tlvs) +} + +pub(super) fn write_accept(accept: &Accept, writer: &mut Writer) -> Result, WireError> { + validate_accept(accept)?; + writer.u64(accept.s_commitment_number)?; + Ok(vec![ + Tlv { kind: 1, value: accept.payment_hashes.concat() }, + Tlv { kind: 7, value: accept.s_htlc_id_base.to_be_bytes().to_vec() }, + Tlv { kind: 9, value: amount_bytes(&accept.amounts_msat) }, + Tlv { kind: 11, value: accept.init_hash.to_vec() }, + ]) +} + +fn validate_init(init: &Init) -> Result<(), WireError> { + check_count(init.amounts_msat.len())?; + if init.voucher_expiry <= init.settlement_deadline { + return Err(WireError::InvalidField); + } + let mut total = 0u64; + for &amount in &init.amounts_msat { + if amount == 0 || amount < init.min_payment_msat { + return Err(WireError::InvalidTlv(9)); + } + if init.hash_chain && amount != init.amounts_msat[0] { + return Err(WireError::InvalidTlv(15)); + } + total = total.checked_add(amount).ok_or(WireError::InvalidField)?; + } + if total != init.budget_msat { + return Err(WireError::InvalidField); + } + Ok(()) +} + +fn validate_accept(accept: &Accept) -> Result<(), WireError> { + check_count(accept.payment_hashes.len())?; + if accept.amounts_msat.len() != accept.payment_hashes.len() || accept.amounts_msat.contains(&0) + { + return Err(WireError::InvalidTlv(9)); + } + accept + .s_htlc_id_base + .checked_add(accept.payment_hashes.len() as u64 - 1) + .ok_or(WireError::InvalidTlv(7))?; + Ok(()) +} + +pub(super) fn check_count(count: usize) -> Result<(), WireError> { + if count == 0 || count > MAX_VOUCHERS { + return Err(WireError::InvalidField); + } + Ok(()) +} + +fn read_amounts(bytes: Vec) -> Result, WireError> { + if bytes.len() % 8 != 0 { + return Err(WireError::InvalidTlv(9)); + } + check_count(bytes.len() / 8)?; + bytes + .chunks_exact(8) + .map(|amount| { + Ok(u64::from_be_bytes(amount.try_into().map_err(|_| WireError::InvalidTlv(9))?)) + }) + .collect() +} + +fn amount_bytes(amounts: &[u64]) -> Vec { + amounts.iter().flat_map(|amount| amount.to_be_bytes()).collect() +} + +fn read_points(bytes: Vec) -> Result, WireError> { + let mut reader = Reader::new(&bytes); + let count = usize::from(reader.u16()?); + if count > MAX_MESSAGE_LEN / 33 { + return Err(WireError::SizeLimit); + } + let mut peers = Vec::new(); + for _ in 0..count { + let bytes = reader.take(33)?; + let point = PublicKey::from_slice(bytes).map_err(|_| WireError::InvalidPoint)?; + if point.serialize() != bytes { + return Err(WireError::InvalidPoint); + } + peers.push(point); + } + if !reader.is_empty() { + return Err(WireError::InvalidTlv(13)); + } + Ok(peers) +} diff --git a/crates/ffor-protocol/src/wire/tlv.rs b/crates/ffor-protocol/src/wire/tlv.rs new file mode 100644 index 0000000000..0386b13662 --- /dev/null +++ b/crates/ffor-protocol/src/wire/tlv.rs @@ -0,0 +1,168 @@ +use super::{Tlv, WireError, MAX_MESSAGE_LEN, MAX_TLV_COUNT}; + +pub(super) struct Reader<'a> { + remaining: &'a [u8], +} + +impl<'a> Reader<'a> { + pub(super) fn new(bytes: &'a [u8]) -> Self { + Self { remaining: bytes } + } + + pub(super) fn take(&mut self, size: usize) -> Result<&'a [u8], WireError> { + let value = self.remaining.get(..size).ok_or(WireError::Truncated)?; + self.remaining = &self.remaining[size..]; + Ok(value) + } + + pub(super) fn array(&mut self) -> Result<[u8; N], WireError> { + self.take(N)?.try_into().map_err(|_| WireError::Truncated) + } + + pub(super) fn u8(&mut self) -> Result { + Ok(self.array::<1>()?[0]) + } + pub(super) fn u16(&mut self) -> Result { + Ok(u16::from_be_bytes(self.array()?)) + } + pub(super) fn u32(&mut self) -> Result { + Ok(u32::from_be_bytes(self.array()?)) + } + pub(super) fn u64(&mut self) -> Result { + Ok(u64::from_be_bytes(self.array()?)) + } + pub(super) fn is_empty(&self) -> bool { + self.remaining.is_empty() + } + + fn bigsize(&mut self) -> Result { + let (value, minimum) = match self.u8()? { + 0xfd => (u64::from(self.u16()?), 0xfd), + 0xfe => (u64::from(self.u32()?), 0x1_0000), + 0xff => (self.u64()?, 0x1_0000_0000), + value => return Ok(u64::from(value)), + }; + if value < minimum { + return Err(WireError::NonCanonicalBigSize); + } + Ok(value) + } +} + +pub(super) fn read_tlvs(reader: &mut Reader<'_>) -> Result, WireError> { + let mut entries = Vec::new(); + let mut last = None; + while !reader.is_empty() { + let kind = reader.bigsize()?; + if last.is_some_and(|previous| kind <= previous) { + return Err(WireError::TlvOrder); + } + let len = usize::try_from(reader.bigsize()?).map_err(|_| WireError::SizeLimit)?; + if len > MAX_MESSAGE_LEN || entries.len() >= MAX_TLV_COUNT { + return Err(WireError::SizeLimit); + } + let value = reader.take(len)?.to_vec(); + entries.push(Tlv { kind, value }); + last = Some(kind); + } + Ok(entries) +} + +pub(super) fn required(entries: &mut Vec, kind: u64) -> Result, WireError> { + optional(entries, kind).ok_or(WireError::MissingTlv(kind)) +} + +pub(super) fn optional(entries: &mut Vec, kind: u64) -> Option> { + let position = entries.iter().position(|entry| entry.kind == kind)?; + Some(entries.remove(position).value) +} + +pub(super) fn check_extensions(entries: &[Tlv]) -> Result<(), WireError> { + if entries.len() > MAX_TLV_COUNT { + return Err(WireError::SizeLimit); + } + let mut previous = None; + let mut total = 0usize; + for entry in entries { + if previous.is_some_and(|last| entry.kind <= last) { + return Err(WireError::TlvOrder); + } + if entry.kind % 2 == 0 { + return Err(WireError::UnknownEvenTlv(entry.kind)); + } + total = total + .checked_add(entry.value.len()) + .and_then(|n| n.checked_add(2)) + .ok_or(WireError::SizeLimit)?; + if total > MAX_MESSAGE_LEN { + return Err(WireError::SizeLimit); + } + previous = Some(entry.kind); + } + Ok(()) +} + +pub(super) struct Writer(pub(super) Vec); + +impl Writer { + pub(super) fn new() -> Self { + Self(Vec::new()) + } + pub(super) fn put(&mut self, bytes: &[u8]) -> Result<(), WireError> { + if bytes.len() > MAX_MESSAGE_LEN - self.0.len() { + return Err(WireError::SizeLimit); + } + self.0.extend_from_slice(bytes); + Ok(()) + } + pub(super) fn u16(&mut self, value: u16) -> Result<(), WireError> { + self.put(&value.to_be_bytes()) + } + pub(super) fn u32(&mut self, value: u32) -> Result<(), WireError> { + self.put(&value.to_be_bytes()) + } + pub(super) fn u64(&mut self, value: u64) -> Result<(), WireError> { + self.put(&value.to_be_bytes()) + } + + fn bigsize(&mut self, value: u64) -> Result<(), WireError> { + match value { + 0..=0xfc => self.put(&[value as u8]), + 0xfd..=0xffff => { + self.put(&[0xfd])?; + self.u16(value as u16) + }, + 0x1_0000..=0xffff_ffff => { + self.put(&[0xfe])?; + self.u32(value as u32) + }, + _ => { + self.put(&[0xff])?; + self.u64(value) + }, + } + } +} + +pub(super) fn write_tlvs( + writer: &mut Writer, known: Vec, extensions: &[Tlv], +) -> Result<(), WireError> { + check_extensions(extensions)?; + for extension in extensions { + if known.iter().any(|tlv| tlv.kind == extension.kind) { + return Err(WireError::InvalidTlv(extension.kind)); + } + } + let mut entries: Vec<_> = known.iter().chain(extensions).collect(); + entries.sort_by_key(|entry| entry.kind); + for entry in entries { + writer.bigsize(entry.kind)?; + writer.bigsize(entry.value.len() as u64)?; + writer.put(&entry.value)?; + } + Ok(()) +} + +pub(super) fn fixed(value: Vec, kind: u64) -> Result<[u8; N], WireError> { + value.try_into().map_err(|_| WireError::InvalidTlv(kind)) +} diff --git a/crates/ffor-protocol/src/wire/types.rs b/crates/ffor-protocol/src/wire/types.rs new file mode 100644 index 0000000000..8f12016d8f --- /dev/null +++ b/crates/ffor-protocol/src/wire/types.rs @@ -0,0 +1,126 @@ +use bitcoin::secp256k1::PublicKey; + +use crate::transcript::Digest; + +/// Identifiers signed by every supported message; neither is a trusted channel lookup key. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Header { + /// BOLT channel identifier. + pub channel_id: [u8; 32], + /// Receiver-selected epoch identifier, whose uniqueness the engine must enforce. + pub epoch_id: [u8; 32], +} + +/// An unknown optional extension retained in the signed canonical TLV stream. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Tlv { + /// Odd type number; duplicate or known types cannot be supplied as extensions. + pub kind: u64, + /// Exact extension bytes, bounded by the peer message limit. + pub value: Vec, +} + +/// Signed Variant D init terms. G and receiver point count are fixed to zero on wire. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Init { + /// Exact sum of the proposed voucher amounts, excluding forwarding fees. + pub budget_msat: u64, + /// Minimum permitted amount for every voucher. + pub min_payment_msat: u64, + /// Absolute height after which new settlement is forbidden. + pub settlement_deadline: u32, + /// Uniform voucher HTLC expiry height, strictly after the deadline. + pub voucher_expiry: u32, + /// Proposed base forwarding fee in millisatoshis. + pub fee_base_msat: u32, + /// Proposed proportional forwarding fee in millionths. + pub fee_proportional_millionths: u32, + /// Required TLV 9 amounts in slot order; length encodes K, from 1 to 483. + pub amounts_msat: Vec, + /// Optional TLV 13 witness peer restriction, preserving peer order. + pub witness_peers: Option>, + /// TLV 15 requests uniform hash-chained vouchers; chain verification needs the accept. + pub hash_chain: bool, +} + +/// Signed accept fields. Cross-message agreement is checked by `Message::validate_accept`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Accept { + /// Peer commitment number before the voucher round. + pub s_commitment_number: u64, + /// Required TLV 1 payment hashes in slot order. + pub payment_hashes: Vec<[u8; 32]>, + /// Required TLV 7 first voucher HTLC id. + pub s_htlc_id_base: u64, + /// Required TLV 9 exact amounts, with one amount per payment hash. + pub amounts_msat: Vec, + /// Required TLV 11 digest of the exact signed init wire bytes. + pub init_hash: Digest, +} + +/// Receiver's proposed activation commitments, still requiring live engine validation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Activate { + /// T_setup binding the init and accept. + pub setup_hash: Digest, + /// H_book for the canonical slot book. + pub book_hash: Digest, + /// H_commit for the real committed transaction pair. + pub commit_hash: Digest, + /// Receiver's activation height; the peer must check the six-block agreement rule. + pub epoch_start_height: u32, +} + +/// Signed pre-activation abort request; lifecycle permission is an engine concern. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Abort { + /// T_setup if accept was exchanged, otherwise T_init. + pub transcript_hash: Digest, + /// Normative reason code from 0 through 7. + pub reason: u16, + /// Bounded uninterpreted text or evidence; not assumed to be UTF-8. + pub data: Vec, +} + +/// A slot-numbered preimage in a Variant D close acknowledgement. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Preimage { + /// One-based slot index, in ascending order in the preimage list. + pub slot: u16, + /// Preimage whose SHA256 must match the corresponding authenticated book entry. + pub value: [u8; 32], +} + +/// Signed peer close report. Bitmap padding must be zero and every set bit has a preimage. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CloseAck { + /// H_act of the active epoch. + pub activation_hash: Digest, + /// K from the agreed book, checked against that book by the engine. + pub num_slots: u16, + /// LSB-first settled bitmap, exactly ceil(K/8) bytes. + pub settled: Vec, + /// Exactly one record per set bit in TLV 1; missing is permitted only when no slots settled. + pub preimages: Vec, + /// Preserve explicit empty TLV 1 versus absence when the bitmap is empty. + pub preimages_tlv_present: bool, +} + +/// Supported Variant D signed messages; undefined and unsupported types are rejected. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Payload { + /// ff_init, type 55001. + Init(Init), + /// ff_accept, type 55003. + Accept(Accept), + /// ff_activate, type 55045. + Activate(Activate), + /// ff_activate_ack, type 55047, carrying H_act. + ActivateAck(Digest), + /// ff_abort, type 55049. + Abort(Abort), + /// ff_close, type 55051, carrying H_act. + Close(Digest), + /// ff_close_ack, type 55053. + CloseAck(CloseAck), +} diff --git a/crates/ffor-protocol/tests/data/beignet-lifecycle.json b/crates/ffor-protocol/tests/data/beignet-lifecycle.json new file mode 100644 index 0000000000..fa1f5da1c1 --- /dev/null +++ b/crates/ffor-protocol/tests/data/beignet-lifecycle.json @@ -0,0 +1,35 @@ +{ + "source_revision": "8aee31d18e596fe49a0d195b325a6e757d7a009b", + "fixtures": [ + { + "name": "abort", + "message_type": 55049, + "signer": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c", + "wire": "d70901010101010101010101010101010101010101010101010101010101010101010202020202020202020202020202020202020202020202020202020202020202080808080808080808080808080808080808080808080808080808080808080800060019646973636f6e6e656374656420647572696e672073657475708e68c4763d4ebe74817028d6a02fc833da4ca71b584623466ede149efeaa17c86ed6adfcca9e8c3770b5ca620ca3b15bc4d28a033357d2dfc640ee2b81a77893" + }, + { + "name": "close", + "message_type": 55051, + "signer": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c", + "wire": "d70b010101010101010101010101010101010101010101010101010101010101010102020202020202020202020202020202020202020202020202020202020202020909090909090909090909090909090909090909090909090909090909090909b8da4a492327808e2b591f2db8be5451b07c3d54feb5d719937804e3126501e4092d425044ce969609d8583736e9693602bc6da36eebfa5bf12c99bc57a5440c" + }, + { + "name": "close_ack_paid", + "message_type": 55053, + "signer": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c", + "wire": "d70d010101010101010101010101010101010101010101010101010101010101010102020202020202020202020202020202020202020202020202020202020202020909090909090909090909090909090909090909090909090909090909090909000201012200010505050505050505050505050505050505050505050505050505050505050505b7ddea980cd1041b4dd42c200b4a1f23e84bf9c78526f25a60b46bac63d9694e27170e46aa38740eeda54b20324eafa15074324b53f43339f54375eee3c93683" + }, + { + "name": "close_ack_unpaid_absent_tlv", + "message_type": 55053, + "signer": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c", + "wire": "d70d010101010101010101010101010101010101010101010101010101010101010102020202020202020202020202020202020202020202020202020202020202020909090909090909090909090909090909090909090909090909090909090909000200b5ef97faf9628a1baf0bb5911c52cc21482c750da7107d10f12c064b2ae0e7f04bfda970e26140b436fb5a5855c0374b502b21448f92ae990d202d120670beed" + }, + { + "name": "close_ack_unpaid_empty_tlv", + "message_type": 55053, + "signer": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c", + "wire": "d70d010101010101010101010101010101010101010101010101010101010101010102020202020202020202020202020202020202020202020202020202020202020909090909090909090909090909090909090909090909090909090909090909000200010050a03c5bd10fffb8e15f81cdf2868320fcb86f235654c6f3ee60d56deda66a4e756c3f5f01cf83f9c15791f8f2df269167fd4a06f3477d8041ac2bf2079e0458" + } + ] +} diff --git a/crates/ffor-protocol/tests/data/generate_beignet_lifecycle.cjs b/crates/ffor-protocol/tests/data/generate_beignet_lifecycle.cjs new file mode 100644 index 0000000000..e70d4a2ce0 --- /dev/null +++ b/crates/ffor-protocol/tests/data/generate_beignet_lifecycle.cjs @@ -0,0 +1,41 @@ +// Reproduce lifecycle interoperability fixtures using the pinned Beignet implementation. +// Usage: node -r /path/to/beignet/node_modules/ts-node/register \ +// generate_beignet_lifecycle.cjs /path/to/beignet /path/to/output.json +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const reference = path.resolve(process.argv[2]); +const expectedRevision = '8aee31d18e596fe49a0d195b325a6e757d7a009b'; +const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: reference, encoding: 'utf8' }).trim(); +if (revision !== expectedRevision) throw new Error('Beignet revision differs from the fixture baseline'); +const codecs = require(path.join(reference, 'src/lightning/ffor/messages.ts')); +const { getPublicKey } = require(path.join(reference, 'src/lightning/crypto/ecdh.ts')); + +// Deterministic test-only signing material. These fixtures contain no wallet keys or funds. +const secret = Buffer.alloc(32, 42); +const publicKey = getPublicKey(secret); +const header = { channelId: Buffer.alloc(32, 1), epochId: Buffer.alloc(32, 2) }; +const activation = { ...header, activationHash: Buffer.alloc(32, 9) }; +const emptyClose = codecs.encodeFforCloseAckUnsigned({ + ...activation, numSlots: 2, settled: Buffer.from([0]), preimages: [] +}); +const cases = [ + ['abort', 55049, codecs.encodeFforAbortUnsigned({ + ...header, transcriptHash: Buffer.alloc(32, 8), reason: 6, + data: Buffer.from('disconnected during setup', 'utf8') + })], + ['close', 55051, codecs.encodeFforCloseUnsigned(activation)], + ['close_ack_paid', 55053, codecs.encodeFforCloseAckUnsigned({ + ...activation, numSlots: 2, settled: Buffer.from([1]), + preimages: [{ k: 1, preimage: Buffer.alloc(32, 5) }] + })], + ['close_ack_unpaid_absent_tlv', 55053, emptyClose], + ['close_ack_unpaid_empty_tlv', 55053, Buffer.concat([emptyClose, Buffer.from([1, 0])])] +]; +const fixtures = cases.map(([name, type, unsigned]) => { + const body = codecs.signFforMessage(type, unsigned, secret); + if (!codecs.verifyFforMessage(type, body, publicKey)) throw new Error('Fixture authentication failed'); + return { name, message_type: type, signer: publicKey.toString('hex'), wire: codecs.fforWireBytes(type, body).toString('hex') }; +}); +fs.writeFileSync(process.argv[3], JSON.stringify({ source_revision: revision, fixtures }, null, 2) + '\n'); diff --git a/crates/ffor-protocol/tests/reference_lifecycle.rs b/crates/ffor-protocol/tests/reference_lifecycle.rs new file mode 100644 index 0000000000..bbdbaea0f3 --- /dev/null +++ b/crates/ffor-protocol/tests/reference_lifecycle.rs @@ -0,0 +1,37 @@ +use bitcoin::secp256k1::PublicKey; +use ffor_protocol::wire::Message; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Reference { + source_revision: String, + fixtures: Vec, +} + +#[derive(Deserialize)] +struct Fixture { + name: String, + message_type: u16, + signer: String, + wire: String, +} + +fn hex(value: &str) -> Vec { + (0..value.len()).step_by(2).map(|i| u8::from_str_radix(&value[i..i + 2], 16).unwrap()).collect() +} + +#[test] +fn beignet_lifecycle_messages_preserve_signed_wire_bytes() { + let reference: Reference = + serde_json::from_str(include_str!("data/beignet-lifecycle.json")).unwrap(); + assert_eq!(reference.source_revision, "8aee31d18e596fe49a0d195b325a6e757d7a009b"); + assert_eq!(reference.fixtures.len(), 5); + for fixture in reference.fixtures { + let wire = hex(&fixture.wire); + let decoded = + Message::decode(&wire).unwrap_or_else(|error| panic!("{}: {error}", fixture.name)); + assert_eq!(decoded.message_type(), fixture.message_type); + decoded.verify_signature(&PublicKey::from_slice(&hex(&fixture.signer)).unwrap()).unwrap(); + assert_eq!(decoded.encode().unwrap(), wire, "{}", fixture.name); + } +} diff --git a/crates/ffor-protocol/tests/setup.rs b/crates/ffor-protocol/tests/setup.rs new file mode 100644 index 0000000000..c5985dd1e5 --- /dev/null +++ b/crates/ffor-protocol/tests/setup.rs @@ -0,0 +1,337 @@ +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::secp256k1::{Message as SecpMessage, PublicKey, Secp256k1, SecretKey}; +use ffor_protocol::setup::{AuthenticatedSetup, SetupError}; +use ffor_protocol::transcript; +use ffor_protocol::wire::{ + Accept, Activate, CloseAck, Header, Init, Message, Payload, Preimage, Tlv, +}; +use proptest::prelude::*; +use serde::Deserialize; + +fn key(value: u8) -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[value; 32]).unwrap()) +} + +fn sign(message: &mut Message, value: u8) { + let digest = message.signature_digest().unwrap(); + message.signature = Secp256k1::new() + .sign_ecdsa( + &SecpMessage::from_digest(digest), + &SecretKey::from_slice(&[value; 32]).unwrap(), + ) + .serialize_compact(); +} + +fn message(payload: Payload, signer: u8) -> Message { + let mut message = Message { + header: Header { channel_id: [1; 32], epoch_id: [2; 32] }, + payload, + extensions: Vec::new(), + signature: [0; 64], + }; + sign(&mut message, signer); + message +} + +fn setup_messages() -> (Message, Message) { + let init = message( + Payload::Init(Init { + budget_msat: 2_000_000, + min_payment_msat: 354_000, + settlement_deadline: 1000, + voucher_expiry: 2008, + fee_base_msat: 1000, + fee_proportional_millionths: 100, + amounts_msat: vec![1_000_000; 2], + witness_peers: None, + hash_chain: false, + }), + 42, + ); + let accept = message( + Payload::Accept(Accept { + s_commitment_number: 3, + payment_hashes: vec![ + sha256::Hash::hash(&[5; 32]).to_byte_array(), + sha256::Hash::hash(&[6; 32]).to_byte_array(), + ], + s_htlc_id_base: 8, + amounts_msat: vec![1_000_000; 2], + init_hash: transcript::init_hash(&init.encode().unwrap()), + }), + 43, + ); + (init, accept) +} + +fn setup() -> AuthenticatedSetup { + let (init, accept) = setup_messages(); + AuthenticatedSetup::new(&init, &accept, key(42), key(43)).unwrap() +} + +fn activate(setup: &AuthenticatedSetup, height: u32) -> Message { + message( + Payload::Activate(Activate { + setup_hash: setup.setup_hash(), + book_hash: setup.book_hash(), + commit_hash: [9; 32], + epoch_start_height: height, + }), + 42, + ) +} + +fn refresh_accept(init: &Message, accept: &mut Message) { + let Payload::Accept(accepted) = &mut accept.payload else { unreachable!() }; + accepted.init_hash = transcript::init_hash(&init.encode().unwrap()); + sign(accept, 43); +} + +fn hex(value: &str) -> Vec { + (0..value.len()).step_by(2).map(|i| u8::from_str_radix(&value[i..i + 2], 16).unwrap()).collect() +} + +#[derive(Deserialize)] +struct Fixture { + init_wire: String, + accept_wire: String, + activate_wire: String, + ack_wire: String, + book: String, + book_hash: String, + setup_hash: String, + commitment_hash: String, + activation_hash: String, +} + +#[test] +fn all_public_books_and_activation_statements_authenticate() { + let fixtures: Vec = + serde_json::from_str(include_str!("data/appendix-d.json")).unwrap(); + assert_eq!(fixtures.len(), 6); + let receiver = PublicKey::from_slice(&hex( + "039fca7f8157aa768708894ffd92550fe970edd18526a5f936583ea3b54dab3228", + )) + .unwrap(); + let settlement = PublicKey::from_slice(&hex( + "02087b7d1b4789170f6e374f0a0e58a1b7a899e34929795314ab6964e69609e9c0", + )) + .unwrap(); + for fixture in fixtures { + let init = Message::decode(&hex(&fixture.init_wire)).unwrap(); + let accept = Message::decode(&hex(&fixture.accept_wire)).unwrap(); + let setup = AuthenticatedSetup::new(&init, &accept, receiver, settlement).unwrap(); + assert_eq!(setup.canonical_book(), hex(&fixture.book)); + assert_eq!(setup.book_hash().as_slice(), hex(&fixture.book_hash)); + assert_eq!(setup.setup_hash().as_slice(), hex(&fixture.setup_hash)); + let activation = Message::decode(&hex(&fixture.activate_wire)).unwrap(); + let expected_commitment = hex(&fixture.commitment_hash).try_into().unwrap(); + let hash = setup.validate_activation(&activation, expected_commitment, 790_000).unwrap(); + assert_eq!(hash.as_slice(), hex(&fixture.activation_hash)); + let ack = Message::decode(&hex(&fixture.ack_wire)).unwrap(); + setup.validate_activation_ack(&ack, hash).unwrap(); + } +} + +#[test] +fn setup_requires_distinct_expected_signers_and_unique_hashes() { + let (init, mut accept) = setup_messages(); + assert!(matches!( + AuthenticatedSetup::new(&init, &accept, key(42), key(42)), + Err(SetupError::Roles) + )); + assert!(matches!( + AuthenticatedSetup::new(&init, &accept, key(43), key(42)), + Err(SetupError::Wire(_)) + )); + let Payload::Accept(accepted) = &mut accept.payload else { unreachable!() }; + accepted.payment_hashes[1] = accepted.payment_hashes[0]; + sign(&mut accept, 43); + assert!(matches!( + AuthenticatedSetup::new(&init, &accept, key(42), key(43)), + Err(SetupError::DuplicateHash) + )); +} + +#[test] +fn setup_rejects_signed_fee_overflow() { + let (mut init, mut accept) = setup_messages(); + let Payload::Init(terms) = &mut init.payload else { unreachable!() }; + terms.amounts_msat = vec![u64::MAX]; + terms.budget_msat = u64::MAX; + terms.fee_proportional_millionths = 2; + sign(&mut init, 42); + let Payload::Accept(accepted) = &mut accept.payload else { unreachable!() }; + accepted.amounts_msat = vec![u64::MAX]; + accepted.payment_hashes.truncate(1); + refresh_accept(&init, &mut accept); + assert!(matches!( + AuthenticatedSetup::new(&init, &accept, key(42), key(43)), + Err(SetupError::FeeOverflow) + )); +} + +#[test] +fn setup_copies_signed_inputs_and_preserves_optional_extensions() { + let (mut init, mut accept) = setup_messages(); + init.extensions.push(Tlv { kind: 99, value: vec![1, 2, 3] }); + sign(&mut init, 42); + refresh_accept(&init, &mut accept); + let setup = AuthenticatedSetup::new(&init, &accept, key(42), key(43)).unwrap(); + let original_wire = setup.init().encode().unwrap(); + init.extensions[0].value[0] = 255; + accept.header.epoch_id = [8; 32]; + assert_eq!(setup.init().encode().unwrap(), original_wire); + assert_eq!(setup.accept().header, setup.header()); + assert_eq!(setup.vouchers()[1].htlc_id, 9); + assert_eq!(setup.terms().fees.base_msat, 1000); +} + +#[test] +fn requested_hash_chain_must_link_every_slot() { + let (mut init, mut accept) = setup_messages(); + let Payload::Init(terms) = &mut init.payload else { unreachable!() }; + terms.hash_chain = true; + sign(&mut init, 42); + refresh_accept(&init, &mut accept); + assert!(matches!( + AuthenticatedSetup::new(&init, &accept, key(42), key(43)), + Err(SetupError::HashChain) + )); + let Payload::Accept(accepted) = &mut accept.payload else { unreachable!() }; + accepted.payment_hashes[0] = sha256::Hash::hash(&accepted.payment_hashes[1]).to_byte_array(); + sign(&mut accept, 43); + AuthenticatedSetup::new(&init, &accept, key(42), key(43)).unwrap(); +} + +#[test] +fn activation_checks_actual_commitments_identity_and_height_boundaries() { + let setup = setup(); + let activation = activate(&setup, 990); + assert_eq!(setup.validate_activation(&activation, [8; 32], 990), Err(SetupError::Transcript)); + for height in [983, 997, 1000, u32::MAX] { + assert_eq!( + setup.validate_activation(&activation, [9; 32], height), + Err(SetupError::Height) + ); + } + for height in [984, 990, 996] { + setup.validate_activation(&activation, [9; 32], height).unwrap(); + } + let mut other_epoch = activation; + other_epoch.header.epoch_id[0] ^= 1; + sign(&mut other_epoch, 42); + assert_eq!(setup.validate_activation(&other_epoch, [9; 32], 990), Err(SetupError::Identity)); + let at_deadline = activate(&setup, 1000); + assert_eq!(setup.validate_activation(&at_deadline, [9; 32], 999), Err(SetupError::Height)); +} + +#[test] +fn activation_ack_is_bound_to_role_type_and_activation_hash() { + let setup = setup(); + let hash = setup.validate_activation(&activate(&setup, 990), [9; 32], 990).unwrap(); + let mut ack = message(Payload::ActivateAck(hash), 43); + setup.validate_activation_ack(&ack, hash).unwrap(); + assert_eq!(setup.validate_activation_ack(&ack, [0; 32]), Err(SetupError::Transcript)); + sign(&mut ack, 42); + assert!(matches!(setup.validate_activation_ack(&ack, hash), Err(SetupError::Wire(_)))); + let close = message(Payload::Close(hash), 43); + assert_eq!(setup.validate_activation_ack(&close, hash), Err(SetupError::MessageType)); +} + +#[test] +fn lifecycle_validation_rejects_signed_wrong_message_types() { + let setup = setup(); + let receiver_close = message(Payload::Close([9; 32]), 42); + let settlement_close = message(Payload::Close([9; 32]), 43); + assert_eq!( + setup.validate_activation(&receiver_close, [9; 32], 990), + Err(SetupError::MessageType) + ); + assert_eq!(setup.validate_close_ack(&settlement_close, [9; 32]), Err(SetupError::MessageType)); + assert!(matches!( + AuthenticatedSetup::new(&receiver_close, &settlement_close, key(42), key(43)), + Err(SetupError::MessageType) + )); +} + +#[test] +fn activation_cannot_admit_at_local_deadline_even_when_peer_tip_is_close() { + let setup = setup(); + assert_eq!( + setup.validate_activation(&activate(&setup, 999), [9; 32], 1000), + Err(SetupError::Height) + ); +} + +#[test] +fn close_ack_verifies_book_size_and_preimages() { + let setup = setup(); + let mut ack = message( + Payload::CloseAck(CloseAck { + activation_hash: [9; 32], + num_slots: 2, + settled: vec![1], + preimages_tlv_present: true, + preimages: vec![Preimage { slot: 1, value: [5; 32] }], + }), + 43, + ); + setup.validate_close_ack(&ack, [9; 32]).unwrap(); + let mut missing_preimage_tlv = ack.clone(); + let Payload::CloseAck(close) = &mut missing_preimage_tlv.payload else { unreachable!() }; + close.preimages_tlv_present = false; + assert!(missing_preimage_tlv.signature_digest().is_err()); + assert!(missing_preimage_tlv.encode().is_err()); + assert_eq!(setup.validate_close_ack(&ack, [8; 32]), Err(SetupError::Transcript)); + let Payload::CloseAck(close) = &mut ack.payload else { unreachable!() }; + close.preimages[0].value = [6; 32]; + sign(&mut ack, 43); + assert_eq!(setup.validate_close_ack(&ack, [9; 32]), Err(SetupError::CloseBook)); + let Payload::CloseAck(close) = &mut ack.payload else { unreachable!() }; + close.num_slots = 3; + sign(&mut ack, 43); + assert_eq!(setup.validate_close_ack(&ack, [9; 32]), Err(SetupError::CloseBook)); +} + +#[test] +fn chained_close_cannot_claim_a_later_slot_without_every_prior_slot() { + let (mut init, mut accept) = setup_messages(); + let Payload::Init(terms) = &mut init.payload else { unreachable!() }; + terms.hash_chain = true; + sign(&mut init, 42); + let first_preimage = sha256::Hash::hash(&[6; 32]).to_byte_array(); + let Payload::Accept(accepted) = &mut accept.payload else { unreachable!() }; + accepted.payment_hashes[0] = sha256::Hash::hash(&first_preimage).to_byte_array(); + refresh_accept(&init, &mut accept); + let setup = AuthenticatedSetup::new(&init, &accept, key(42), key(43)).unwrap(); + let mut ack = message( + Payload::CloseAck(CloseAck { + activation_hash: [9; 32], + num_slots: 2, + settled: vec![2], + preimages_tlv_present: true, + preimages: vec![Preimage { slot: 2, value: [6; 32] }], + }), + 43, + ); + assert_eq!(setup.validate_close_ack(&ack, [9; 32]), Err(SetupError::CloseBook)); + let Payload::CloseAck(close) = &mut ack.payload else { unreachable!() }; + close.settled[0] = 3; + close.preimages.insert(0, Preimage { slot: 1, value: first_preimage }); + sign(&mut ack, 43); + setup.validate_close_ack(&ack, [9; 32]).unwrap(); +} + +proptest! { + #[test] + fn signed_wrong_activation_hashes_are_rejected(field in 0_u8..3, index in 0_usize..32, bit in 0_u8..8) { + let setup = setup(); + let mut activation = activate(&setup, 990); + let Payload::Activate(contents) = &mut activation.payload else { unreachable!() }; + let hash = match field { 0 => &mut contents.setup_hash, 1 => &mut contents.book_hash, _ => &mut contents.commit_hash }; + hash[index] ^= 1 << bit; + sign(&mut activation, 42); + prop_assert_eq!(setup.validate_activation(&activation, [9; 32], 990), Err(SetupError::Transcript)); + } +} diff --git a/crates/ffor-protocol/tests/transcript.rs b/crates/ffor-protocol/tests/transcript.rs index f15b5e83d2..e92b8941ac 100644 --- a/crates/ffor-protocol/tests/transcript.rs +++ b/crates/ffor-protocol/tests/transcript.rs @@ -1,6 +1,7 @@ -use bitcoin::secp256k1::ecdsa::Signature; -use bitcoin::secp256k1::{Message, PublicKey, Secp256k1}; +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::secp256k1::{Message, PublicKey, Secp256k1, SecretKey}; use ffor_protocol::transcript; +use ffor_protocol::transcript::{verify_message_signature, SignatureError}; use proptest::prelude::*; use serde::Deserialize; @@ -43,7 +44,6 @@ fn appendix_d_all_six_transcripts_match_published_bytes() { "02087b7d1b4789170f6e374f0a0e58a1b7a899e34929795314ab6964e69609e9c0", )) .unwrap(); - let secp = Secp256k1::verification_only(); for fixture in fixtures { let init = transcript::init_hash(&hex(&fixture.init_wire)); assert_eq!(init, digest(&fixture.init_hash), "{}", fixture.scenario); @@ -70,21 +70,86 @@ fn appendix_d_all_six_transcripts_match_published_bytes() { ] { let wire = hex(encoded); let signature_start = wire.len() - 64; - let signature = Signature::from_compact(&wire[signature_start..]).unwrap(); - let mut normalized = signature; - normalized.normalize_s(); - assert_eq!(signature, normalized); + let signature = wire[signature_start..].try_into().unwrap(); let kind = u16::from_be_bytes(wire[..2].try_into().unwrap()); let unsigned = &wire[2..signature_start]; - let message = Message::from_digest(transcript::message_digest(kind, unsigned)); - secp.verify_ecdsa(&message, &signature, &key).unwrap(); - let wrong_type = Message::from_digest(transcript::message_digest(kind + 2, unsigned)); - assert!(secp.verify_ecdsa(&wrong_type, &signature, &key).is_err()); + verify_message_signature(kind, unsigned, &signature, &key).unwrap(); + assert_eq!( + verify_message_signature(kind + 2, unsigned, &signature, &key), + Err(SignatureError::Invalid) + ); } } } +fn test_key() -> (SecretKey, PublicKey) { + let secret = SecretKey::from_slice(&[42; 32]).unwrap(); + let public = PublicKey::from_secret_key(&Secp256k1::new(), &secret); + (secret, public) +} + +fn sign_digest(digest: [u8; 32]) -> [u8; 64] { + Secp256k1::new().sign_ecdsa(&Message::from_digest(digest), &test_key().0).serialize_compact() +} + +#[test] +fn signature_rejects_invalid_compact_scalars() { + assert_eq!( + verify_message_signature(55001, b"body", &[255; 64], &test_key().1), + Err(SignatureError::Malformed) + ); + assert_eq!( + verify_message_signature(55001, b"body", &[0; 64], &test_key().1), + Err(SignatureError::Invalid) + ); +} + +#[test] +fn signature_rejects_high_s_without_normalizing_received_bytes() { + let body = [7; 128]; + let mut signature = sign_digest(transcript::message_digest(55001, &body)); + // Negating s modulo the curve order preserves ECDSA validity but violates canonical low-S. + let order = hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); + let mut borrow = 0_i16; + for index in (0..32).rev() { + let value = i16::from(order[index]) - i16::from(signature[32 + index]) - borrow; + signature[32 + index] = value.rem_euclid(256) as u8; + borrow = i16::from(value < 0); + } + assert_eq!( + verify_message_signature(55001, &body, &signature, &test_key().1), + Err(SignatureError::HighS) + ); +} + +#[test] +fn signature_requires_single_hash_and_ffor_domain() { + let body = [9; 128]; + let digest = transcript::message_digest(55001, &body); + for wrong_digest in + [sha256::Hash::hash(&body).to_byte_array(), sha256::Hash::hash(&digest).to_byte_array()] + { + assert_eq!( + verify_message_signature(55001, &body, &sign_digest(wrong_digest), &test_key().1), + Err(SignatureError::Invalid) + ); + } +} + proptest! { + #[test] + fn signed_bytes_and_expected_peer_cannot_be_substituted(mut body in prop::collection::vec(any::(), 64..2048), changed_index in any::()) { + let (_, public) = test_key(); + let signature = sign_digest(transcript::message_digest(55001, &body)); + prop_assert_eq!(verify_message_signature(55001, &body, &signature, &public), Ok(())); + let other_secret = SecretKey::from_slice(&[43; 32]).unwrap(); + let other_public = PublicKey::from_secret_key(&Secp256k1::new(), &other_secret); + prop_assert_eq!(verify_message_signature(55001, &body, &signature, &other_public), Err(SignatureError::Invalid)); + let index = changed_index % body.len(); + body[index] ^= 1; + prop_assert_eq!(verify_message_signature(55001, &body, &signature, &public), Err(SignatureError::Invalid)); + } + #[test] fn transcript_fields_and_domains_are_bound(bytes in prop::collection::vec(any::(), 0..2048), height in 0_u32..u32::MAX) { let init = transcript::init_hash(&bytes); diff --git a/crates/ffor-protocol/tests/wire.rs b/crates/ffor-protocol/tests/wire.rs new file mode 100644 index 0000000000..9a4b655e65 --- /dev/null +++ b/crates/ffor-protocol/tests/wire.rs @@ -0,0 +1,502 @@ +use bitcoin::secp256k1::PublicKey; +use ffor_protocol::transcript; +use ffor_protocol::wire::{ + Abort, CloseAck, Message, Payload, Preimage, Tlv, WireError, MAX_MESSAGE_LEN, MAX_TLV_COUNT, +}; +use proptest::prelude::*; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Fixture { + scenario: String, + init_wire: String, + accept_wire: String, + activate_wire: String, + ack_wire: String, +} + +fn fixtures() -> Vec { + serde_json::from_str(include_str!("data/appendix-d.json")).unwrap() +} + +fn hex(value: &str) -> Vec { + (0..value.len()).step_by(2).map(|i| u8::from_str_radix(&value[i..i + 2], 16).unwrap()).collect() +} + +fn receiver() -> PublicKey { + PublicKey::from_slice(&hex( + "039fca7f8157aa768708894ffd92550fe970edd18526a5f936583ea3b54dab3228", + )) + .unwrap() +} + +fn settlement() -> PublicKey { + PublicKey::from_slice(&hex( + "02087b7d1b4789170f6e374f0a0e58a1b7a899e34929795314ab6964e69609e9c0", + )) + .unwrap() +} + +fn init() -> Message { + Message::decode(&hex(&fixtures()[0].init_wire)).unwrap() +} +fn ack() -> Message { + Message::decode(&hex(&fixtures()[0].ack_wire)).unwrap() +} + +// Modified fixture signatures remain untrusted bytes, never newly signed messages. +fn append_tlv(mut wire: Vec, tlv: &[u8]) -> Vec { + wire.splice(wire.len() - 64..wire.len() - 64, tlv.iter().copied()); + wire +} + +fn close_ack() -> Message { + let mut message = ack(); + message.payload = Payload::CloseAck(CloseAck { + activation_hash: [3; 32], + num_slots: 9, + settled: vec![1, 1], + preimages: vec![Preimage { slot: 1, value: [1; 32] }, Preimage { slot: 9, value: [9; 32] }], + preimages_tlv_present: true, + }); + message +} + +#[test] +fn appendix_d_all_published_setup_and_activation_messages_round_trip() { + for fixture in fixtures() { + let initial = Message::decode(&hex(&fixture.init_wire)).unwrap(); + let accept = Message::decode(&hex(&fixture.accept_wire)).unwrap(); + accept.validate_accept(&initial).unwrap(); + for (wire, signer) in [ + (fixture.init_wire, receiver()), + (fixture.accept_wire, settlement()), + (fixture.activate_wire, receiver()), + (fixture.ack_wire, settlement()), + ] { + let bytes = hex(&wire); + let message = Message::decode(&bytes).unwrap(); + assert_eq!(message.encode().unwrap(), bytes, "{}", fixture.scenario); + message.verify_signature(&signer).unwrap(); + assert_eq!( + message.signature_digest().unwrap(), + transcript::message_digest(message.message_type(), &bytes[2..bytes.len() - 64]) + ); + } + } +} + +#[test] +fn truncation_and_oversized_peer_messages_are_rejected() { + let fixture = &fixtures()[0]; + for encoded in + [&fixture.init_wire, &fixture.accept_wire, &fixture.activate_wire, &fixture.ack_wire] + { + let bytes = hex(encoded); + for end in 0..bytes.len() { + assert!(Message::decode(&bytes[..end]).is_err(), "prefix {end}"); + } + } + assert_eq!(Message::decode(&vec![0; MAX_MESSAGE_LEN + 1]), Err(WireError::SizeLimit)); +} + +#[test] +fn unsupported_variants_and_unassigned_message_types_are_rejected() { + for variant in [0, 1, 2, 3, 5, 255] { + let mut bytes = init().encode().unwrap(); + bytes[66] = variant; + assert_eq!(Message::decode(&bytes), Err(WireError::UnsupportedVariant(variant))); + } + for kind in [55011u16, 55021, 55055, 0, u16::MAX] { + let mut bytes = init().encode().unwrap(); + bytes[..2].copy_from_slice(&kind.to_be_bytes()); + assert_eq!(Message::decode(&bytes), Err(WireError::UnsupportedMessage(kind))); + } +} + +#[test] +fn variant_d_disallows_escape_ladders_and_receiver_points() { + for offset in [108, 110] { + let mut bytes = init().encode().unwrap(); + bytes[offset] = 1; + assert_eq!(Message::decode(&bytes), Err(WireError::InvalidField)); + } + for kind in [1, 3, 5] { + let mut bytes = init().encode().unwrap(); + bytes.splice(111..111, [kind, 0]); + assert_eq!(Message::decode(&bytes), Err(WireError::InvalidTlv(u64::from(kind)))); + } +} + +#[test] +fn slot_count_amount_lengths_minimum_budget_and_deadline_are_checked() { + for count in [0u16, 484, u16::MAX] { + let mut bytes = init().encode().unwrap(); + bytes[75..77].copy_from_slice(&count.to_be_bytes()); + assert!(Message::decode(&bytes).is_err()); + } + let mut bytes = init().encode().unwrap(); + bytes[76] = 2; + assert_eq!(Message::decode(&bytes), Err(WireError::InvalidTlv(9))); + for mutate in [0, 1, 2, 3] { + let mut message = init(); + let Payload::Init(terms) = &mut message.payload else { panic!() }; + match mutate { + 0 => terms.amounts_msat[0] = 0, + 1 => terms.min_payment_msat = u64::MAX, + 2 => terms.budget_msat += 1, + _ => terms.voucher_expiry = terms.settlement_deadline, + } + assert!(message.encode().is_err()); + } +} + +#[test] +fn amount_sum_overflow_is_rejected() { + let mut message = init(); + let Payload::Init(terms) = &mut message.payload else { panic!() }; + terms.amounts_msat = vec![u64::MAX, 1]; + terms.budget_msat = 0; + terms.min_payment_msat = 1; + assert_eq!(message.encode(), Err(WireError::InvalidField)); +} + +#[test] +fn required_tlv_and_canonical_bigsize_rules_are_enforced() { + let mut no_amounts = init().encode().unwrap(); + no_amounts.drain(111..no_amounts.len() - 64); + assert_eq!(Message::decode(&no_amounts), Err(WireError::MissingTlv(9))); + for tlv in [vec![9, 0], vec![7, 0]] { + assert_eq!( + Message::decode(&append_tlv(init().encode().unwrap(), &tlv)), + Err(WireError::TlvOrder) + ); + } + assert_eq!( + Message::decode(&append_tlv(init().encode().unwrap(), &[10, 0])), + Err(WireError::UnknownEvenTlv(10)) + ); + for nonminimal in [ + vec![0xfd, 0, 11, 0], + vec![11, 0xfd, 0, 0], + vec![0xfe, 0, 0, 1, 1, 0], + vec![0xff, 0, 0, 0, 0, 0, 1, 0, 0, 0], + ] { + assert_eq!( + Message::decode(&append_tlv(init().encode().unwrap(), &nonminimal)), + Err(WireError::NonCanonicalBigSize) + ); + } + assert_eq!( + Message::decode(&append_tlv( + init().encode().unwrap(), + &[11, 0xff, 255, 255, 255, 255, 255, 255, 255, 255] + )), + Err(WireError::SizeLimit) + ); + assert!(Message::decode(&append_tlv(init().encode().unwrap(), &[11, 2, 1])).is_err()); +} + +#[test] +fn unknown_odd_extensions_preserve_exact_bytes_and_are_signed() { + let mut message = init(); + message.extensions = + vec![Tlv { kind: 257, value: vec![7; 253] }, Tlv { kind: u64::MAX, value: vec![] }]; + let wire = message.encode().unwrap(); + assert_eq!(Message::decode(&wire).unwrap(), message); + assert_eq!(message.verify_signature(&receiver()), Err(WireError::InvalidSignature)); + assert_ne!(message.signature_digest().unwrap(), init().signature_digest().unwrap()); +} + +#[test] +fn witness_points_require_compressed_valid_curve_points_and_exact_lengths() { + let mut message = init(); + let Payload::Init(terms) = &mut message.payload else { panic!() }; + terms.witness_peers = Some(vec![receiver(), settlement()]); + let wire = message.encode().unwrap(); + assert_eq!(Message::decode(&wire).unwrap(), message); + let mut invalid = vec![13, 35, 0, 1]; + invalid.extend_from_slice(&[0; 33]); + assert_eq!( + Message::decode(&append_tlv(init().encode().unwrap(), &invalid)), + Err(WireError::InvalidPoint) + ); + assert!(Message::decode(&append_tlv(init().encode().unwrap(), &[13, 2, 0, 1])).is_err()); + assert_eq!( + Message::decode(&append_tlv(init().encode().unwrap(), &[13, 3, 0, 0, 0])), + Err(WireError::InvalidTlv(13)) + ); +} + +#[test] +fn hash_chain_profile_has_one_canonical_encoding_and_requires_uniform_amounts() { + for value in [vec![], vec![0], vec![2], vec![1, 1]] { + let mut tlv = vec![15, value.len() as u8]; + tlv.extend(value); + assert_eq!( + Message::decode(&append_tlv(init().encode().unwrap(), &tlv)), + Err(WireError::InvalidTlv(15)) + ); + } + let mut message = init(); + let Payload::Init(terms) = &mut message.payload else { panic!() }; + terms.hash_chain = true; + assert_eq!(Message::decode(&message.encode().unwrap()).unwrap(), message); + let Payload::Init(terms) = &mut message.payload else { panic!() }; + terms.amounts_msat.push(terms.amounts_msat[0] + 1); + terms.budget_msat = terms.amounts_msat.iter().sum(); + assert_eq!(message.encode(), Err(WireError::InvalidTlv(15))); +} + +#[test] +fn accept_requires_each_known_tlv_and_consistent_slot_identity() { + let fixture = &fixtures()[0]; + let original = Message::decode(&hex(&fixture.accept_wire)).unwrap(); + for mutate in [0, 1, 2] { + let mut message = original.clone(); + let Payload::Accept(accept) = &mut message.payload else { panic!() }; + match mutate { + 0 => accept.amounts_msat.clear(), + 1 => accept.amounts_msat[0] = 0, + _ => { + accept.payment_hashes.push([2; 32]); + accept.amounts_msat.push(1); + accept.s_htlc_id_base = u64::MAX; + }, + } + assert!(message.encode().is_err()); + } + for (start, len, kind) in [(74, 34, 1), (108, 10, 7), (118, 10, 9), (128, 34, 11)] { + let mut bytes = original.encode().unwrap(); + bytes.drain(start..start + len); + assert_eq!(Message::decode(&bytes), Err(WireError::MissingTlv(kind))); + } +} + +#[test] +fn accept_binds_header_exact_amounts_and_complete_signed_init_bytes() { + let mut initial = init(); + let accept = Message::decode(&hex(&fixtures()[0].accept_wire)).unwrap(); + accept.validate_accept(&initial).unwrap(); + initial.extensions.push(Tlv { kind: 17, value: vec![1] }); + assert_eq!(accept.validate_accept(&initial), Err(WireError::SetupMismatch)); + let mut other = accept.clone(); + other.header.epoch_id[0] ^= 1; + assert_eq!(other.validate_accept(&init()), Err(WireError::SetupMismatch)); + let Payload::Accept(terms) = &mut other.payload else { panic!() }; + terms.amounts_msat[0] += 1; + other.header = init().header; + assert_eq!(other.validate_accept(&init()), Err(WireError::SetupMismatch)); + assert_eq!(init().validate_accept(&accept), Err(WireError::SetupMismatch)); +} + +#[test] +fn lifecycle_structure_round_trips_but_changed_messages_do_not_authenticate() { + for payload in [ + Payload::Abort(Abort { transcript_hash: [2; 32], reason: 7, data: vec![0, 255, 1] }), + Payload::Close([3; 32]), + close_ack().payload, + ] { + let mut message = ack(); + message.payload = payload; + let bytes = message.encode().unwrap(); + assert_eq!(Message::decode(&bytes).unwrap(), message); + assert_eq!(message.verify_signature(&settlement()), Err(WireError::InvalidSignature)); + } +} + +#[test] +fn close_ack_requires_preimages_matching_every_set_bit_in_order() { + for mutate in [0, 1, 2, 3, 4, 5] { + let mut message = close_ack(); + let Payload::CloseAck(close) = &mut message.payload else { panic!() }; + match mutate { + 0 => close.preimages.clear(), + 1 => close.preimages.swap(0, 1), + 2 => close.preimages[1].slot = 1, + 3 => close.settled[1] = 0x81, + 4 => close.num_slots = 0, + _ => close.settled.push(0), + } + assert!(message.encode().is_err()); + } + let mut message = close_ack(); + let Payload::CloseAck(close) = &mut message.payload else { panic!() }; + close.settled = vec![0, 0]; + close.preimages.clear(); + let mut bytes = message.encode().unwrap(); + assert_eq!(Message::decode(&bytes).unwrap(), message); + let end = bytes.len() - 64; + bytes.drain(end - 2..end); + let absent = Message::decode(&bytes).unwrap(); + let Payload::CloseAck(close) = &absent.payload else { panic!() }; + assert!(!close.preimages_tlv_present); + assert_eq!(absent.encode().unwrap(), bytes); +} + +#[test] +fn encoder_rejects_oversized_data_and_noncanonical_extensions() { + let mut message = ack(); + message.payload = Payload::Abort(Abort { transcript_hash: [2; 32], reason: 8, data: vec![] }); + assert_eq!(message.encode(), Err(WireError::InvalidField)); + let Payload::Abort(abort) = &mut message.payload else { panic!() }; + abort.reason = 0; + abort.data = vec![0; MAX_MESSAGE_LEN]; + assert_eq!(message.encode(), Err(WireError::SizeLimit)); + for extensions in [ + vec![Tlv { kind: 17, value: vec![0; MAX_MESSAGE_LEN] }], + vec![Tlv { kind: 13, value: vec![] }], + vec![Tlv { kind: 15, value: vec![1] }], + vec![Tlv { kind: 17, value: vec![] }, Tlv { kind: 17, value: vec![] }], + vec![Tlv { kind: 19, value: vec![] }, Tlv { kind: 17, value: vec![] }], + vec![Tlv { kind: 18, value: vec![] }], + ] { + let mut message = init(); + message.extensions = extensions; + assert!(message.encode().is_err()); + } +} + +#[test] +fn zero_out_of_range_and_high_s_signatures_are_rejected() { + for bytes in [[0; 64], [255; 64]] { + let mut message = ack(); + message.signature = bytes; + assert_eq!(message.encode(), Err(WireError::InvalidSignature)); + } + let mut message = ack(); + let order = hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); + let mut borrow = 0i16; + for i in (0..32).rev() { + let value = i16::from(order[i]) - i16::from(message.signature[i + 32]) - borrow; + message.signature[i + 32] = value.rem_euclid(256) as u8; + borrow = i16::from(value < 0); + } + assert_eq!(message.encode(), Err(WireError::InvalidSignature)); +} + +#[test] +fn unsigned_digest_can_be_requested_without_manufacturing_a_signature() { + let mut message = init(); + let digest = message.signature_digest().unwrap(); + message.signature = [0; 64]; + assert_eq!(message.signature_digest().unwrap(), digest); + assert_eq!(message.encode(), Err(WireError::InvalidSignature)); + message.extensions.push(Tlv { kind: 17, value: vec![1] }); + assert_ne!(message.signature_digest().unwrap(), digest); +} + +#[test] +fn encoder_reserves_signature_space_and_bounds_collections_before_processing() { + let mut message = ack(); + message.payload = Payload::Abort(Abort { + transcript_hash: [2; 32], + reason: 0, + data: vec![7; MAX_MESSAGE_LEN - 166], + }); + let bytes = message.encode().unwrap(); + assert_eq!(bytes.len(), MAX_MESSAGE_LEN); + assert_eq!(Message::decode(&bytes).unwrap(), message); + let Payload::Abort(abort) = &mut message.payload else { panic!() }; + abort.data.push(1); + assert_eq!(message.signature_digest(), Err(WireError::SizeLimit)); + let mut oversized_extensions = init(); + oversized_extensions.extensions = vec![Tlv { kind: 17, value: vec![] }; MAX_TLV_COUNT + 1]; + assert_eq!(oversized_extensions.encode(), Err(WireError::SizeLimit)); + let mut oversized_points = init(); + let Payload::Init(terms) = &mut oversized_points.payload else { panic!() }; + terms.witness_peers = Some(vec![receiver(); MAX_MESSAGE_LEN / 33 + 1]); + assert_eq!(oversized_points.encode(), Err(WireError::SizeLimit)); + assert_eq!( + Message::decode(&append_tlv(init().encode().unwrap(), &[13, 2, 255, 255])), + Err(WireError::SizeLimit) + ); +} + +#[test] +fn malformed_known_tlv_value_lengths_are_rejected_on_the_wire() { + let mut amounts = init().encode().unwrap(); + amounts[112] = 7; + amounts.remove(120); + assert_eq!(Message::decode(&amounts), Err(WireError::InvalidTlv(9))); + let mut hashes = Message::decode(&hex(&fixtures()[0].accept_wire)).unwrap().encode().unwrap(); + hashes[75] = 31; + hashes.remove(107); + assert_eq!(Message::decode(&hashes), Err(WireError::InvalidTlv(1))); + let mut preimages = close_ack().encode().unwrap(); + preimages[103] = 67; + preimages.remove(171); + assert_eq!(Message::decode(&preimages), Err(WireError::InvalidTlv(1))); + let mut one_slot = close_ack(); + let Payload::CloseAck(close) = &mut one_slot.payload else { panic!() }; + close.num_slots = 1; + close.settled = vec![1]; + close.preimages.truncate(1); + let mut too_many_preimages = one_slot.encode().unwrap(); + too_many_preimages[102] = 68; + too_many_preimages.splice(137..137, [0; 34]); + assert_eq!(Message::decode(&too_many_preimages), Err(WireError::InvalidTlv(1))); + let mut absent_preimages = one_slot.encode().unwrap(); + absent_preimages.drain(101..137); + assert!(Message::decode(&absent_preimages).is_err()); +} + +#[test] +fn invalid_wire_abort_reason_and_oversized_constructed_abort_are_rejected() { + let mut message = ack(); + message.payload = Payload::Abort(Abort { transcript_hash: [2; 32], reason: 0, data: vec![] }); + let mut wire = message.encode().unwrap(); + wire[99] = 8; + assert_eq!(Message::decode(&wire), Err(WireError::InvalidField)); + let Payload::Abort(abort) = &mut message.payload else { panic!() }; + abort.data = vec![0; MAX_MESSAGE_LEN + 1]; + assert_eq!(message.encode(), Err(WireError::SizeLimit)); +} + +#[test] +fn known_extension_collisions_and_zero_s_are_rejected() { + let mut accept = Message::decode(&hex(&fixtures()[0].accept_wire)).unwrap(); + accept.extensions.push(Tlv { kind: 1, value: vec![1] }); + assert_eq!(accept.encode(), Err(WireError::InvalidTlv(1))); + let mut close = close_ack(); + close.extensions.push(Tlv { kind: 1, value: vec![] }); + assert_eq!(close.encode(), Err(WireError::InvalidTlv(1))); + let mut message = ack(); + message.signature[32..].fill(0); + assert_eq!(message.encode(), Err(WireError::InvalidSignature)); + assert!(WireError::InvalidSignature.to_string().contains("InvalidSignature")); +} + +proptest! { + #[test] + fn arbitrary_bounded_input_never_panics_and_accepted_bytes_are_canonical( + bytes in prop::collection::vec(any::(), 0..=MAX_MESSAGE_LEN + 1) + ) { + if let Ok(message) = Message::decode(&bytes) { + prop_assert_eq!(message.encode().unwrap(), bytes); + } + } + + #[test] + fn unknown_odd_tlv_values_round_trip_without_normalization(value in prop::collection::vec(any::(), 0..2048)) { + let mut message = init(); + message.extensions = vec![Tlv { kind: 65_537, value }]; + let bytes = message.encode().unwrap(); + prop_assert_eq!(Message::decode(&bytes).unwrap(), message); + } + + #[test] + fn close_bitmaps_round_trip_with_exactly_the_corresponding_preimages(bits in prop::collection::vec(any::(), 1..=483)) { + let mut close = CloseAck { activation_hash: [3; 32], num_slots: bits.len() as u16, + settled: vec![0; bits.len().div_ceil(8)], preimages: Vec::new(), preimages_tlv_present: true }; + for (index, bit) in bits.into_iter().enumerate() { + if bit { + close.settled[index / 8] |= 1 << (index % 8); + close.preimages.push(Preimage { slot: index as u16 + 1, value: [index as u8; 32] }); + } + } + let mut message = ack(); + message.payload = Payload::CloseAck(close); + prop_assert_eq!(Message::decode(&message.encode().unwrap()).unwrap(), message); + } +} From 1b6be3b1b664194302b76992ff2a541f9a8edb40 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Sun, 20 Sep 2026 19:33:22 -0400 Subject: [PATCH 04/23] feat(ffor): validate variant d reconnect reports --- crates/ffor-protocol/README.md | 18 ++- .../ffor-protocol/fuzz/fuzz_targets/wire.rs | 4 + crates/ffor-protocol/fuzz/seed_corpus.py | 4 +- crates/ffor-protocol/src/lib.rs | 5 +- crates/ffor-protocol/src/reestablish.rs | 123 ++++++++++++++++++ .../tests/data/beignet-lifecycle.json | 37 ++++++ .../tests/data/generate_beignet_lifecycle.cjs | 11 +- crates/ffor-protocol/tests/reestablish.rs | 84 ++++++++++++ 8 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 crates/ffor-protocol/src/reestablish.rs create mode 100644 crates/ffor-protocol/tests/reestablish.rs diff --git a/crates/ffor-protocol/README.md b/crates/ffor-protocol/README.md index 473946ec1f..6ffef45ad2 100644 --- a/crates/ffor-protocol/README.md +++ b/crates/ffor-protocol/README.md @@ -1,8 +1,8 @@ # FFOR protocol foundation This unpublished crate implements checked Variant D amount and anchor-channel book -calculations, signed setup and lifecycle codecs, canonical books and authenticated -transcript checks from draft v0.9.4. It is not connected to `Node`, the payment +calculations, signed setup and lifecycle codecs, reconnect reports, canonical books +and authenticated transcript checks from draft v0.9.4. It is not connected to `Node`, the payment handlers, custom peer messages or UniFFI. It cannot prepare an offline invoice or receive a payment. @@ -57,11 +57,19 @@ Regenerate them with `generate_beignet_lifecycle.cjs` against the pinned Beignet checkout, using that checkout's `ts-node/register`. Both encodings are preserved exactly so authentication never depends on normalizing received signed bytes. +The same fixture file includes all seven reconnect states from Beignet. The +`reestablish` module reads the exact 67-byte TLV 55001 value and rejects unknown +states and nonzero Variant D sequences. It preserves the peer's reported hash as +untrusted data. The pinned Beignet sender includes its computed hash in `ACTIVATING`, +although section 11.1 specifies zeros before `ACTIVE`; local engine writers must +follow the specification. Neither form proves activation without the signed ack. + The standalone [fuzz target](fuzz/README.md) exercises parsing, canonical round trips, signatures and authenticated setup with real signed fixture seeds. A bounded local -run completed 6,359,059 inputs in 121 seconds without a crash. The current suite has -61 tests and three doctests. Nightly LLVM instrumentation measured 838/859 source -lines (97.56%) and 196/198 branches (98.99%) covered across this crate. Uncovered +run completed 6,359,059 inputs in 121 seconds without a crash; a run including the +reconnect parser completed 2,118,228 inputs in 31 seconds without a crash. The current +suite has 66 tests and four doctests. Nightly LLVM instrumentation measured 865/892 +source lines (96.97%) and 198/200 branches (99.00%) covered across this crate. Uncovered code includes diagnostic formatting and defensive paths whose preconditions are excluded by prior validated bounds. These are measured results, not exhaustive proofs of correctness. diff --git a/crates/ffor-protocol/fuzz/fuzz_targets/wire.rs b/crates/ffor-protocol/fuzz/fuzz_targets/wire.rs index ffc89fcf56..ae2752744c 100644 --- a/crates/ffor-protocol/fuzz/fuzz_targets/wire.rs +++ b/crates/ffor-protocol/fuzz/fuzz_targets/wire.rs @@ -1,6 +1,7 @@ #![no_main] use bitcoin::secp256k1::PublicKey; +use ffor_protocol::reestablish::Reestablish; use ffor_protocol::setup::AuthenticatedSetup; use ffor_protocol::wire::{Message, MAX_MESSAGE_LEN}; use libfuzzer_sys::fuzz_target; @@ -22,6 +23,9 @@ fuzz_target!(|data: &[u8]| { return; } let (receiver, settlement) = keys(); + if let Ok(report) = Reestablish::decode(data) { + assert_eq!(report.encode().as_slice(), data); + } if let Ok(message) = Message::decode(data) { assert_eq!(message.encode().unwrap(), data); let _ = message.verify_signature(&receiver); diff --git a/crates/ffor-protocol/fuzz/seed_corpus.py b/crates/ffor-protocol/fuzz/seed_corpus.py index a0be8152a3..5eb982898e 100644 --- a/crates/ffor-protocol/fuzz/seed_corpus.py +++ b/crates/ffor-protocol/fuzz/seed_corpus.py @@ -17,5 +17,7 @@ reference = json.loads((crate / "tests/data/beignet-lifecycle.json").read_text()) for fixture in reference["fixtures"]: (corpus / fixture["name"]).write_bytes(bytes.fromhex(fixture["wire"])) +for fixture in reference["reestablish"]: + (corpus / f"reestablish-{fixture['state']}").write_bytes(bytes.fromhex(fixture["value"])) -print("Seeded 35 signed reference messages and setup pairs") +print("Seeded 35 signed reference messages/setup pairs and 7 reconnect reports") diff --git a/crates/ffor-protocol/src/lib.rs b/crates/ffor-protocol/src/lib.rs index 1befe62478..f2402b50fd 100644 --- a/crates/ffor-protocol/src/lib.rs +++ b/crates/ffor-protocol/src/lib.rs @@ -1,8 +1,8 @@ //! Pure primitives for the draft FFOR Variant D protocol. //! //! These primitives do not implement a receiver or settlement peer. In particular, successful -//! arithmetic or transcript construction is not proof of channel commitments, signature -//! verification, durable activation, or readiness to expose an offline invoice. +//! arithmetic, signature verification or transcript construction is not proof of channel +//! commitments, durable activation, or readiness to expose an offline invoice. //! //! The channel engine must retain sole ownership of commitment transitions and signing keys. @@ -11,6 +11,7 @@ pub mod amounts; pub mod book; +pub mod reestablish; pub mod setup; pub mod transcript; pub mod wire; diff --git a/crates/ffor-protocol/src/reestablish.rs b/crates/ffor-protocol/src/reestablish.rs new file mode 100644 index 0000000000..de8dcf69ba --- /dev/null +++ b/crates/ffor-protocol/src/reestablish.rs @@ -0,0 +1,123 @@ +//! The Variant D value carried by `channel_reestablish` TLV 55001 (FFOR section 11.1). +//! +//! This is an unsigned peer report, not evidence of activation or payment. The channel engine +//! must compare it with its durable epoch and authenticate any replayed acknowledgement. In +//! particular, Variant D's zero sequence number never permits discarding an active epoch. + +use std::fmt; + +use crate::transcript::Digest; + +/// The TLV type within a standard BOLT 2 `channel_reestablish` message. +pub const TLV_TYPE: u64 = 55001; +/// The exact value length, excluding the enclosing TLV type and length. +pub const VALUE_LEN: usize = 67; + +/// A reported lifecycle phase, without authority to perform a channel transition. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum ReportedState { + /// Setup has started but its vouchers are not yet committed. + Negotiating = 0, + /// The complete book exists in both commitment views. + VouchersCommitted = 1, + /// The receiver is waiting for the signed activation acknowledgement. + Activating = 2, + /// The reporting peer says it durably accepted activation. + Active = 3, + /// The reporting peer says it accepted the close acknowledgement. + Draining = 4, + /// All vouchers have been irrevocably resolved. + Closed = 5, + /// Setup was aborted before activation. + Aborted = 6, +} + +/// The reported epoch and phase on reconnect. +/// +/// No signature is included here. Parsing preserves the reported hash even in a pre-active +/// state, where it has no activation authority. This also permits reading the pinned Beignet +/// implementation, which retains its computed hash while reporting `Activating`. +/// +/// Locally generated reports must use a zero hash before `Active`, as required by section 11.1. +/// `last_seq` is not exposed because Variant D always encodes zero. Ordinary BOLT 2 commitment +/// and revocation counters still need their normal validation by the channel engine. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Reestablish { + /// The epoch being reported, scoped by the enclosing message's channel ID. + pub epoch_id: Digest, + /// The peer's reported lifecycle state. + pub state: ReportedState, + /// Reported activation digest, meaningful only with appropriate authenticated evidence. + pub activation_hash: Digest, +} + +impl Reestablish { + /// Decode one exact TLV value. Reject truncation, trailing bytes, unknown states and A/B sequences. + /// + /// This function allocates nothing and does not authorize any channel transition. + pub fn decode(value: &[u8]) -> Result { + let value: &[u8; VALUE_LEN] = value.try_into().map_err(|_| ReestablishError::Length)?; + let state = match value[32] { + 0 => ReportedState::Negotiating, + 1 => ReportedState::VouchersCommitted, + 2 => ReportedState::Activating, + 3 => ReportedState::Active, + 4 => ReportedState::Draining, + 5 => ReportedState::Closed, + 6 => ReportedState::Aborted, + _ => return Err(ReestablishError::State), + }; + if value[33..35] != [0, 0] { + return Err(ReestablishError::Sequence); + } + let mut epoch_id = [0; 32]; + epoch_id.copy_from_slice(&value[..32]); + let mut activation_hash = [0; 32]; + activation_hash.copy_from_slice(&value[35..]); + Ok(Self { epoch_id, state, activation_hash }) + } + + /// Encode this report exactly, with Variant D's zero sequence number. + /// + /// Use only engine-owned durable state to construct an outbound report. For an active receiver, + /// a missing or conflicting peer report is not permission to discard vouchers or lift the freeze. + /// + /// ``` + /// use ffor_protocol::reestablish::{Reestablish, ReportedState}; + /// let report = Reestablish { + /// epoch_id: [1; 32], state: ReportedState::Activating, activation_hash: [0; 32], + /// }; + /// assert_eq!(Reestablish::decode(&report.encode()).unwrap(), report); + /// ``` + pub fn encode(&self) -> [u8; VALUE_LEN] { + let mut value = [0; VALUE_LEN]; + value[..32].copy_from_slice(&self.epoch_id); + value[32] = self.state as u8; + value[35..].copy_from_slice(&self.activation_hash); + value + } +} + +/// Why a reported reconnect value is not a supported Variant D encoding. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReestablishError { + /// The input was not exactly 67 bytes. + Length, + /// The state byte did not identify one of the seven specified states. + State, + /// Variant D requires `last_seq` to equal zero. + Sequence, +} + +impl fmt::Display for ReestablishError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Length => "FFOR reconnect value must be exactly 67 bytes", + Self::State => "unknown FFOR reconnect state", + Self::Sequence => "Variant D reconnect sequence must be zero", + }) + } +} + +impl std::error::Error for ReestablishError {} diff --git a/crates/ffor-protocol/tests/data/beignet-lifecycle.json b/crates/ffor-protocol/tests/data/beignet-lifecycle.json index fa1f5da1c1..10981d3c19 100644 --- a/crates/ffor-protocol/tests/data/beignet-lifecycle.json +++ b/crates/ffor-protocol/tests/data/beignet-lifecycle.json @@ -31,5 +31,42 @@ "signer": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c", "wire": "d70d010101010101010101010101010101010101010101010101010101010101010102020202020202020202020202020202020202020202020202020202020202020909090909090909090909090909090909090909090909090909090909090909000200010050a03c5bd10fffb8e15f81cdf2868320fcb86f235654c6f3ee60d56deda66a4e756c3f5f01cf83f9c15791f8f2df269167fd4a06f3477d8041ac2bf2079e0458" } + ], + "reestablish": [ + { + "state": 0, + "type": 55001, + "value": "02020202020202020202020202020202020202020202020202020202020202020000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "state": 1, + "type": 55001, + "value": "02020202020202020202020202020202020202020202020202020202020202020100000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "state": 2, + "type": 55001, + "value": "02020202020202020202020202020202020202020202020202020202020202020200000909090909090909090909090909090909090909090909090909090909090909" + }, + { + "state": 3, + "type": 55001, + "value": "02020202020202020202020202020202020202020202020202020202020202020300000909090909090909090909090909090909090909090909090909090909090909" + }, + { + "state": 4, + "type": 55001, + "value": "02020202020202020202020202020202020202020202020202020202020202020400000909090909090909090909090909090909090909090909090909090909090909" + }, + { + "state": 5, + "type": 55001, + "value": "02020202020202020202020202020202020202020202020202020202020202020500000909090909090909090909090909090909090909090909090909090909090909" + }, + { + "state": 6, + "type": 55001, + "value": "02020202020202020202020202020202020202020202020202020202020202020600000000000000000000000000000000000000000000000000000000000000000000" + } ] } diff --git a/crates/ffor-protocol/tests/data/generate_beignet_lifecycle.cjs b/crates/ffor-protocol/tests/data/generate_beignet_lifecycle.cjs index e70d4a2ce0..c3851ef1b2 100644 --- a/crates/ffor-protocol/tests/data/generate_beignet_lifecycle.cjs +++ b/crates/ffor-protocol/tests/data/generate_beignet_lifecycle.cjs @@ -38,4 +38,13 @@ const fixtures = cases.map(([name, type, unsigned]) => { if (!codecs.verifyFforMessage(type, body, publicKey)) throw new Error('Fixture authentication failed'); return { name, message_type: type, signer: publicKey.toString('hex'), wire: codecs.fforWireBytes(type, body).toString('hex') }; }); -fs.writeFileSync(process.argv[3], JSON.stringify({ source_revision: revision, fixtures }, null, 2) + '\n'); +const reestablish = Array.from({ length: 7 }, (_, state) => { + // Beignet retains hAct while ACTIVATING, even though section 11.1 says zero before ACTIVE. + // Preserve that input as an interoperability case, without treating it as activation proof. + const activationHash = Buffer.alloc(32, state >= 2 && state <= 5 ? 9 : 0); + const tlv = codecs.encodeFforReestablishTlv({ epochId: header.epochId, state, lastSeq: 0, activationHash }); + const decoded = codecs.decodeFforReestablishTlv(tlv.value); + if (decoded.state !== state || !decoded.activationHash.equals(activationHash)) throw new Error('Reconnect fixture mismatch'); + return { state, type: Number(tlv.type), value: tlv.value.toString('hex') }; +}); +fs.writeFileSync(process.argv[3], JSON.stringify({ source_revision: revision, fixtures, reestablish }, null, 2) + '\n'); diff --git a/crates/ffor-protocol/tests/reestablish.rs b/crates/ffor-protocol/tests/reestablish.rs new file mode 100644 index 0000000000..c35b0ec05d --- /dev/null +++ b/crates/ffor-protocol/tests/reestablish.rs @@ -0,0 +1,84 @@ +use ffor_protocol::reestablish::{ + Reestablish, ReestablishError, ReportedState, TLV_TYPE, VALUE_LEN, +}; +use proptest::prelude::*; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Reference { + source_revision: String, + reestablish: Vec, +} + +#[derive(Deserialize)] +struct Fixture { + state: u8, + r#type: u64, + value: String, +} + +#[test] +fn all_states_match_the_pinned_reference() { + let reference: Reference = + serde_json::from_str(include_str!("data/beignet-lifecycle.json")).unwrap(); + assert_eq!(reference.source_revision, "8aee31d18e596fe49a0d195b325a6e757d7a009b"); + assert_eq!(reference.reestablish.len(), 7); + for fixture in reference.reestablish { + assert_eq!(fixture.r#type, TLV_TYPE); + let bytes: Vec = (0..fixture.value.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&fixture.value[i..i + 2], 16).unwrap()) + .collect(); + let report = Reestablish::decode(&bytes).unwrap(); + assert_eq!(report.epoch_id, [2; 32]); + assert_eq!(report.state as u8, fixture.state); + assert_eq!(report.encode().as_slice(), bytes); + if report.state == ReportedState::Activating { + // This unsigned report is deliberately preserved, not accepted as activation evidence. + assert_eq!(report.activation_hash, [9; 32]); + } + } +} + +#[test] +fn every_length_and_unknown_state_boundary_is_rejected() { + let value = [0; VALUE_LEN]; + for length in 0..VALUE_LEN { + assert_eq!(Reestablish::decode(&value[..length]), Err(ReestablishError::Length)); + } + assert_eq!(Reestablish::decode(&[0; VALUE_LEN + 1]), Err(ReestablishError::Length)); + for state in 7..=255 { + let mut value = value; + value[32] = state; + assert_eq!(Reestablish::decode(&value), Err(ReestablishError::State)); + } +} + +#[test] +fn nonzero_sequence_is_never_variant_d_settlement_evidence() { + for sequence in 1_u16..=u16::MAX { + let mut value = [0; VALUE_LEN]; + value[32] = ReportedState::Active as u8; + value[33..35].copy_from_slice(&sequence.to_be_bytes()); + assert_eq!(Reestablish::decode(&value), Err(ReestablishError::Sequence)); + } +} + +proptest! { + #[test] + fn round_trip_preserves_all_report_bytes(epoch in any::<[u8; 32]>(), hash in any::<[u8; 32]>(), state in 0_u8..7) { + let mut bytes = [0; VALUE_LEN]; + bytes[..32].copy_from_slice(&epoch); + bytes[32] = state; + bytes[35..].copy_from_slice(&hash); + let report = Reestablish::decode(&bytes).unwrap(); + prop_assert_eq!(report.encode(), bytes); + } + + #[test] + fn arbitrary_inputs_never_panic(bytes in proptest::collection::vec(any::(), 0..256)) { + if let Ok(report) = Reestablish::decode(&bytes) { + prop_assert_eq!(report.encode().to_vec(), bytes); + } + } +} From a80c751e34607f1fcc9c2f875837095753fca994 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Sun, 20 Sep 2026 20:15:57 -0400 Subject: [PATCH 05/23] refactor(ffor): consume shared native protocol crate --- .github/workflows/ffor-protocol.yml | 38 -- Cargo.lock | 19 +- Cargo.toml | 3 +- FFOR.md | 47 ++ crates/ffor-protocol/Cargo.toml | 16 - crates/ffor-protocol/README.md | 149 ------ crates/ffor-protocol/fuzz/.gitignore | 5 - crates/ffor-protocol/fuzz/Cargo.toml | 23 - crates/ffor-protocol/fuzz/README.md | 22 - .../ffor-protocol/fuzz/fuzz_targets/wire.rs | 49 -- crates/ffor-protocol/fuzz/seed_corpus.py | 23 - crates/ffor-protocol/src/amounts.rs | 103 ---- crates/ffor-protocol/src/book.rs | 166 ------ crates/ffor-protocol/src/lib.rs | 17 - crates/ffor-protocol/src/reestablish.rs | 123 ----- crates/ffor-protocol/src/setup.rs | 316 ----------- crates/ffor-protocol/src/transcript.rs | 131 ----- crates/ffor-protocol/src/wire/codec.rs | 81 --- crates/ffor-protocol/src/wire/lifecycle.rs | 119 ----- crates/ffor-protocol/src/wire/mod.rs | 158 ------ crates/ffor-protocol/src/wire/setup.rs | 192 ------- crates/ffor-protocol/src/wire/tlv.rs | 168 ------ crates/ffor-protocol/src/wire/types.rs | 126 ----- crates/ffor-protocol/tests/amounts.rs | 127 ----- crates/ffor-protocol/tests/book.rs | 151 ------ .../ffor-protocol/tests/data/appendix-d.json | 92 ---- .../tests/data/beignet-lifecycle.json | 72 --- .../tests/data/extract_appendix_d.py | 94 ---- .../tests/data/generate_beignet_lifecycle.cjs | 50 -- crates/ffor-protocol/tests/reestablish.rs | 84 --- .../tests/reference_lifecycle.rs | 37 -- crates/ffor-protocol/tests/setup.rs | 337 ------------ crates/ffor-protocol/tests/transcript.rs | 161 ------ crates/ffor-protocol/tests/wire.rs | 502 ------------------ 34 files changed, 58 insertions(+), 3743 deletions(-) delete mode 100644 .github/workflows/ffor-protocol.yml create mode 100644 FFOR.md delete mode 100644 crates/ffor-protocol/Cargo.toml delete mode 100644 crates/ffor-protocol/README.md delete mode 100644 crates/ffor-protocol/fuzz/.gitignore delete mode 100644 crates/ffor-protocol/fuzz/Cargo.toml delete mode 100644 crates/ffor-protocol/fuzz/README.md delete mode 100644 crates/ffor-protocol/fuzz/fuzz_targets/wire.rs delete mode 100644 crates/ffor-protocol/fuzz/seed_corpus.py delete mode 100644 crates/ffor-protocol/src/amounts.rs delete mode 100644 crates/ffor-protocol/src/book.rs delete mode 100644 crates/ffor-protocol/src/lib.rs delete mode 100644 crates/ffor-protocol/src/reestablish.rs delete mode 100644 crates/ffor-protocol/src/setup.rs delete mode 100644 crates/ffor-protocol/src/transcript.rs delete mode 100644 crates/ffor-protocol/src/wire/codec.rs delete mode 100644 crates/ffor-protocol/src/wire/lifecycle.rs delete mode 100644 crates/ffor-protocol/src/wire/mod.rs delete mode 100644 crates/ffor-protocol/src/wire/setup.rs delete mode 100644 crates/ffor-protocol/src/wire/tlv.rs delete mode 100644 crates/ffor-protocol/src/wire/types.rs delete mode 100644 crates/ffor-protocol/tests/amounts.rs delete mode 100644 crates/ffor-protocol/tests/book.rs delete mode 100644 crates/ffor-protocol/tests/data/appendix-d.json delete mode 100644 crates/ffor-protocol/tests/data/beignet-lifecycle.json delete mode 100644 crates/ffor-protocol/tests/data/extract_appendix_d.py delete mode 100644 crates/ffor-protocol/tests/data/generate_beignet_lifecycle.cjs delete mode 100644 crates/ffor-protocol/tests/reestablish.rs delete mode 100644 crates/ffor-protocol/tests/reference_lifecycle.rs delete mode 100644 crates/ffor-protocol/tests/setup.rs delete mode 100644 crates/ffor-protocol/tests/transcript.rs delete mode 100644 crates/ffor-protocol/tests/wire.rs diff --git a/.github/workflows/ffor-protocol.yml b/.github/workflows/ffor-protocol.yml deleted file mode 100644 index e8438d29bf..0000000000 --- a/.github/workflows/ffor-protocol.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: FFOR protocol primitives - -on: - pull_request: - paths: - - 'crates/ffor-protocol/**' - - 'Cargo.toml' - - 'Cargo.lock' - - '.github/workflows/ffor-protocol.yml' - workflow_dispatch: - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - run: cargo fmt -p ffor-protocol -- --check - - run: cargo test -p ffor-protocol --locked - - run: cargo clippy -p ffor-protocol --all-targets --locked -- -D warnings - - fuzz: - runs-on: ubuntu-latest - timeout-minutes: 15 - defaults: - run: - working-directory: crates/ffor-protocol - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@nightly - - run: cargo install cargo-fuzz --version 0.13.2 --locked - - run: python3 fuzz/seed_corpus.py - - run: cargo fuzz run wire -- -max_len=131072 -max_total_time=30 diff --git a/Cargo.lock b/Cargo.lock index 1c3fb98faf..4946f23d57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1032,16 +1032,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "ffor-protocol" -version = "0.1.0" -dependencies = [ - "bitcoin", - "proptest", - "serde", - "serde_json", -] - [[package]] name = "filetime" version = "0.2.29" @@ -1934,6 +1924,7 @@ dependencies = [ "lightning 0.2.5", "lightning-background-processor 0.2.3", "lightning-block-sync 0.2.0", + "lightning-ffor", "lightning-invoice 0.34.1", "lightning-liquidity 0.2.3", "lightning-macros 0.2.1", @@ -2072,6 +2063,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "lightning-ffor" +version = "0.1.0" +source = "git+https://github.com/synonymdev/rust-lightning?rev=364d8f905743a2bd7a91c1757ef85454c1d258a6#364d8f905743a2bd7a91c1757ef85454c1d258a6" +dependencies = [ + "bitcoin", +] + [[package]] name = "lightning-invoice" version = "0.33.3" diff --git a/Cargo.toml b/Cargo.toml index 1df8187b07..249504a164 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [".", "crates/bdk-wallet-aggregate", "crates/ffor-protocol"] +members = [".", "crates/bdk-wallet-aggregate"] exclude = ["bindings/uniffi-bindgen"] [package] @@ -33,6 +33,7 @@ panic = 'abort' # Abort on panic default = [] [dependencies] +lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "364d8f905743a2bd7a91c1757ef85454c1d258a6" } lightning = { version = "0.2.0", features = ["std"] } lightning-types = { version = "0.3.0" } lightning-invoice = { version = "0.34.0", features = ["std"] } diff --git a/FFOR.md b/FFOR.md new file mode 100644 index 0000000000..b3a0024a3a --- /dev/null +++ b/FFOR.md @@ -0,0 +1,47 @@ +# Experimental offline receiving + +The integration target is Bitkit's current rust-lightning 0.2.5 baseline and +Blocktank's LND v0.21.3-beta baseline. Work is tracked in +[issue 117](https://github.com/synonymdev/ldk-node/issues/117). + +The shared protocol implementation lives in `lightning-ffor` in the +[Rust channel-engine draft](https://github.com/synonymdev/rust-lightning/pull/4). +Node pins an exact commit in Cargo.toml and Cargo.lock. Wire parsers, authenticated +setup derivation, transcript hashes, reference fixtures and fuzz tests belong to +that crate. Keeping one implementation lets Node and the channel engine use the +same validation rules. + +This dependency does not enable offline receiving. The current native bindings +and ordinary invoice behavior remain unchanged. The mobile draft providers expose +no production capability until authenticated activation, durable channel ownership +and recovery are implemented together. + +## Required runtime integration + +- Bind signed setup to the actual local identity, peer, chain and channel limits. +- Park a complete voucher book outside ordinary invoice and forwarding handling. +- Freeze the verified commitment pair through the real quiescence protocol. +- Persist exact activation and acknowledgement evidence before releasing either + acknowledgement or invoice readiness. Retain evidence after channel removal. +- Restore active epochs, reconcile reconnect state and fail setup safely after a + disconnect, timeout, partial round or storage failure. +- Connect settlement, slot accounting, witness/mailbox recovery and on-chain + protection before offering an offline invoice. +- Generate native bindings and connect the Android and iOS providers to the same + exact-amount, single-channel eligibility and invoice metadata contract. + +## Trust boundaries and validation + +Peer messages, invoices, reconnect reports and local crash recovery are separate +inputs. A valid signature proves message authorship, not current channel state or +successful storage. Eligibility requires the complete native state transition, +not a parser result or cached liquidity estimate. Application code must not export +node keys or manually assert that a commitment is safe. + +The shared crate has reference-vector, property, malformed-message and seeded fuzz +coverage. Native channel and storage tests cover real commitment rounds, delayed +monitor completion, parking, abort, reload and compatibility with older readers. +Local LND tests exercise actual commitment evidence and atomic database guards. +These tests do not establish end-to-end offline interoperability. Regtest process +crashes, link reconnection, chain enforcement and physical mobile device lifecycle +tests remain necessary before enabling the feature. diff --git a/crates/ffor-protocol/Cargo.toml b/crates/ffor-protocol/Cargo.toml deleted file mode 100644 index 907070f8cc..0000000000 --- a/crates/ffor-protocol/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "ffor-protocol" -version = "0.1.0" -edition = "2021" -rust-version = "1.85" -license = "MIT OR Apache-2.0" -description = "Experimental FFOR Variant D arithmetic and transcript primitives" -publish = false - -[dependencies] -bitcoin = "0.32.7" - -[dev-dependencies] -proptest = "1.0.0" -serde = { version = "1.0.210", features = ["derive"] } -serde_json = "1.0.128" diff --git a/crates/ffor-protocol/README.md b/crates/ffor-protocol/README.md deleted file mode 100644 index 6ffef45ad2..0000000000 --- a/crates/ffor-protocol/README.md +++ /dev/null @@ -1,149 +0,0 @@ -# FFOR protocol foundation - -This unpublished crate implements checked Variant D amount and anchor-channel book -calculations, signed setup and lifecycle codecs, reconnect reports, canonical books -and authenticated transcript checks from draft v0.9.4. It is not connected to `Node`, the payment -handlers, custom peer messages or UniFFI. -It cannot prepare an offline invoice or receive a payment. - -Work is tracked in [ldk-node #117](https://github.com/synonymdev/ldk-node/issues/117). - -## Channel-engine baseline - -The receiver port targets rust-lightning **v0.2.5**, pinned to -[`5bc1dc84b3a1b084f84de4b7ece3d978b678d894`](https://github.com/lightningdevkit/rust-lightning/commit/5bc1dc84b3a1b084f84de4b7ece3d978b678d894). -This retains the current LDK Node dependency generation and its maintenance fixes. -The Synonym fork's `main` snapshot uses `0.3.0+git` and requires a separate API -migration. It is not the baseline for this port. - -## Reference and verification scope - -The normative source is FFOR at -[`d719161f42d1eeb6bd6c3856d564222f03c2205e`](https://github.com/coreyphillips/ffor/tree/d719161f42d1eeb6bd6c3856d564222f03c2205e). -`tests/data/appendix-d.json` contains the public Appendix D transcript fixtures. -The extraction script reconstructs the abbreviated D.5 messages and book using the -published deterministic inputs and existing signatures, then checks the published -wire hashes and book hash before writing the fixture. No signing keys are needed. - -```sh -python3 crates/ffor-protocol/tests/data/extract_appendix_d.py /path/to/ffor/ffor-variant-d-vectors.md -cargo test -p ffor-protocol --locked -cargo clippy -p ffor-protocol --all-targets --locked -- -D warnings -cargo fmt -p ffor-protocol -- --check -``` - -Tests cover the six published transcript scenarios, including the 483-slot book, -verification of their public node signatures, both funder roles, both commitment -dust limits, an unfunded receiver, exact budgets, fee-spike reserves, negotiated -limits, deadlines, overflow and public-channel fee selection. Property tests compare -arithmetic with a wide-integer oracle and mutate budgets, amounts and transcript -domains. They do not construct or broadcast commitment transactions. - -The wire parser supports `ff_init`, `ff_accept`, `ff_activate`, `ff_activate_ack`, -`ff_abort`, `ff_close` and `ff_close_ack`. It bounds message sizes and collection -counts, rejects noncanonical BigSize values, unknown mandatory TLVs and invalid -compressed points, and preserves unknown optional fields in the signed bytes. -Low-S compact signatures are verified against expected channel peer identities. -Unsigned digest construction does not require a placeholder valid signature. - -`AuthenticatedSetup` derives the book exclusively from authenticated setup messages. -It checks fee bounds, unique hashes, requested hash chains, activation transcripts, -height agreement and close preimages, including prefix settlement for chained books. -It is immutable protocol data, not an epoch state machine or proof of live capacity. - -`tests/data/beignet-lifecycle.json` adds five signed reference lifecycle fixtures, -including both absent and explicitly empty preimage TLVs when no payment settled. -Regenerate them with `generate_beignet_lifecycle.cjs` against the pinned Beignet -checkout, using that checkout's `ts-node/register`. Both encodings are preserved -exactly so authentication never depends on normalizing received signed bytes. - -The same fixture file includes all seven reconnect states from Beignet. The -`reestablish` module reads the exact 67-byte TLV 55001 value and rejects unknown -states and nonzero Variant D sequences. It preserves the peer's reported hash as -untrusted data. The pinned Beignet sender includes its computed hash in `ACTIVATING`, -although section 11.1 specifies zeros before `ACTIVE`; local engine writers must -follow the specification. Neither form proves activation without the signed ack. - -The standalone [fuzz target](fuzz/README.md) exercises parsing, canonical round trips, -signatures and authenticated setup with real signed fixture seeds. A bounded local -run completed 6,359,059 inputs in 121 seconds without a crash; a run including the -reconnect parser completed 2,118,228 inputs in 31 seconds without a crash. The current -suite has 66 tests and four doctests. Nightly LLVM instrumentation measured 865/892 -source lines (96.97%) and 198/200 branches (99.00%) covered across this crate. Uncovered -code includes diagnostic formatting and defensive paths whose preconditions are -excluded by prior validated bounds. These are measured results, not exhaustive -proofs of correctness. - -There is no signer, persistence implementation or channel state machine in this -crate. Crash injection, monitor recovery and cross-engine regtest remain requirements -for the engine port. Passing pure protocol tests is not evidence of offline payment -settlement or recovery. - -## Implementation references - -The receiver and settlement port should be compared with these immutable source -revisions, in addition to the normative specification: - -- [Beignet 0.21.10, `8aee31d18e596fe49a0d195b325a6e757d7a009b`](https://github.com/coreyphillips/beignet/tree/8aee31d18e596fe49a0d195b325a6e757d7a009b). - `src/lightning/channel/channel.ts` owns voucher matching, both-view commitment - verification, activation, freeze and drain. `src/lightning/ffor/` contains the - wire, transcript and witness code. The Variant D setup and settlement tests - cover acknowledgement loss, restart, rejected updates and cooperative return. -- [beignet-umbrel, `12d483462ca2eabd8ae9cc105e69affa420459f9`](https://github.com/coreyphillips/beignet-umbrel/tree/12d483462ca2eabd8ae9cc105e69affa420459f9). - `manager/ui/src/pages/tabs/ReceiveTab.jsx` and the receive routes demonstrate - explicit offline intent, stable request identity, existing-channel capacity - and rejecting a response that is not explicitly offline-capable. The - `scripts/lfbw-regtest/` FFOR scenarios exercise process-stopped receiving and - return through the manager and daemon APIs. - -These sources implement their own channel engine. They do not supply FFOR APIs -to rust-lightning or LND. Port the protocol invariants into each engine's own -commitment and persistence boundary. Do not copy deployment defaults such as -channel headroom or invoice lifetime into Bitkit as protocol guarantees. Passing -reference tests alone cannot qualify the native port. - -## Trust boundaries - -- Amounts, peer data and public policy are untrusted. Overflow, underpayment, - overpayment and inconsistent budgets must fail before committing vouchers. -- Only the channel engine may supply channel type, negotiated limits, balances, - funder identity and dust limits. Application aggregate inbound liquidity is not - sufficient. Existing ordinary HTLCs must be drained before book validation. -- A public fee exception requires an authenticated local public channel to the - correct receiver, all four announcement signatures, correct node/funding-key - bindings and the onion's actual SCID. This crate only compares fee amounts. -- Hash functions operate on public transcript bytes and authenticate nothing on - their own. Callers must validate canonical encodings and low-S signatures. The - existing signer must retain all private keys. -- A valid book is a proposed reservation, not received money. Neither amount checks - nor matching transcript hashes imply durable activation or invoice readiness. - -There is no new unsafe code, secret storage, nonce generation or custom crypto. -SHA256 and signature verification use the existing Bitcoin dependency and its -secp256k1 implementation. No production signing API is introduced. - -## Required integration - -The registry `lightning 0.2.5` used by ldk-node has no FFOR receiver support. -[rust-lightning #4](https://github.com/synonymdev/rust-lightning/pull/4) adds a -point-in-time verifier for actual committed vouchers and monitor claim signatures -on that baseline. It is not wired into this crate. The next engine changes must -own voucher recognition, both-view commitment completion, -persistent ACTIVE freeze, activation acknowledgement replay, cooperative drain and -on-chain enforcement inside rust-lightning. A second channel state machine in -ldk-node would duplicate signing authority and is not an acceptable substitute. - -After that integration, ldk-node must provide exact-amount eligibility, durable and -retry-safe preparation, invoice exposure, recovery and status through generated -Kotlin/Swift bindings. Readiness must require the agreed witness acknowledgements. -The Bitkit provider adapters must only be enabled after these real APIs exist and -the interoperability tests demonstrate payer success with the receiver stopped. - -The first app profile is a positive fixed amount in whole satoshis, single-part -BOLT 11, one eligible anchor channel, with a `Receive Offline` checkbox. Amountless -invoices, just-in-time liquidity and aggregate multi-channel capacity cannot qualify. -The node must enforce the selected offline window, claim margin, chain watching and -fee funding. No production window or witness deployment is chosen by this crate. - -The requested settlement baseline is LND v0.21.3-beta. LND implementation work is -kept local. No native dependency, binding version or release is changed here. diff --git a/crates/ffor-protocol/fuzz/.gitignore b/crates/ffor-protocol/fuzz/.gitignore deleted file mode 100644 index a0e83e5bfa..0000000000 --- a/crates/ffor-protocol/fuzz/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -/target/ -/corpus/ -/artifacts/ -/coverage/ -/Cargo.lock diff --git a/crates/ffor-protocol/fuzz/Cargo.toml b/crates/ffor-protocol/fuzz/Cargo.toml deleted file mode 100644 index 5cb8dafb1e..0000000000 --- a/crates/ffor-protocol/fuzz/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "ffor-protocol-fuzz" -version = "0.0.0" -publish = false -edition = "2021" - -[package.metadata] -cargo-fuzz = true - -[dependencies] -libfuzzer-sys = "0.4" -bitcoin = "0.32.7" -ffor-protocol = { path = ".." } - -[workspace] -members = ["."] - -[[bin]] -name = "wire" -path = "fuzz_targets/wire.rs" -test = false -doc = false -bench = false diff --git a/crates/ffor-protocol/fuzz/README.md b/crates/ffor-protocol/fuzz/README.md deleted file mode 100644 index 8cc2d0c63b..0000000000 --- a/crates/ffor-protocol/fuzz/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Wire parser and setup fuzzing - -The `wire` target checks the 65,535-byte envelope bound, canonical decode/encode round trips, -signature verification using the Appendix D public keys, and authenticated setup construction. -It accepts either one raw message or `[u16 init_length][init][accept]` so valid signed setup -pairs can seed the authenticated boundary. It contains no signing operation or private keys. - -From this directory, run: - -```sh -python3 seed_corpus.py -cargo +nightly fuzz run wire -- -max_len=131072 -max_total_time=60 -``` - -The seed script writes the six Appendix D scenarios' messages and length-prefixed setup -pairs from `../tests/data/appendix-d.json`, plus the Beignet lifecycle fixtures. Generated -corpora, crash artifacts, coverage data and the standalone workspace lockfile are ignored. - -A bounded run detects regressions but does not prove exhaustive parser coverage. The parent -crate's property tests exercise bounded arbitrary bytes, extension lengths, and settled -bitmaps independently of this target. Live network partitions, persistence and channel -transitions are outside this pure codec and require later engine integration tests. diff --git a/crates/ffor-protocol/fuzz/fuzz_targets/wire.rs b/crates/ffor-protocol/fuzz/fuzz_targets/wire.rs deleted file mode 100644 index ae2752744c..0000000000 --- a/crates/ffor-protocol/fuzz/fuzz_targets/wire.rs +++ /dev/null @@ -1,49 +0,0 @@ -#![no_main] - -use bitcoin::secp256k1::PublicKey; -use ffor_protocol::reestablish::Reestablish; -use ffor_protocol::setup::AuthenticatedSetup; -use ffor_protocol::wire::{Message, MAX_MESSAGE_LEN}; -use libfuzzer_sys::fuzz_target; - -fn keys() -> (PublicKey, PublicKey) { - let receiver = [ - 3, 159, 202, 127, 129, 87, 170, 118, 135, 8, 137, 79, 253, 146, 85, 15, 233, 112, 237, 209, - 133, 38, 165, 249, 54, 88, 62, 163, 181, 77, 171, 50, 40, - ]; - let settlement = [ - 2, 8, 123, 125, 27, 71, 137, 23, 15, 110, 55, 79, 10, 14, 88, 161, 183, 168, 153, 227, 73, - 41, 121, 83, 20, 171, 105, 100, 230, 150, 9, 233, 192, - ]; - (PublicKey::from_slice(&receiver).unwrap(), PublicKey::from_slice(&settlement).unwrap()) -} - -fuzz_target!(|data: &[u8]| { - if data.len() > 2 * MAX_MESSAGE_LEN + 2 { - return; - } - let (receiver, settlement) = keys(); - if let Ok(report) = Reestablish::decode(data) { - assert_eq!(report.encode().as_slice(), data); - } - if let Ok(message) = Message::decode(data) { - assert_eq!(message.encode().unwrap(), data); - let _ = message.verify_signature(&receiver); - let _ = message.verify_signature(&settlement); - } - // Two-byte first-message length also permits real signed setup pairs in the corpus. - if data.len() < 2 { - return; - } - let split = usize::from(u16::from_be_bytes([data[0], data[1]])); - let Some(first) = data.get(2..2 + split) else { - return; - }; - let Some(second) = data.get(2 + split..) else { - return; - }; - if let (Ok(init), Ok(accept)) = (Message::decode(first), Message::decode(second)) { - let _ = AuthenticatedSetup::new(&init, &accept, receiver, settlement); - let _ = AuthenticatedSetup::new(&init, &accept, settlement, receiver); - } -}); diff --git a/crates/ffor-protocol/fuzz/seed_corpus.py b/crates/ffor-protocol/fuzz/seed_corpus.py deleted file mode 100644 index 5eb982898e..0000000000 --- a/crates/ffor-protocol/fuzz/seed_corpus.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Seed protocol fuzzing with pinned public signed fixtures, without a signing key.""" - -import json -from pathlib import Path - -crate = Path(__file__).resolve().parents[1] -corpus = crate / "fuzz" / "corpus" / "wire" -corpus.mkdir(parents=True, exist_ok=True) -fixtures = json.loads((crate / "tests/data/appendix-d.json").read_text()) -for index, fixture in enumerate(fixtures): - for field in ("init_wire", "accept_wire", "activate_wire", "ack_wire"): - (corpus / f"appendix-{index}-{field}").write_bytes(bytes.fromhex(fixture[field])) - init = bytes.fromhex(fixture["init_wire"]) - accept = bytes.fromhex(fixture["accept_wire"]) - (corpus / f"appendix-{index}-pair").write_bytes(len(init).to_bytes(2, "big") + init + accept) - -reference = json.loads((crate / "tests/data/beignet-lifecycle.json").read_text()) -for fixture in reference["fixtures"]: - (corpus / fixture["name"]).write_bytes(bytes.fromhex(fixture["wire"])) -for fixture in reference["reestablish"]: - (corpus / f"reestablish-{fixture['state']}").write_bytes(bytes.fromhex(fixture["value"])) - -print("Seeded 35 signed reference messages/setup pairs and 7 reconnect reports") diff --git a/crates/ffor-protocol/src/amounts.rs b/crates/ffor-protocol/src/amounts.rs deleted file mode 100644 index 52606d2ec1..0000000000 --- a/crates/ffor-protocol/src/amounts.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! Checked fixed-amount arithmetic from FFOR v0.9.4 section 7.6. - -use std::fmt; - -const MILLION: u64 = 1_000_000; - -/// Invalid amount arithmetic. Errors must prevent setup or settlement. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum AmountError { - /// A fixed-amount voucher cannot have a zero value. - ZeroAmount, - /// A multiplication, fee, or gross amount exceeds the protocol's u64 bound. - Overflow, - /// A plaintext hop attempts to credit a different amount than the signed voucher. - WrongPayeeAmount, - /// The upstream HTLC does not cover the forwarding amount and required fee. - InsufficientFee, -} - -impl fmt::Display for AmountError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match self { - Self::ZeroAmount => "FFOR voucher amount must be positive", - Self::Overflow => "FFOR amount exceeds u64", - Self::WrongPayeeAmount => "forwarding amount differs from voucher amount", - Self::InsufficientFee => "incoming amount does not cover forwarding fee", - }) - } -} - -impl std::error::Error for AmountError {} - -/// BOLT 7 forwarding terms, applied to the voucher's payee amount in millisatoshis. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct FeePolicy { - /// Fixed forwarding fee in millisatoshis. - pub base_msat: u32, - /// Proportional fee in millionths of the payee amount. - pub proportional_millionths: u32, -} - -impl FeePolicy { - /// Calculate the fee without reducing the voucher amount. - /// - /// Section 7.6 rejects a product exceeding u64 even if division would make it fit. - /// This check must also run during setup, before committing the voucher. - /// - /// ``` - /// use ffor_protocol::amounts::FeePolicy; - /// let fees = FeePolicy { base_msat: 1_000, proportional_millionths: 5_000 }; - /// assert_eq!(fees.fee_msat(1_000_000), Ok(6_000)); - /// ``` - pub fn fee_msat(self, payee_msat: u64) -> Result { - let product = payee_msat - .checked_mul(u64::from(self.proportional_millionths)) - .ok_or(AmountError::Overflow)?; - (product / MILLION).checked_add(u64::from(self.base_msat)).ok_or(AmountError::Overflow) - } - - /// Calculate the book-priced upstream amount with all protocol overflow checks. - pub fn gross_msat(self, payee_msat: u64) -> Result { - payee_msat.checked_add(self.fee_msat(payee_msat)?).ok_or(AmountError::Overflow) - } -} - -/// Check the two plaintext amount conditions for a Variant D payment. -/// -/// `qualifying_public_policy` may only be supplied after the engine verifies section 7.6's -/// public channel announcement, both node/funding-key bindings and the onion's actual SCID. -/// Private channels, aliases and unverified announcements require `None`. This function -/// cannot authenticate channel gossip. Never use it for blinded payments. -/// -/// Successful amount checks do not authorize preimage release: the channel engine must also -/// verify the slot, durable epoch, deadlines and irrevocable upstream HTLC before settlement. -pub fn check_plaintext_amount( - payee_msat: u64, forward_msat: u64, incoming_msat: u64, book_policy: FeePolicy, - qualifying_public_policy: Option, -) -> Result<(), AmountError> { - if payee_msat == 0 { - return Err(AmountError::ZeroAmount); - } - if forward_msat != payee_msat { - return Err(AmountError::WrongPayeeAmount); - } - let book_fee = book_policy.gross_msat(payee_msat)? - payee_msat; - // Compare complete policies. Mixing the cheapest base and rate would undercharge. - let required_fee = match qualifying_public_policy { - Some(public) => { - // The setup product restriction applies to the signed book. A later public policy - // increase must never prevent settlement at the valid book fee. - let public_fee = u128::from(public.base_msat) - + u128::from(payee_msat) * u128::from(public.proportional_millionths) - / u128::from(MILLION); - u128::from(book_fee).min(public_fee) as u64 - }, - None => book_fee, - }; - let paid_fee = incoming_msat.checked_sub(forward_msat).ok_or(AmountError::InsufficientFee)?; - if paid_fee < required_fee { - return Err(AmountError::InsufficientFee); - } - Ok(()) -} diff --git a/crates/ffor-protocol/src/book.rs b/crates/ffor-protocol/src/book.rs deleted file mode 100644 index 052b5c9e89..0000000000 --- a/crates/ffor-protocol/src/book.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! Arithmetic eligibility for ECDSA zero-fee-HTLC anchor channels. -//! -//! The caller must derive every limit from the same current channel state, with no other -//! pending HTLCs. This module does not establish negotiated support or sign commitments. - -use std::fmt; - -use crate::amounts::{AmountError, FeePolicy}; - -/// BOLT 2 upper bound, not a promise of available channel capacity. -pub const MAX_VOUCHERS: usize = 483; -const ANCHOR_COMMITMENT_WEIGHT: u64 = 1124; -const HTLC_OUTPUT_WEIGHT: u64 = 172; -const BOTH_ANCHORS_SAT: u64 = 660; - -/// Proposed fixed-amount book. All amounts are millisatoshis and deadlines are block heights. -#[derive(Clone, Debug)] -pub struct BookTerms { - /// Exact amount of every voucher, in hash-set order. - pub amounts_msat: Vec, - /// Signed sum of all voucher amounts, excluding forwarding fees. - pub budget_msat: u64, - /// Signed minimum voucher amount. - pub minimum_payment_msat: u64, - /// Signed forwarding policy. - pub fees: FeePolicy, - /// Last height at which the peer can admit delegated payments. - pub settlement_deadline: u32, - /// Uniform absolute HTLC expiry. - pub voucher_expiry: u32, -} - -/// Current limits for a channel already verified to use zero-fee-HTLC anchors. -/// -/// These values must come from the channel engine, not application balances or peer claims. -/// Normal pending HTLCs must be drained before testing a new Variant D book. -#[derive(Clone, Copy, Debug)] -pub struct AnchorChannelLimits { - /// Receiver's negotiated maximum accepted HTLC count. - pub max_accepted_htlcs: u16, - /// Receiver's negotiated maximum in-flight value in millisatoshis. - pub max_in_flight_msat: u64, - /// Minimum HTLC amount for offers from settlement peer to receiver. - pub htlc_minimum_msat: u64, - /// Dust limit in the receiver's commitment view. - pub receiver_dust_sat: u64, - /// Dust limit in the settlement peer's commitment view. - pub settlement_dust_sat: u64, - /// Settlement peer's balance before the voucher round, before commitment fee deduction. - pub settlement_balance_msat: u64, - /// Receiver's balance before the voucher round, before commitment fee deduction. - pub receiver_balance_msat: u64, - /// Reserve the settlement peer must retain in satoshis. - pub settlement_reserve_sat: u64, - /// Reserve the receiver must retain in satoshis. - pub receiver_reserve_sat: u64, - /// Whether the settlement peer funds the channel and pays commitment fees. - pub settlement_is_funder: bool, - /// Frozen commitment feerate in satoshis per 1000 weight units. - pub feerate_sat_per_kw: u32, -} - -/// An arithmetic condition preventing voucher setup. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum BookError { - /// Empty book, protocol count exceeded, or negotiated count exceeded. - SlotCount, - /// A zero amount, signed minimum violation, or channel HTLC minimum violation. - MinimumAmount, - /// At least one voucher would be trimmed in a commitment view. - Dust, - /// The signed budget differs from the voucher sum. - BudgetMismatch, - /// The voucher sum exceeds the receiver's negotiated in-flight bound. - InFlightLimit, - /// Admission has already ended, or the required claim margin does not fit. - Deadline, - /// The settlement peer cannot fund the book and retain its reserve. - SettlementReserve, - /// The funder cannot retain its reserve, both anchors and the doubled-feerate buffer. - FeeReserve, - /// A required sum, product, or upstream amount exceeds its protocol bound. - Overflow, -} - -impl fmt::Display for BookError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "ineligible FFOR voucher book: {self:?}") - } -} - -impl std::error::Error for BookError {} - -impl From for BookError { - fn from(_: AmountError) -> Self { - Self::Overflow - } -} - -/// Validate the arithmetic conditions for a proposed Variant D anchor-channel book. -/// -/// `claim_margin_blocks` is a deployment policy, not an assumed offline window. Section 7.1 -/// recommends at least 1008 blocks. The caller must additionally validate channel delay, -/// chain watching, negotiated variant, signatures, unique hashes, HTLC identities, actual -/// commitment outputs, and persistence. At activation, recheck these conditions using the -/// retained pre-round balances and current policy. Do not pass post-round balances here: -/// this function subtracts the book budget, which would debit the vouchers twice. Actual -/// committed outputs and remaining balances require separate engine validation. -/// -/// This function has no side effects and never means an offline invoice is ready. -pub fn validate_anchor_book( - terms: &BookTerms, channel: &AnchorChannelLimits, current_height: u32, claim_margin_blocks: u32, -) -> Result<(), BookError> { - let count = terms.amounts_msat.len(); - if count == 0 || count > MAX_VOUCHERS || count > usize::from(channel.max_accepted_htlcs) { - return Err(BookError::SlotCount); - } - let required_expiry = terms.settlement_deadline.checked_add(claim_margin_blocks); - if terms.settlement_deadline <= current_height - || claim_margin_blocks == 0 - || required_expiry.is_none_or(|expiry| terms.voucher_expiry < expiry) - { - return Err(BookError::Deadline); - } - let mut total_msat = 0_u64; - for &amount in &terms.amounts_msat { - if amount == 0 || amount < terms.minimum_payment_msat || amount < channel.htlc_minimum_msat - { - return Err(BookError::MinimumAmount); - } - if amount / 1000 < channel.receiver_dust_sat.max(channel.settlement_dust_sat) { - return Err(BookError::Dust); - } - terms.fees.gross_msat(amount)?; - total_msat = total_msat.checked_add(amount).ok_or(BookError::Overflow)?; - } - if total_msat != terms.budget_msat { - return Err(BookError::BudgetMismatch); - } - if total_msat > channel.max_in_flight_msat { - return Err(BookError::InFlightLimit); - } - let settlement_after_msat = channel - .settlement_balance_msat - .checked_sub(total_msat) - .ok_or(BookError::SettlementReserve)?; - if settlement_after_msat / 1000 < channel.settlement_reserve_sat { - return Err(BookError::SettlementReserve); - } - let (funder_after_sat, funder_reserve_sat) = if channel.settlement_is_funder { - (settlement_after_msat / 1000, channel.settlement_reserve_sat) - } else { - (channel.receiver_balance_msat / 1000, channel.receiver_reserve_sat) - }; - // Count is bounded by 483 and feerate by u32, so this u64 product cannot overflow. - let weight = ANCHOR_COMMITMENT_WEIGHT + HTLC_OUTPUT_WEIGHT * count as u64; - let spike_fee_sat = u64::from(channel.feerate_sat_per_kw) * 2 * weight / 1000; - let required_sat = funder_reserve_sat - .checked_add(spike_fee_sat) - .and_then(|amount| amount.checked_add(BOTH_ANCHORS_SAT)) - .ok_or(BookError::Overflow)?; - if funder_after_sat < required_sat { - return Err(BookError::FeeReserve); - } - Ok(()) -} diff --git a/crates/ffor-protocol/src/lib.rs b/crates/ffor-protocol/src/lib.rs deleted file mode 100644 index f2402b50fd..0000000000 --- a/crates/ffor-protocol/src/lib.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! Pure primitives for the draft FFOR Variant D protocol. -//! -//! These primitives do not implement a receiver or settlement peer. In particular, successful -//! arithmetic, signature verification or transcript construction is not proof of channel -//! commitments, durable activation, or readiness to expose an offline invoice. -//! -//! The channel engine must retain sole ownership of commitment transitions and signing keys. - -#![forbid(unsafe_code)] -#![deny(missing_docs)] - -pub mod amounts; -pub mod book; -pub mod reestablish; -pub mod setup; -pub mod transcript; -pub mod wire; diff --git a/crates/ffor-protocol/src/reestablish.rs b/crates/ffor-protocol/src/reestablish.rs deleted file mode 100644 index de8dcf69ba..0000000000 --- a/crates/ffor-protocol/src/reestablish.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! The Variant D value carried by `channel_reestablish` TLV 55001 (FFOR section 11.1). -//! -//! This is an unsigned peer report, not evidence of activation or payment. The channel engine -//! must compare it with its durable epoch and authenticate any replayed acknowledgement. In -//! particular, Variant D's zero sequence number never permits discarding an active epoch. - -use std::fmt; - -use crate::transcript::Digest; - -/// The TLV type within a standard BOLT 2 `channel_reestablish` message. -pub const TLV_TYPE: u64 = 55001; -/// The exact value length, excluding the enclosing TLV type and length. -pub const VALUE_LEN: usize = 67; - -/// A reported lifecycle phase, without authority to perform a channel transition. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[repr(u8)] -pub enum ReportedState { - /// Setup has started but its vouchers are not yet committed. - Negotiating = 0, - /// The complete book exists in both commitment views. - VouchersCommitted = 1, - /// The receiver is waiting for the signed activation acknowledgement. - Activating = 2, - /// The reporting peer says it durably accepted activation. - Active = 3, - /// The reporting peer says it accepted the close acknowledgement. - Draining = 4, - /// All vouchers have been irrevocably resolved. - Closed = 5, - /// Setup was aborted before activation. - Aborted = 6, -} - -/// The reported epoch and phase on reconnect. -/// -/// No signature is included here. Parsing preserves the reported hash even in a pre-active -/// state, where it has no activation authority. This also permits reading the pinned Beignet -/// implementation, which retains its computed hash while reporting `Activating`. -/// -/// Locally generated reports must use a zero hash before `Active`, as required by section 11.1. -/// `last_seq` is not exposed because Variant D always encodes zero. Ordinary BOLT 2 commitment -/// and revocation counters still need their normal validation by the channel engine. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Reestablish { - /// The epoch being reported, scoped by the enclosing message's channel ID. - pub epoch_id: Digest, - /// The peer's reported lifecycle state. - pub state: ReportedState, - /// Reported activation digest, meaningful only with appropriate authenticated evidence. - pub activation_hash: Digest, -} - -impl Reestablish { - /// Decode one exact TLV value. Reject truncation, trailing bytes, unknown states and A/B sequences. - /// - /// This function allocates nothing and does not authorize any channel transition. - pub fn decode(value: &[u8]) -> Result { - let value: &[u8; VALUE_LEN] = value.try_into().map_err(|_| ReestablishError::Length)?; - let state = match value[32] { - 0 => ReportedState::Negotiating, - 1 => ReportedState::VouchersCommitted, - 2 => ReportedState::Activating, - 3 => ReportedState::Active, - 4 => ReportedState::Draining, - 5 => ReportedState::Closed, - 6 => ReportedState::Aborted, - _ => return Err(ReestablishError::State), - }; - if value[33..35] != [0, 0] { - return Err(ReestablishError::Sequence); - } - let mut epoch_id = [0; 32]; - epoch_id.copy_from_slice(&value[..32]); - let mut activation_hash = [0; 32]; - activation_hash.copy_from_slice(&value[35..]); - Ok(Self { epoch_id, state, activation_hash }) - } - - /// Encode this report exactly, with Variant D's zero sequence number. - /// - /// Use only engine-owned durable state to construct an outbound report. For an active receiver, - /// a missing or conflicting peer report is not permission to discard vouchers or lift the freeze. - /// - /// ``` - /// use ffor_protocol::reestablish::{Reestablish, ReportedState}; - /// let report = Reestablish { - /// epoch_id: [1; 32], state: ReportedState::Activating, activation_hash: [0; 32], - /// }; - /// assert_eq!(Reestablish::decode(&report.encode()).unwrap(), report); - /// ``` - pub fn encode(&self) -> [u8; VALUE_LEN] { - let mut value = [0; VALUE_LEN]; - value[..32].copy_from_slice(&self.epoch_id); - value[32] = self.state as u8; - value[35..].copy_from_slice(&self.activation_hash); - value - } -} - -/// Why a reported reconnect value is not a supported Variant D encoding. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ReestablishError { - /// The input was not exactly 67 bytes. - Length, - /// The state byte did not identify one of the seven specified states. - State, - /// Variant D requires `last_seq` to equal zero. - Sequence, -} - -impl fmt::Display for ReestablishError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match self { - Self::Length => "FFOR reconnect value must be exactly 67 bytes", - Self::State => "unknown FFOR reconnect state", - Self::Sequence => "Variant D reconnect sequence must be zero", - }) - } -} - -impl std::error::Error for ReestablishError {} diff --git a/crates/ffor-protocol/src/setup.rs b/crates/ffor-protocol/src/setup.rs deleted file mode 100644 index ada394387b..0000000000 --- a/crates/ffor-protocol/src/setup.rs +++ /dev/null @@ -1,316 +0,0 @@ -//! Authenticated Variant D setup transcripts and their canonical voucher books. -//! -//! These are immutable protocol data, not a second channel state machine. Construction checks -//! node signatures and signed agreement. Live capacity, revealed commitment secrets, voucher -//! parking, persisted claim material, freeze and invoice exposure remain engine responsibilities. - -use std::collections::BTreeSet; -use std::fmt; - -use bitcoin::hashes::{sha256, Hash}; -use bitcoin::secp256k1::PublicKey; - -use crate::amounts::FeePolicy; -use crate::book::BookTerms; -use crate::transcript::{self, Digest}; -use crate::wire::{Header, Init, Message, Payload, WireError}; - -/// A signed statement is inconsistent with the agreed setup or expected peer. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SetupError { - /// The envelope is invalid or its expected peer's signature does not verify. - Wire(WireError), - /// Receiver and settlement peer must be distinct authenticated nodes. - Roles, - /// This operation received the wrong message type. - MessageType, - /// The channel or epoch identifier differs from the authenticated setup. - Identity, - /// A payment hash was assigned to more than one voucher. - DuplicateHash, - /// Requested chained vouchers do not satisfy the signed hash-chain relation. - HashChain, - /// A signed voucher's fee product or upstream gross amount exceeds u64. - FeeOverflow, - /// The claimed transcript, book, activation or actual commitment digest differs. - Transcript, - /// The activation height is too far from the local tip or admission has ended. - Height, - /// A close acknowledgement does not describe exactly this book's slots and hashes. - CloseBook, -} - -impl fmt::Display for SetupError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "invalid FFOR setup statement: {self:?}") - } -} - -impl std::error::Error for SetupError {} - -impl From for SetupError { - fn from(error: WireError) -> Self { - Self::Wire(error) - } -} - -/// One entry in an authenticated, fixed-amount Variant D book. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Voucher { - /// One-based position in the signed payment hash list. - pub slot: u16, - /// Settlement-generated hash; no preimage is part of setup. - pub payment_hash: [u8; 32], - /// Exact promised payee amount, with forwarding fees charged separately. - pub amount_msat: u64, - /// Absolute HTLC expiry shared by the entire book. - pub expiry: u32, - /// Admission deadline shared by the entire book. - pub deadline: u32, - /// Settlement peer's actual outgoing HTLC identifier to use for this voucher. - pub htlc_id: u64, -} - -/// Immutable signed agreement and canonical book derived exclusively from init and accept. -/// -/// The public messages used to construct this value are copied. Mutating them afterwards cannot -/// alter its hashes or vouchers. This value neither owns a channel nor authorizes activation. -#[derive(Clone, Debug)] -pub struct AuthenticatedSetup { - init: Message, - accept: Message, - receiver: PublicKey, - settlement: PublicKey, - vouchers: Vec, - setup_hash: Digest, - book_hash: Digest, - canonical_book: Vec, -} - -impl AuthenticatedSetup { - /// Verify both signatures and signed agreement, then derive the canonical slot book. - /// - /// The node keys must come from the channel's authenticated identities. Check channel/epoch - /// uniqueness and reject payment hashes matching disclosed commitment secrets in the engine. - /// This constructor has no I/O and cannot make an invoice ready. - pub fn new( - init: &Message, accept: &Message, receiver: PublicKey, settlement: PublicKey, - ) -> Result { - if receiver == settlement { - return Err(SetupError::Roles); - } - let (Payload::Init(terms), Payload::Accept(accepted)) = (&init.payload, &accept.payload) - else { - return Err(SetupError::MessageType); - }; - init.verify_signature(&receiver)?; - accept.verify_signature(&settlement)?; - accept.validate_accept(init)?; - let mut seen = BTreeSet::new(); - let mut vouchers = Vec::with_capacity(terms.amounts_msat.len()); - let fees = FeePolicy { - base_msat: terms.fee_base_msat, - proportional_millionths: terms.fee_proportional_millionths, - }; - for (index, (&amount, &hash)) in - terms.amounts_msat.iter().zip(&accepted.payment_hashes).enumerate() - { - fees.gross_msat(amount).map_err(|_| SetupError::FeeOverflow)?; - if !seen.insert(hash) { - return Err(SetupError::DuplicateHash); - } - if terms.hash_chain - && index > 0 && sha256::Hash::hash(&hash).to_byte_array() - != accepted.payment_hashes[index - 1] - { - return Err(SetupError::HashChain); - } - // Encoding validated the 483-slot bound and the entire HTLC id interval. - vouchers.push(Voucher { - slot: index as u16 + 1, - payment_hash: hash, - amount_msat: amount, - expiry: terms.voucher_expiry, - deadline: terms.settlement_deadline, - htlc_id: accepted.s_htlc_id_base + index as u64, - }); - } - let canonical_book = encode_book(init.header.epoch_id, &vouchers); - let init_hash = transcript::init_hash(&init.encode()?); - Ok(Self { - init: init.clone(), - accept: accept.clone(), - receiver, - settlement, - vouchers, - setup_hash: transcript::setup_hash(&init_hash, &accept.encode()?), - book_hash: transcript::book_hash(&canonical_book), - canonical_book, - }) - } - - /// The common channel and epoch identifiers authenticated by both nodes. - pub fn header(&self) -> Header { - self.init.header - } - - /// Original init, including all signed optional extensions. - pub fn init(&self) -> &Message { - &self.init - } - - /// Original accept, including all signed optional extensions. - pub fn accept(&self) -> &Message { - &self.accept - } - - /// Canonical slot order, with one exact payment hash and amount per HTLC identifier. - pub fn vouchers(&self) -> &[Voucher] { - &self.vouchers - } - - /// Section 7.5.3 bytes with variant 4 and fixed-amount profile 1. - pub fn canonical_book(&self) -> &[u8] { - &self.canonical_book - } - - /// T_setup over the complete signed init and accept. - pub fn setup_hash(&self) -> Digest { - self.setup_hash - } - - /// H_book over the canonical voucher entries, without a wire-message wrapper. - pub fn book_hash(&self) -> Digest { - self.book_hash - } - - /// Signed arithmetic terms, to validate against limits read from the actual channel. - pub fn terms(&self) -> BookTerms { - let init = self.init_terms(); - BookTerms { - amounts_msat: init.amounts_msat.clone(), - budget_msat: init.budget_msat, - minimum_payment_msat: init.min_payment_msat, - fees: FeePolicy { - base_msat: init.fee_base_msat, - proportional_millionths: init.fee_proportional_millionths, - }, - settlement_deadline: init.settlement_deadline, - voucher_expiry: init.voucher_expiry, - } - } - - /// Authenticate activation against this setup and the channel engine's actual H_commit. - /// - /// The caller must obtain `commitment_hash` from both fully signed, irrevocably committed - /// transaction views under quiescence. A peer-supplied digest is not acceptable evidence. - /// The returned H_act is only a digest: persist the complete epoch and enforce the channel - /// freeze before acknowledging activation or exposing an invoice. - pub fn validate_activation( - &self, message: &Message, commitment_hash: Digest, current_height: u32, - ) -> Result { - self.authenticate(message, &self.receiver)?; - let Payload::Activate(activation) = &message.payload else { - return Err(SetupError::MessageType); - }; - if activation.setup_hash != self.setup_hash - || activation.book_hash != self.book_hash - || activation.commit_hash != commitment_hash - { - return Err(SetupError::Transcript); - } - let deadline = self.init_terms().settlement_deadline; - if activation.epoch_start_height.abs_diff(current_height) > 6 - || current_height >= deadline - || activation.epoch_start_height >= deadline - { - return Err(SetupError::Height); - } - Ok(transcript::activation_hash( - &self.setup_hash, - &self.book_hash, - &commitment_hash, - activation.epoch_start_height, - )) - } - - /// Verify the settlement peer's acknowledgement of the locally validated H_act. - /// - /// An authenticated acknowledgement does not establish persistence or recovery readiness. - pub fn validate_activation_ack( - &self, message: &Message, activation_hash: Digest, - ) -> Result<(), SetupError> { - self.authenticate(message, &self.settlement)?; - match message.payload { - Payload::ActivateAck(hash) if hash == activation_hash => Ok(()), - Payload::ActivateAck(_) => Err(SetupError::Transcript), - _ => Err(SetupError::MessageType), - } - } - - /// Verify close accounting and every disclosed preimage against this signed book. - /// - /// An unset bit never permits discarding a preimage learned from another source. Retain - /// contradictory evidence and fulfil every known claim through the engine's drain path. - pub fn validate_close_ack( - &self, message: &Message, activation_hash: Digest, - ) -> Result<(), SetupError> { - self.authenticate(message, &self.settlement)?; - let Payload::CloseAck(close) = &message.payload else { - return Err(SetupError::MessageType); - }; - if close.activation_hash != activation_hash { - return Err(SetupError::Transcript); - } - if usize::from(close.num_slots) != self.vouchers.len() { - return Err(SetupError::CloseBook); - } - if self.init_terms().hash_chain - && close - .preimages - .iter() - .enumerate() - .any(|(index, preimage)| usize::from(preimage.slot) != index + 1) - { - return Err(SetupError::CloseBook); - } - for preimage in &close.preimages { - let voucher = &self.vouchers[usize::from(preimage.slot) - 1]; - if sha256::Hash::hash(&preimage.value).to_byte_array() != voucher.payment_hash { - return Err(SetupError::CloseBook); - } - } - Ok(()) - } - - fn authenticate(&self, message: &Message, signer: &PublicKey) -> Result<(), SetupError> { - message.verify_signature(signer)?; - if message.header != self.header() { - return Err(SetupError::Identity); - } - Ok(()) - } - - fn init_terms(&self) -> &Init { - match &self.init.payload { - Payload::Init(init) => init, - _ => unreachable!("invariant: constructor validated immutable init payload"), - } - } -} - -fn encode_book(epoch_id: [u8; 32], vouchers: &[Voucher]) -> Vec { - let mut bytes = Vec::with_capacity(36 + 58 * vouchers.len()); - bytes.extend_from_slice(&epoch_id); - bytes.extend_from_slice(&[4, 1]); - bytes.extend_from_slice(&(vouchers.len() as u16).to_be_bytes()); - for voucher in vouchers { - bytes.extend_from_slice(&voucher.slot.to_be_bytes()); - bytes.extend_from_slice(&voucher.payment_hash); - bytes.extend_from_slice(&voucher.amount_msat.to_be_bytes()); - bytes.extend_from_slice(&voucher.expiry.to_be_bytes()); - bytes.extend_from_slice(&voucher.deadline.to_be_bytes()); - bytes.extend_from_slice(&voucher.htlc_id.to_be_bytes()); - } - bytes -} diff --git a/crates/ffor-protocol/src/transcript.rs b/crates/ffor-protocol/src/transcript.rs deleted file mode 100644 index 207f8a494f..0000000000 --- a/crates/ffor-protocol/src/transcript.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! Domain-separated SHA256 transcript primitives from FFOR section 7.5. -//! -//! Inputs are public transcript data. The caller must validate canonical wire encodings and -//! signatures before using these digests. Hashing alone authenticates nothing. - -use bitcoin::hashes::{sha256, Hash, HashEngine}; -use bitcoin::secp256k1::ecdsa::Signature; -use bitcoin::secp256k1::{Message, PublicKey, Secp256k1}; -use std::fmt; - -/// A 32-byte SHA256 transcript digest in raw byte order. -pub type Digest = [u8; 32]; - -/// Failure to authenticate a compact node-key signature from FFOR section 7. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SignatureError { - /// The signature is not a valid compact ECDSA encoding. - Malformed, - /// The signature uses a noncanonical high-S scalar. - HighS, - /// The expected node did not sign this message type and exact unsigned body. - Invalid, -} - -impl fmt::Display for SignatureError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "invalid FFOR message signature: {self:?}") - } -} - -impl std::error::Error for SignatureError {} - -/// Authenticate the exact unsigned body under the expected peer's node key. -/// -/// Uses the single SHA256 digest and low-S compact ECDSA encoding required by section 7. -/// The body includes the channel and epoch identifiers and every TLV, without a message type -/// prefix or trailing signature. Do not reconstruct it while discarding unknown odd TLVs. -/// The expected key must come from the authenticated channel peer, not the message itself. -/// -/// This verifies identity and byte integrity only. The caller must still decode a canonical -/// supported message, check the sender's role, validate the transcript and enforce epoch state. -/// No signing key is used or exposed. -/// -/// ``` -/// use bitcoin::secp256k1::PublicKey; -/// use ffor_protocol::transcript::verify_message_signature; -/// let node = PublicKey::from_slice(&[ -/// 2, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, -/// 0x95, 0xce, 0x87, 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, -/// 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, 0xf8, 0x17, 0x98, -/// ]).unwrap(); -/// assert!(verify_message_signature(55001, &[0; 64], &[0; 64], &node).is_err()); -/// ``` -pub fn verify_message_signature( - message_type: u16, unsigned_body: &[u8], compact_signature: &[u8; 64], - expected_signer: &PublicKey, -) -> Result<(), SignatureError> { - let signature = - Signature::from_compact(compact_signature).map_err(|_| SignatureError::Malformed)?; - let mut normalized = signature; - normalized.normalize_s(); - if normalized != signature { - return Err(SignatureError::HighS); - } - let message = Message::from_digest(message_digest(message_type, unsigned_body)); - Secp256k1::verification_only() - .verify_ecdsa(&message, &signature, expected_signer) - .map_err(|_| SignatureError::Invalid) -} - -fn hash_parts(tag: &[u8], parts: &[&[u8]]) -> Digest { - let mut engine = sha256::Hash::engine(); - engine.input(tag); - for part in parts { - engine.input(part); - } - sha256::Hash::from_engine(engine).to_byte_array() -} - -/// Digest signed by a node key: one SHA256 over domain, type and unsigned body. -/// -/// The unsigned body includes channel id, epoch id, fixed fields and every TLV, but excludes -/// the final 64-byte signature. Signature verification must separately reject high-S values. -pub fn message_digest(message_type: u16, unsigned_body: &[u8]) -> Digest { - hash_parts(b"ffor/msg", &[&message_type.to_be_bytes(), unsigned_body]) -} - -/// Hash the complete, validated `ff_init` wire message including type and signature. -pub fn init_hash(init_wire: &[u8]) -> Digest { - hash_parts(b"ffor/tr/init", &[init_wire]) -} - -/// Bind the complete `ff_accept` wire message to its preceding signed init. -pub fn setup_hash(init: &Digest, accept_wire: &[u8]) -> Digest { - hash_parts(b"ffor/tr/setup", &[init, accept_wire]) -} - -/// Hash the canonical voucher book, including its epoch, variant, profile and ordered entries. -/// -/// Canonical encoding and book validation are the caller's responsibility. -pub fn book_hash(canonical_book: &[u8]) -> Digest { - hash_parts(b"ffor/book", &[canonical_book]) -} - -/// Bind the current commitment numbers and both transaction ids. -/// -/// Transaction ids must be in internal byte order, the reverse of display hex. The caller -/// must obtain them from the actual signed, fully committed voucher transactions. -pub fn commitment_hash( - receiver_number: u64, receiver_txid_internal: &[u8; 32], settlement_number: u64, - settlement_txid_internal: &[u8; 32], -) -> Digest { - hash_parts( - b"ffor/commit", - &[ - &receiver_number.to_be_bytes(), - receiver_txid_internal, - &settlement_number.to_be_bytes(), - settlement_txid_internal, - ], - ) -} - -/// Bind setup, voucher book, both commitments and the agreed activation height. -/// -/// A matching digest does not establish durable ACTIVE state or invoice readiness. -pub fn activation_hash( - setup: &Digest, book: &Digest, commitment: &Digest, start_height: u32, -) -> Digest { - hash_parts(b"ffor/activate", &[setup, book, commitment, &start_height.to_be_bytes()]) -} diff --git a/crates/ffor-protocol/src/wire/codec.rs b/crates/ffor-protocol/src/wire/codec.rs deleted file mode 100644 index d798a8c6e9..0000000000 --- a/crates/ffor-protocol/src/wire/codec.rs +++ /dev/null @@ -1,81 +0,0 @@ -use bitcoin::secp256k1::ecdsa::Signature; - -use super::tlv::{check_extensions, read_tlvs, write_tlvs, Reader, Writer}; -use super::{lifecycle, setup, Header, Message, Payload, WireError, MAX_MESSAGE_LEN}; - -pub(super) fn decode(wire: &[u8]) -> Result { - if wire.len() > MAX_MESSAGE_LEN { - return Err(WireError::SizeLimit); - } - if wire.len() < 2 + 64 + 64 { - return Err(WireError::Truncated); - } - let signature_start = wire.len() - 64; - let signature = wire[signature_start..].try_into().map_err(|_| WireError::Truncated)?; - validate_signature_encoding(&signature)?; - let mut reader = Reader::new(&wire[..signature_start]); - let kind = reader.u16()?; - let header = Header { channel_id: reader.array()?, epoch_id: reader.array()? }; - let (payload, extensions) = match kind { - 55001 => setup::read_init(&mut reader)?, - 55003 => setup::read_accept(&mut reader)?, - 55045 | 55047 | 55049 | 55051 | 55053 => lifecycle::read(kind, &mut reader)?, - _ => return Err(WireError::UnsupportedMessage(kind)), - }; - check_extensions(&extensions)?; - Ok(Message { header, payload, extensions, signature }) -} - -pub(super) fn encode(message: &Message) -> Result, WireError> { - validate_signature_encoding(&message.signature)?; - let mut writer = Writer(encode_unsigned(message)?); - writer.put(&message.signature)?; - Ok(writer.0) -} - -pub(super) fn encode_unsigned(message: &Message) -> Result, WireError> { - check_extensions(&message.extensions)?; - let mut writer = Writer::new(); - writer.u16(message.message_type())?; - writer.put(&message.header.channel_id)?; - writer.put(&message.header.epoch_id)?; - let known = match &message.payload { - Payload::Init(init) => setup::write_init(init, &mut writer)?, - Payload::Accept(accept) => setup::write_accept(accept, &mut writer)?, - payload => lifecycle::write(payload, &mut writer)?, - }; - if matches!(message.payload, Payload::Init(_)) { - for tlv in &message.extensions { - if matches!(tlv.kind, 1 | 3 | 5 | 9 | 13 | 15) { - return Err(WireError::InvalidTlv(tlv.kind)); - } - } - } - if matches!(message.payload, Payload::CloseAck(_)) - && message.extensions.iter().any(|tlv| tlv.kind == 1) - { - return Err(WireError::InvalidTlv(1)); - } - write_tlvs(&mut writer, known, &message.extensions)?; - if writer.0.len() > MAX_MESSAGE_LEN - 64 { - return Err(WireError::SizeLimit); - } - Ok(writer.0) -} - -fn validate_signature_encoding(bytes: &[u8; 64]) -> Result<(), WireError> { - if bytes[..32].iter().all(|byte| *byte == 0) || bytes[32..].iter().all(|byte| *byte == 0) { - return Err(WireError::InvalidSignature); - } - let signature = Signature::from_compact(bytes).map_err(|_| WireError::InvalidSignature)?; - let mut normalized = signature; - normalized.normalize_s(); - if normalized != signature { - return Err(WireError::InvalidSignature); - } - Ok(()) -} - -pub(super) fn remaining_tlvs(reader: &mut Reader<'_>) -> Result, WireError> { - read_tlvs(reader) -} diff --git a/crates/ffor-protocol/src/wire/lifecycle.rs b/crates/ffor-protocol/src/wire/lifecycle.rs deleted file mode 100644 index f58a65bad7..0000000000 --- a/crates/ffor-protocol/src/wire/lifecycle.rs +++ /dev/null @@ -1,119 +0,0 @@ -use super::codec::remaining_tlvs; -use super::setup::check_count; -use super::tlv::{optional, Reader, Writer}; -use super::{Abort, Activate, CloseAck, Payload, Preimage, Tlv, WireError, MAX_MESSAGE_LEN}; - -pub(super) fn read(kind: u16, reader: &mut Reader<'_>) -> Result<(Payload, Vec), WireError> { - let payload = match kind { - 55045 => Payload::Activate(Activate { - setup_hash: reader.array()?, - book_hash: reader.array()?, - commit_hash: reader.array()?, - epoch_start_height: reader.u32()?, - }), - 55047 => Payload::ActivateAck(reader.array()?), - 55049 => { - let transcript_hash = reader.array()?; - let reason = reader.u16()?; - if reason > 7 { - return Err(WireError::InvalidField); - } - let data_len = usize::from(reader.u16()?); - Payload::Abort(Abort { transcript_hash, reason, data: reader.take(data_len)?.to_vec() }) - }, - 55051 => Payload::Close(reader.array()?), - 55053 => return read_close_ack(reader), - _ => return Err(WireError::UnsupportedMessage(kind)), - }; - Ok((payload, remaining_tlvs(reader)?)) -} - -fn read_close_ack(reader: &mut Reader<'_>) -> Result<(Payload, Vec), WireError> { - let activation_hash = reader.array()?; - let num_slots = reader.u16()?; - check_count(usize::from(num_slots))?; - let settled = reader.take(usize::from(num_slots).div_ceil(8))?.to_vec(); - let mut tlvs = remaining_tlvs(reader)?; - let value = optional(&mut tlvs, 1); - let preimages_tlv_present = value.is_some(); - let bytes = value.unwrap_or_default(); - if bytes.len() % 34 != 0 || bytes.len() / 34 > usize::from(num_slots) { - return Err(WireError::InvalidTlv(1)); - } - let mut preimages = Vec::new(); - let mut records = Reader::new(&bytes); - while !records.is_empty() { - preimages.push(Preimage { slot: records.u16()?, value: records.array()? }); - } - let ack = CloseAck { activation_hash, num_slots, settled, preimages, preimages_tlv_present }; - validate_close_ack(&ack)?; - Ok((Payload::CloseAck(ack), tlvs)) -} - -pub(super) fn write(payload: &Payload, writer: &mut Writer) -> Result, WireError> { - match payload { - Payload::Activate(activate) => { - writer.put(&activate.setup_hash)?; - writer.put(&activate.book_hash)?; - writer.put(&activate.commit_hash)?; - writer.u32(activate.epoch_start_height)?; - }, - Payload::ActivateAck(hash) | Payload::Close(hash) => writer.put(hash)?, - Payload::Abort(abort) => { - if abort.reason > 7 { - return Err(WireError::InvalidField); - } - if abort.data.len() > MAX_MESSAGE_LEN { - return Err(WireError::SizeLimit); - } - writer.put(&abort.transcript_hash)?; - writer.u16(abort.reason)?; - writer.u16(abort.data.len() as u16)?; - writer.put(&abort.data)?; - }, - Payload::CloseAck(ack) => return write_close_ack(ack, writer), - _ => return Err(WireError::InvalidField), - } - Ok(Vec::new()) -} - -fn write_close_ack(ack: &CloseAck, writer: &mut Writer) -> Result, WireError> { - validate_close_ack(ack)?; - writer.put(&ack.activation_hash)?; - writer.u16(ack.num_slots)?; - writer.put(&ack.settled)?; - let mut preimages = Writer::new(); - for record in &ack.preimages { - preimages.u16(record.slot)?; - preimages.put(&record.value)?; - } - Ok(if ack.preimages_tlv_present { - vec![Tlv { kind: 1, value: preimages.0 }] - } else { - Vec::new() - }) -} - -fn validate_close_ack(ack: &CloseAck) -> Result<(), WireError> { - let count = usize::from(ack.num_slots); - check_count(count)?; - if ack.settled.len() != count.div_ceil(8) { - return Err(WireError::InvalidField); - } - // Bits outside the book cannot describe slots and must not create alternate encodings. - let used_bits = count % 8; - if used_bits != 0 && ack.settled[count / 8] >> used_bits != 0 { - return Err(WireError::InvalidField); - } - let expected = (1..=ack.num_slots).filter(|slot| { - let bit = usize::from(slot - 1); - ack.settled[bit / 8] & (1 << (bit % 8)) != 0 - }); - if !expected.eq(ack.preimages.iter().map(|record| record.slot)) { - return Err(WireError::InvalidTlv(1)); - } - if !ack.preimages_tlv_present && !ack.preimages.is_empty() { - return Err(WireError::MissingTlv(1)); - } - Ok(()) -} diff --git a/crates/ffor-protocol/src/wire/mod.rs b/crates/ffor-protocol/src/wire/mod.rs deleted file mode 100644 index a1852d26f0..0000000000 --- a/crates/ffor-protocol/src/wire/mod.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Bounded canonical encodings for the signed Variant D setup and lifecycle messages. -//! -//! Peer bytes are untrusted. Decoding checks structure, low-S signature encoding and -//! message-local invariants, but does not authenticate the sender or establish channel -//! readiness. Call [`Message::verify_signature`] with the expected channel peer before -//! acting, then validate the transcript and live channel state. Unknown odd TLVs are -//! retained byte-for-byte; unknown even TLVs are rejected. No signing keys are accepted. -//! -//! ``` -//! use ffor_protocol::wire::{Message, WireError}; -//! assert_eq!(Message::decode(&[]), Err(WireError::Truncated)); -//! ``` - -mod codec; -mod lifecycle; -mod setup; -mod tlv; -mod types; - -use std::fmt; - -use bitcoin::secp256k1::PublicKey; -pub use types::{Abort, Accept, Activate, CloseAck, Header, Init, Payload, Preimage, Tlv}; - -use crate::transcript::{self, Digest}; - -/// BOLT 8 maximum plaintext peer message, including its two-byte type. -pub const MAX_MESSAGE_LEN: usize = 65_535; -/// Maximum number of TLVs that can fit a peer message even with empty values. -pub const MAX_TLV_COUNT: usize = MAX_MESSAGE_LEN / 2; - -/// Structural or authentication failure. No error authorizes a channel transition. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum WireError { - /// The message, field, or BigSize value ended early. - Truncated, - /// A message or bounded collection exceeds its protocol limit. - SizeLimit, - /// This message type is not implemented by this Variant D codec. - UnsupportedMessage(u16), - /// The init selected a variant other than D. - UnsupportedVariant(u8), - /// A BigSize value used a longer-than-minimal representation. - NonCanonicalBigSize, - /// TLV types were duplicated or not strictly increasing. - TlvOrder, - /// An unknown mandatory even TLV appeared. - UnknownEvenTlv(u64), - /// A required known TLV is absent. - MissingTlv(u64), - /// A known TLV has an invalid value length or is forbidden for this message. - InvalidTlv(u64), - /// A fixed field or count violates the message's local invariants. - InvalidField, - /// A compressed secp256k1 point is invalid or is not compressed. - InvalidPoint, - /// The compact signature is malformed, high-S, or fails authentication. - InvalidSignature, - /// An accept does not bind the supplied init's header, amounts, or transcript. - SetupMismatch, -} - -impl fmt::Display for WireError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "invalid FFOR wire message: {self:?}") - } -} - -impl std::error::Error for WireError {} - -/// A parsed signed envelope. Public fields remain untrusted until signature verification. -/// -/// Encoding revalidates all fields and produces canonical bytes. It never signs or repairs -/// a signature: changing any header, payload or extension requires a new external signature. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Message { - /// Channel and epoch to which the signed message belongs. - pub header: Header, - /// Known message fields and known TLVs. - pub payload: Payload, - /// Unknown odd TLVs, in strictly increasing type order. - pub extensions: Vec, - /// Unverified compact low-S ECDSA signature in the final 64 wire bytes. - pub signature: [u8; 64], -} - -impl Message { - /// Decode one complete peer message with no trailing framing bytes. - /// - /// All input-dependent allocation is bounded by [`MAX_MESSAGE_LEN`]. Successful - /// decoding does not verify the signature, live limits, or lifecycle ordering. - pub fn decode(wire: &[u8]) -> Result { - codec::decode(wire) - } - - /// Encode canonical wire bytes after rechecking message-local invariants. - /// - /// An existing signature is retained unchanged, even if fields were modified. Call - /// [`Self::verify_signature`] before transmitting or acting on a reconstructed message. - pub fn encode(&self) -> Result, WireError> { - codec::encode(self) - } - - /// The assigned two-byte message type, without authenticating the message. - pub fn message_type(&self) -> u16 { - match self.payload { - Payload::Init(_) => 55001, - Payload::Accept(_) => 55003, - Payload::Activate(_) => 55045, - Payload::ActivateAck(_) => 55047, - Payload::Abort(_) => 55049, - Payload::Close(_) => 55051, - Payload::CloseAck(_) => 55053, - } - } - - /// The existing transcript module's domain-separated digest over the unsigned body. - /// - /// The signature field is intentionally ignored so the channel's existing signer can - /// sign newly constructed terms. All unsigned fields and the final envelope size are checked. - pub fn signature_digest(&self) -> Result { - let wire = codec::encode_unsigned(self)?; - Ok(transcript::message_digest(self.message_type(), &wire[2..])) - } - - /// Authenticate the canonical envelope with the expected channel peer's node key. - /// - /// This proves only the signed bytes, not the correctness of the peer's claims. - pub fn verify_signature(&self, expected_signer: &PublicKey) -> Result<(), WireError> { - let wire = self.encode()?; - transcript::verify_message_signature( - self.message_type(), - &wire[2..wire.len() - 64], - &self.signature, - expected_signer, - ) - .map_err(|_| WireError::InvalidSignature) - } - - /// Check this accept against the exact canonical signed init it answers. - /// - /// Verify both signatures first. Live channel capacity, negotiated limits, deadline - /// margins and previously disclosed commitment secrets remain the engine's checks. - pub fn validate_accept(&self, init: &Message) -> Result<(), WireError> { - let (Payload::Accept(accept), Payload::Init(terms)) = (&self.payload, &init.payload) else { - return Err(WireError::SetupMismatch); - }; - self.encode()?; - let init_wire = init.encode()?; - if self.header != init.header - || accept.amounts_msat != terms.amounts_msat - || accept.init_hash != transcript::init_hash(&init_wire) - { - return Err(WireError::SetupMismatch); - } - Ok(()) - } -} diff --git a/crates/ffor-protocol/src/wire/setup.rs b/crates/ffor-protocol/src/wire/setup.rs deleted file mode 100644 index 3b8b12af4a..0000000000 --- a/crates/ffor-protocol/src/wire/setup.rs +++ /dev/null @@ -1,192 +0,0 @@ -use bitcoin::secp256k1::PublicKey; - -use super::codec::remaining_tlvs; -use super::tlv::{fixed, optional, required, Reader, Writer}; -use super::{Accept, Init, Payload, Tlv, WireError, MAX_MESSAGE_LEN}; -use crate::book::MAX_VOUCHERS; - -pub(super) fn read_init(reader: &mut Reader<'_>) -> Result<(Payload, Vec), WireError> { - let variant = reader.u8()?; - if variant != 4 { - return Err(WireError::UnsupportedVariant(variant)); - } - let budget_msat = reader.u64()?; - let count = usize::from(reader.u16()?); - check_count(count)?; - let min_payment_msat = reader.u64()?; - let settlement_deadline = reader.u32()?; - let voucher_expiry = reader.u32()?; - let fee_base_msat = reader.u32()?; - let fee_proportional_millionths = reader.u32()?; - if reader.u64()? != 0 || reader.u16()? != 0 { - return Err(WireError::InvalidField); - } - let mut tlvs = remaining_tlvs(reader)?; - if let Some(forbidden) = tlvs.iter().find(|entry| matches!(entry.kind, 1 | 3 | 5)) { - return Err(WireError::InvalidTlv(forbidden.kind)); - } - let amounts_msat = read_amounts(required(&mut tlvs, 9)?)?; - if amounts_msat.len() != count { - return Err(WireError::InvalidTlv(9)); - } - let witness_peers = optional(&mut tlvs, 13).map(read_points).transpose()?; - let hash_chain = match optional(&mut tlvs, 15) { - None => false, - Some(value) if value == [1] => true, - Some(_) => return Err(WireError::InvalidTlv(15)), - }; - let init = Init { - budget_msat, - min_payment_msat, - settlement_deadline, - voucher_expiry, - fee_base_msat, - fee_proportional_millionths, - amounts_msat, - witness_peers, - hash_chain, - }; - validate_init(&init)?; - Ok((Payload::Init(init), tlvs)) -} - -pub(super) fn read_accept(reader: &mut Reader<'_>) -> Result<(Payload, Vec), WireError> { - let s_commitment_number = reader.u64()?; - let mut tlvs = remaining_tlvs(reader)?; - let hashes = required(&mut tlvs, 1)?; - if hashes.len() % 32 != 0 { - return Err(WireError::InvalidTlv(1)); - } - check_count(hashes.len() / 32)?; - let payment_hashes = hashes - .chunks_exact(32) - .map(|hash| hash.try_into().map_err(|_| WireError::InvalidTlv(1))) - .collect::>()?; - let s_htlc_id_base = u64::from_be_bytes(fixed(required(&mut tlvs, 7)?, 7)?); - let amounts_msat = read_amounts(required(&mut tlvs, 9)?)?; - let init_hash = fixed(required(&mut tlvs, 11)?, 11)?; - let accept = - Accept { s_commitment_number, payment_hashes, s_htlc_id_base, amounts_msat, init_hash }; - validate_accept(&accept)?; - Ok((Payload::Accept(accept), tlvs)) -} - -pub(super) fn write_init(init: &Init, writer: &mut Writer) -> Result, WireError> { - validate_init(init)?; - writer.put(&[4])?; - writer.u64(init.budget_msat)?; - writer.u16(init.amounts_msat.len() as u16)?; - writer.u64(init.min_payment_msat)?; - writer.u32(init.settlement_deadline)?; - writer.u32(init.voucher_expiry)?; - writer.u32(init.fee_base_msat)?; - writer.u32(init.fee_proportional_millionths)?; - writer.u64(0)?; - writer.u16(0)?; - let mut tlvs = vec![Tlv { kind: 9, value: amount_bytes(&init.amounts_msat) }]; - if let Some(peers) = &init.witness_peers { - if peers.len() > MAX_MESSAGE_LEN / 33 { - return Err(WireError::SizeLimit); - } - let mut points = Writer::new(); - points.u16(peers.len() as u16)?; - for peer in peers { - points.put(&peer.serialize())?; - } - tlvs.push(Tlv { kind: 13, value: points.0 }); - } - if init.hash_chain { - tlvs.push(Tlv { kind: 15, value: vec![1] }); - } - Ok(tlvs) -} - -pub(super) fn write_accept(accept: &Accept, writer: &mut Writer) -> Result, WireError> { - validate_accept(accept)?; - writer.u64(accept.s_commitment_number)?; - Ok(vec![ - Tlv { kind: 1, value: accept.payment_hashes.concat() }, - Tlv { kind: 7, value: accept.s_htlc_id_base.to_be_bytes().to_vec() }, - Tlv { kind: 9, value: amount_bytes(&accept.amounts_msat) }, - Tlv { kind: 11, value: accept.init_hash.to_vec() }, - ]) -} - -fn validate_init(init: &Init) -> Result<(), WireError> { - check_count(init.amounts_msat.len())?; - if init.voucher_expiry <= init.settlement_deadline { - return Err(WireError::InvalidField); - } - let mut total = 0u64; - for &amount in &init.amounts_msat { - if amount == 0 || amount < init.min_payment_msat { - return Err(WireError::InvalidTlv(9)); - } - if init.hash_chain && amount != init.amounts_msat[0] { - return Err(WireError::InvalidTlv(15)); - } - total = total.checked_add(amount).ok_or(WireError::InvalidField)?; - } - if total != init.budget_msat { - return Err(WireError::InvalidField); - } - Ok(()) -} - -fn validate_accept(accept: &Accept) -> Result<(), WireError> { - check_count(accept.payment_hashes.len())?; - if accept.amounts_msat.len() != accept.payment_hashes.len() || accept.amounts_msat.contains(&0) - { - return Err(WireError::InvalidTlv(9)); - } - accept - .s_htlc_id_base - .checked_add(accept.payment_hashes.len() as u64 - 1) - .ok_or(WireError::InvalidTlv(7))?; - Ok(()) -} - -pub(super) fn check_count(count: usize) -> Result<(), WireError> { - if count == 0 || count > MAX_VOUCHERS { - return Err(WireError::InvalidField); - } - Ok(()) -} - -fn read_amounts(bytes: Vec) -> Result, WireError> { - if bytes.len() % 8 != 0 { - return Err(WireError::InvalidTlv(9)); - } - check_count(bytes.len() / 8)?; - bytes - .chunks_exact(8) - .map(|amount| { - Ok(u64::from_be_bytes(amount.try_into().map_err(|_| WireError::InvalidTlv(9))?)) - }) - .collect() -} - -fn amount_bytes(amounts: &[u64]) -> Vec { - amounts.iter().flat_map(|amount| amount.to_be_bytes()).collect() -} - -fn read_points(bytes: Vec) -> Result, WireError> { - let mut reader = Reader::new(&bytes); - let count = usize::from(reader.u16()?); - if count > MAX_MESSAGE_LEN / 33 { - return Err(WireError::SizeLimit); - } - let mut peers = Vec::new(); - for _ in 0..count { - let bytes = reader.take(33)?; - let point = PublicKey::from_slice(bytes).map_err(|_| WireError::InvalidPoint)?; - if point.serialize() != bytes { - return Err(WireError::InvalidPoint); - } - peers.push(point); - } - if !reader.is_empty() { - return Err(WireError::InvalidTlv(13)); - } - Ok(peers) -} diff --git a/crates/ffor-protocol/src/wire/tlv.rs b/crates/ffor-protocol/src/wire/tlv.rs deleted file mode 100644 index 0386b13662..0000000000 --- a/crates/ffor-protocol/src/wire/tlv.rs +++ /dev/null @@ -1,168 +0,0 @@ -use super::{Tlv, WireError, MAX_MESSAGE_LEN, MAX_TLV_COUNT}; - -pub(super) struct Reader<'a> { - remaining: &'a [u8], -} - -impl<'a> Reader<'a> { - pub(super) fn new(bytes: &'a [u8]) -> Self { - Self { remaining: bytes } - } - - pub(super) fn take(&mut self, size: usize) -> Result<&'a [u8], WireError> { - let value = self.remaining.get(..size).ok_or(WireError::Truncated)?; - self.remaining = &self.remaining[size..]; - Ok(value) - } - - pub(super) fn array(&mut self) -> Result<[u8; N], WireError> { - self.take(N)?.try_into().map_err(|_| WireError::Truncated) - } - - pub(super) fn u8(&mut self) -> Result { - Ok(self.array::<1>()?[0]) - } - pub(super) fn u16(&mut self) -> Result { - Ok(u16::from_be_bytes(self.array()?)) - } - pub(super) fn u32(&mut self) -> Result { - Ok(u32::from_be_bytes(self.array()?)) - } - pub(super) fn u64(&mut self) -> Result { - Ok(u64::from_be_bytes(self.array()?)) - } - pub(super) fn is_empty(&self) -> bool { - self.remaining.is_empty() - } - - fn bigsize(&mut self) -> Result { - let (value, minimum) = match self.u8()? { - 0xfd => (u64::from(self.u16()?), 0xfd), - 0xfe => (u64::from(self.u32()?), 0x1_0000), - 0xff => (self.u64()?, 0x1_0000_0000), - value => return Ok(u64::from(value)), - }; - if value < minimum { - return Err(WireError::NonCanonicalBigSize); - } - Ok(value) - } -} - -pub(super) fn read_tlvs(reader: &mut Reader<'_>) -> Result, WireError> { - let mut entries = Vec::new(); - let mut last = None; - while !reader.is_empty() { - let kind = reader.bigsize()?; - if last.is_some_and(|previous| kind <= previous) { - return Err(WireError::TlvOrder); - } - let len = usize::try_from(reader.bigsize()?).map_err(|_| WireError::SizeLimit)?; - if len > MAX_MESSAGE_LEN || entries.len() >= MAX_TLV_COUNT { - return Err(WireError::SizeLimit); - } - let value = reader.take(len)?.to_vec(); - entries.push(Tlv { kind, value }); - last = Some(kind); - } - Ok(entries) -} - -pub(super) fn required(entries: &mut Vec, kind: u64) -> Result, WireError> { - optional(entries, kind).ok_or(WireError::MissingTlv(kind)) -} - -pub(super) fn optional(entries: &mut Vec, kind: u64) -> Option> { - let position = entries.iter().position(|entry| entry.kind == kind)?; - Some(entries.remove(position).value) -} - -pub(super) fn check_extensions(entries: &[Tlv]) -> Result<(), WireError> { - if entries.len() > MAX_TLV_COUNT { - return Err(WireError::SizeLimit); - } - let mut previous = None; - let mut total = 0usize; - for entry in entries { - if previous.is_some_and(|last| entry.kind <= last) { - return Err(WireError::TlvOrder); - } - if entry.kind % 2 == 0 { - return Err(WireError::UnknownEvenTlv(entry.kind)); - } - total = total - .checked_add(entry.value.len()) - .and_then(|n| n.checked_add(2)) - .ok_or(WireError::SizeLimit)?; - if total > MAX_MESSAGE_LEN { - return Err(WireError::SizeLimit); - } - previous = Some(entry.kind); - } - Ok(()) -} - -pub(super) struct Writer(pub(super) Vec); - -impl Writer { - pub(super) fn new() -> Self { - Self(Vec::new()) - } - pub(super) fn put(&mut self, bytes: &[u8]) -> Result<(), WireError> { - if bytes.len() > MAX_MESSAGE_LEN - self.0.len() { - return Err(WireError::SizeLimit); - } - self.0.extend_from_slice(bytes); - Ok(()) - } - pub(super) fn u16(&mut self, value: u16) -> Result<(), WireError> { - self.put(&value.to_be_bytes()) - } - pub(super) fn u32(&mut self, value: u32) -> Result<(), WireError> { - self.put(&value.to_be_bytes()) - } - pub(super) fn u64(&mut self, value: u64) -> Result<(), WireError> { - self.put(&value.to_be_bytes()) - } - - fn bigsize(&mut self, value: u64) -> Result<(), WireError> { - match value { - 0..=0xfc => self.put(&[value as u8]), - 0xfd..=0xffff => { - self.put(&[0xfd])?; - self.u16(value as u16) - }, - 0x1_0000..=0xffff_ffff => { - self.put(&[0xfe])?; - self.u32(value as u32) - }, - _ => { - self.put(&[0xff])?; - self.u64(value) - }, - } - } -} - -pub(super) fn write_tlvs( - writer: &mut Writer, known: Vec, extensions: &[Tlv], -) -> Result<(), WireError> { - check_extensions(extensions)?; - for extension in extensions { - if known.iter().any(|tlv| tlv.kind == extension.kind) { - return Err(WireError::InvalidTlv(extension.kind)); - } - } - let mut entries: Vec<_> = known.iter().chain(extensions).collect(); - entries.sort_by_key(|entry| entry.kind); - for entry in entries { - writer.bigsize(entry.kind)?; - writer.bigsize(entry.value.len() as u64)?; - writer.put(&entry.value)?; - } - Ok(()) -} - -pub(super) fn fixed(value: Vec, kind: u64) -> Result<[u8; N], WireError> { - value.try_into().map_err(|_| WireError::InvalidTlv(kind)) -} diff --git a/crates/ffor-protocol/src/wire/types.rs b/crates/ffor-protocol/src/wire/types.rs deleted file mode 100644 index 8f12016d8f..0000000000 --- a/crates/ffor-protocol/src/wire/types.rs +++ /dev/null @@ -1,126 +0,0 @@ -use bitcoin::secp256k1::PublicKey; - -use crate::transcript::Digest; - -/// Identifiers signed by every supported message; neither is a trusted channel lookup key. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Header { - /// BOLT channel identifier. - pub channel_id: [u8; 32], - /// Receiver-selected epoch identifier, whose uniqueness the engine must enforce. - pub epoch_id: [u8; 32], -} - -/// An unknown optional extension retained in the signed canonical TLV stream. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Tlv { - /// Odd type number; duplicate or known types cannot be supplied as extensions. - pub kind: u64, - /// Exact extension bytes, bounded by the peer message limit. - pub value: Vec, -} - -/// Signed Variant D init terms. G and receiver point count are fixed to zero on wire. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Init { - /// Exact sum of the proposed voucher amounts, excluding forwarding fees. - pub budget_msat: u64, - /// Minimum permitted amount for every voucher. - pub min_payment_msat: u64, - /// Absolute height after which new settlement is forbidden. - pub settlement_deadline: u32, - /// Uniform voucher HTLC expiry height, strictly after the deadline. - pub voucher_expiry: u32, - /// Proposed base forwarding fee in millisatoshis. - pub fee_base_msat: u32, - /// Proposed proportional forwarding fee in millionths. - pub fee_proportional_millionths: u32, - /// Required TLV 9 amounts in slot order; length encodes K, from 1 to 483. - pub amounts_msat: Vec, - /// Optional TLV 13 witness peer restriction, preserving peer order. - pub witness_peers: Option>, - /// TLV 15 requests uniform hash-chained vouchers; chain verification needs the accept. - pub hash_chain: bool, -} - -/// Signed accept fields. Cross-message agreement is checked by `Message::validate_accept`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Accept { - /// Peer commitment number before the voucher round. - pub s_commitment_number: u64, - /// Required TLV 1 payment hashes in slot order. - pub payment_hashes: Vec<[u8; 32]>, - /// Required TLV 7 first voucher HTLC id. - pub s_htlc_id_base: u64, - /// Required TLV 9 exact amounts, with one amount per payment hash. - pub amounts_msat: Vec, - /// Required TLV 11 digest of the exact signed init wire bytes. - pub init_hash: Digest, -} - -/// Receiver's proposed activation commitments, still requiring live engine validation. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Activate { - /// T_setup binding the init and accept. - pub setup_hash: Digest, - /// H_book for the canonical slot book. - pub book_hash: Digest, - /// H_commit for the real committed transaction pair. - pub commit_hash: Digest, - /// Receiver's activation height; the peer must check the six-block agreement rule. - pub epoch_start_height: u32, -} - -/// Signed pre-activation abort request; lifecycle permission is an engine concern. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Abort { - /// T_setup if accept was exchanged, otherwise T_init. - pub transcript_hash: Digest, - /// Normative reason code from 0 through 7. - pub reason: u16, - /// Bounded uninterpreted text or evidence; not assumed to be UTF-8. - pub data: Vec, -} - -/// A slot-numbered preimage in a Variant D close acknowledgement. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Preimage { - /// One-based slot index, in ascending order in the preimage list. - pub slot: u16, - /// Preimage whose SHA256 must match the corresponding authenticated book entry. - pub value: [u8; 32], -} - -/// Signed peer close report. Bitmap padding must be zero and every set bit has a preimage. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CloseAck { - /// H_act of the active epoch. - pub activation_hash: Digest, - /// K from the agreed book, checked against that book by the engine. - pub num_slots: u16, - /// LSB-first settled bitmap, exactly ceil(K/8) bytes. - pub settled: Vec, - /// Exactly one record per set bit in TLV 1; missing is permitted only when no slots settled. - pub preimages: Vec, - /// Preserve explicit empty TLV 1 versus absence when the bitmap is empty. - pub preimages_tlv_present: bool, -} - -/// Supported Variant D signed messages; undefined and unsupported types are rejected. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Payload { - /// ff_init, type 55001. - Init(Init), - /// ff_accept, type 55003. - Accept(Accept), - /// ff_activate, type 55045. - Activate(Activate), - /// ff_activate_ack, type 55047, carrying H_act. - ActivateAck(Digest), - /// ff_abort, type 55049. - Abort(Abort), - /// ff_close, type 55051, carrying H_act. - Close(Digest), - /// ff_close_ack, type 55053. - CloseAck(CloseAck), -} diff --git a/crates/ffor-protocol/tests/amounts.rs b/crates/ffor-protocol/tests/amounts.rs deleted file mode 100644 index 068b7a4883..0000000000 --- a/crates/ffor-protocol/tests/amounts.rs +++ /dev/null @@ -1,127 +0,0 @@ -use ffor_protocol::amounts::{check_plaintext_amount, AmountError, FeePolicy}; -use proptest::prelude::*; - -#[test] -fn appendix_amounts_preserve_invoice_value() { - let fees = FeePolicy { base_msat: 1000, proportional_millionths: 5000 }; - for (amount, fee, gross) in [ - (1_000_000, 6000, 1_006_000), - (994_000, 5970, 999_970), - (546_250, 3731, 549_981), - (49_749_000, 249_745, 49_998_745), - (546_000, 3730, 549_730), - ] { - assert_eq!(fees.fee_msat(amount), Ok(fee)); - assert_eq!(fees.gross_msat(amount), Ok(gross)); - assert_eq!(check_plaintext_amount(amount, amount, gross, fees, None), Ok(())); - assert_eq!( - check_plaintext_amount(amount, amount, gross - 1, fees, None), - Err(AmountError::InsufficientFee) - ); - } -} - -#[test] -fn rejects_overflow_before_division_and_at_gross_sum() { - let product = FeePolicy { base_msat: 0, proportional_millionths: 2 }; - assert_eq!(product.fee_msat(u64::MAX / 2 + 1), Err(AmountError::Overflow)); - assert!(product.fee_msat(u64::MAX / 2).is_ok()); - let base = FeePolicy { base_msat: 1, proportional_millionths: 0 }; - assert_eq!(base.gross_msat(u64::MAX), Err(AmountError::Overflow)); - assert_eq!(FeePolicy::default().gross_msat(u64::MAX), Ok(u64::MAX)); -} - -#[test] -fn public_policy_compares_complete_fees() { - let book = FeePolicy { base_msat: 1000, proportional_millionths: 0 }; - let public = FeePolicy { base_msat: 0, proportional_millionths: 2000 }; - assert_eq!( - check_plaintext_amount(1_000_000, 1_000_000, 1_000_999, book, Some(public)), - Err(AmountError::InsufficientFee) - ); - assert_eq!(check_plaintext_amount(1_000_000, 1_000_000, 1_001_000, book, Some(public)), Ok(())); - assert_eq!( - check_plaintext_amount(1_000_000, 1_000_000, 1_000_000, book, Some(FeePolicy::default())), - Ok(()) - ); - assert_eq!( - check_plaintext_amount(1_000_000, 1_000_000, 1_000_000, book, None), - Err(AmountError::InsufficientFee) - ); -} - -#[test] -fn public_policy_increase_cannot_disable_book_pricing() { - let amount = u64::MAX / 2; - let public = FeePolicy { base_msat: u32::MAX, proportional_millionths: u32::MAX }; - assert_eq!( - check_plaintext_amount(amount, amount, amount, FeePolicy::default(), Some(public)), - Ok(()) - ); -} - -#[test] -fn cheaper_public_policy_can_have_product_above_book_setup_bound() { - let book = FeePolicy { base_msat: 5000, proportional_millionths: u32::MAX - 1 }; - let public = FeePolicy { base_msat: 0, proportional_millionths: u32::MAX }; - let amount = 4_294_967_298; - let public_fee = 18_446_744_078_004; - assert_eq!( - check_plaintext_amount(amount, amount, amount + public_fee, book, Some(public)), - Ok(()) - ); - assert_eq!( - check_plaintext_amount(amount, amount, amount + public_fee - 1, book, Some(public)), - Err(AmountError::InsufficientFee) - ); -} - -#[test] -fn rejects_mismatched_payee_and_incoming_underflow() { - for forward in [999, 1001] { - assert_eq!( - check_plaintext_amount(1000, forward, 2000, FeePolicy::default(), None), - Err(AmountError::WrongPayeeAmount) - ); - } - assert_eq!( - check_plaintext_amount(1000, 1000, 999, FeePolicy::default(), None), - Err(AmountError::InsufficientFee) - ); -} - -#[test] -fn public_discount_cannot_admit_invalid_signed_book_amounts() { - let fees = FeePolicy { base_msat: 1, proportional_millionths: 0 }; - assert_eq!( - check_plaintext_amount(u64::MAX, u64::MAX, u64::MAX, fees, Some(FeePolicy::default())), - Err(AmountError::Overflow) - ); - assert_eq!( - check_plaintext_amount(0, 0, 0, FeePolicy::default(), None), - Err(AmountError::ZeroAmount) - ); -} - -proptest! { - #[test] - fn fee_bounds_match_wide_integer_oracle(amount in any::(), base in any::(), ppm in any::()) { - let policy = FeePolicy { base_msat: base, proportional_millionths: ppm }; - let product = u128::from(amount) * u128::from(ppm); - let gross = u128::from(amount) + u128::from(base) + product / 1_000_000; - if product > u128::from(u64::MAX) || gross > u128::from(u64::MAX) { - prop_assert_eq!(policy.gross_msat(amount), Err(AmountError::Overflow)); - } else { - prop_assert_eq!(policy.gross_msat(amount), Ok(gross as u64)); - } - } - - #[test] - fn gross_payment_passes_and_payee_changes_fail(amount in 1_u64..1_000_000_000, base in any::(), ppm in 0_u32..1_000_000) { - let policy = FeePolicy { base_msat: base, proportional_millionths: ppm }; - let gross = policy.gross_msat(amount).unwrap(); - prop_assert_eq!(check_plaintext_amount(amount, amount, gross, policy, None), Ok(())); - prop_assert_eq!(check_plaintext_amount(amount, amount - 1, gross, policy, None), Err(AmountError::WrongPayeeAmount)); - prop_assert_eq!(check_plaintext_amount(amount, amount + 1, gross, policy, None), Err(AmountError::WrongPayeeAmount)); - } -} diff --git a/crates/ffor-protocol/tests/book.rs b/crates/ffor-protocol/tests/book.rs deleted file mode 100644 index 7e28ba88dd..0000000000 --- a/crates/ffor-protocol/tests/book.rs +++ /dev/null @@ -1,151 +0,0 @@ -use ffor_protocol::amounts::FeePolicy; -use ffor_protocol::book::{validate_anchor_book, AnchorChannelLimits, BookError, BookTerms}; -use proptest::prelude::*; - -fn terms(amounts_msat: Vec) -> BookTerms { - BookTerms { - budget_msat: amounts_msat.iter().sum(), - amounts_msat, - minimum_payment_msat: 546_000, - fees: FeePolicy { base_msat: 1000, proportional_millionths: 5000 }, - settlement_deadline: 798_992, - voucher_expiry: 800_000, - } -} - -fn channel() -> AnchorChannelLimits { - AnchorChannelLimits { - max_accepted_htlcs: 483, - max_in_flight_msat: 5_000_000_000, - htlc_minimum_msat: 1, - receiver_dust_sat: 546, - settlement_dust_sat: 546, - settlement_balance_msat: 7_000_000_000, - receiver_balance_msat: 3_000_000_000, - settlement_reserve_sat: 10_000, - receiver_reserve_sat: 10_000, - settlement_is_funder: true, - feerate_sat_per_kw: 2500, - } -} - -fn validate(book: &BookTerms, channel: &AnchorChannelLimits) -> Result<(), BookError> { - validate_anchor_book(book, channel, 790_000, 1008) -} - -#[test] -fn appendix_d_books_fit_both_funder_roles() { - for amounts in - [vec![1_000_000], vec![994_000, 546_250, 49_749_000], vec![546_000], vec![546_000; 483]] - { - for settlement_is_funder in [false, true] { - let channel = AnchorChannelLimits { settlement_is_funder, ..channel() }; - assert_eq!(validate(&terms(amounts.clone()), &channel), Ok(())); - } - } -} - -#[test] -fn unfunded_receiver_can_receive_in_settlement_funded_channel() { - let mut channel = channel(); - channel.receiver_balance_msat = 0; - assert_eq!(validate(&terms(vec![1_000_000]), &channel), Ok(())); - channel.settlement_is_funder = false; - assert_eq!(validate(&terms(vec![1_000_000]), &channel), Err(BookError::FeeReserve)); -} - -#[test] -fn requires_fee_spike_buffer_including_both_anchors() { - let book = terms(vec![1_000_000]); - let mut channel = channel(); - // D.1 fee at twice 2500 sat/kw is 6480 sat, plus 660 sat for anchors. - channel.settlement_balance_msat = book.budget_msat + (10_000 + 6480 + 660) * 1000; - assert_eq!(validate(&book, &channel), Ok(())); - channel.settlement_balance_msat -= 1; - assert_eq!(validate(&book, &channel), Err(BookError::FeeReserve)); - channel.settlement_balance_msat = book.budget_msat + channel.settlement_reserve_sat * 1000 - 1; - assert_eq!(validate(&book, &channel), Err(BookError::SettlementReserve)); - channel.settlement_balance_msat = book.budget_msat - 1; - assert_eq!(validate(&book, &channel), Err(BookError::SettlementReserve)); -} - -#[test] -fn validates_both_commitment_dust_limits() { - let book = terms(vec![546_999]); - let mut channel = channel(); - channel.receiver_dust_sat = 547; - assert_eq!(validate(&book, &channel), Err(BookError::Dust)); - channel.receiver_dust_sat = 546; - channel.settlement_dust_sat = 547; - assert_eq!(validate(&book, &channel), Err(BookError::Dust)); - channel.settlement_dust_sat = u64::MAX; - assert_eq!(validate(&book, &channel), Err(BookError::Dust)); -} - -#[test] -fn rejects_budget_and_channel_limit_violations() { - let mut book = terms(vec![1_000_000]); - let mut channel = channel(); - book.budget_msat += 1; - assert_eq!(validate(&book, &channel), Err(BookError::BudgetMismatch)); - book.budget_msat -= 1; - channel.max_in_flight_msat = book.budget_msat - 1; - assert_eq!(validate(&book, &channel), Err(BookError::InFlightLimit)); - channel.max_in_flight_msat = book.budget_msat; - channel.htlc_minimum_msat = book.budget_msat + 1; - assert_eq!(validate(&book, &channel), Err(BookError::MinimumAmount)); - channel.htlc_minimum_msat = 1; - book.minimum_payment_msat = book.budget_msat + 1; - assert_eq!(validate(&book, &channel), Err(BookError::MinimumAmount)); - book.minimum_payment_msat = 0; - book.amounts_msat = vec![0]; - assert_eq!(validate(&book, &channel), Err(BookError::MinimumAmount)); -} - -#[test] -fn bounds_slot_count_before_processing_amounts() { - for count in [0, 484] { - assert_eq!(validate(&terms(vec![546_000; count]), &channel()), Err(BookError::SlotCount)); - } - let channel = AnchorChannelLimits { max_accepted_htlcs: 1, ..channel() }; - assert_eq!(validate(&terms(vec![546_000; 2]), &channel), Err(BookError::SlotCount)); -} - -#[test] -fn rejects_expired_or_overflowing_deadlines() { - let mut book = terms(vec![1_000_000]); - book.settlement_deadline = 790_000; - assert_eq!(validate(&book, &channel()), Err(BookError::Deadline)); - book.settlement_deadline = u32::MAX; - assert_eq!(validate(&book, &channel()), Err(BookError::Deadline)); - book.settlement_deadline = 798_993; - assert_eq!(validate(&book, &channel()), Err(BookError::Deadline)); - assert_eq!( - validate_anchor_book(&terms(vec![1_000_000]), &channel(), 790_000, 0), - Err(BookError::Deadline) - ); -} - -#[test] -fn rejects_arithmetic_overflow_without_wrapping() { - let mut book = terms(vec![1_000_000]); - book.amounts_msat = vec![u64::MAX]; - assert_eq!(validate(&book, &channel()), Err(BookError::Overflow)); - book.fees = FeePolicy::default(); - book.amounts_msat = vec![u64::MAX, 546_000]; - assert_eq!(validate(&book, &channel()), Err(BookError::Overflow)); - let mut channel = channel(); - channel.settlement_is_funder = false; - channel.receiver_reserve_sat = u64::MAX; - assert_eq!(validate(&terms(vec![1_000_000]), &channel), Err(BookError::Overflow)); -} - -proptest! { - #[test] - fn valid_books_cannot_exceed_signed_budget(amounts in prop::collection::vec(546_000_u64..2_000_000, 1..100)) { - let mut book = terms(amounts); - prop_assert_eq!(validate(&book, &channel()), Ok(())); - book.budget_msat += 1; - prop_assert_eq!(validate(&book, &channel()), Err(BookError::BudgetMismatch)); - } -} diff --git a/crates/ffor-protocol/tests/data/appendix-d.json b/crates/ffor-protocol/tests/data/appendix-d.json deleted file mode 100644 index 356ae81cb1..0000000000 --- a/crates/ffor-protocol/tests/data/appendix-d.json +++ /dev/null @@ -1,92 +0,0 @@ -[ - { - "scenario": "D.1", - "init_hash": "343331edc28e500ea03cbce26983583e66fff33dc9f096a3d6924b8239f68eb8", - "setup_hash": "ba34be8afe89e4457543a6e7279f86054f1d6e9038234b786b740e9c905c001f", - "book_hash": "8ae276dd460c7b2030287a608677bc51eb9cdef5b7cfc1f73d005e463076510a", - "commitment_hash": "723024696ad20b4758b228284620d18fd30750b7051cc64efca23a1417cf1321", - "activation_hash": "580a4a958e8131b32452741e51d724e0f231cb518896c743fa59de236ac6e716", - "init_wire": "d6d9bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff150400000000000f4240000100000000000854d0000c3110000c3500000003e80000138800000000000000000000090800000000000f4240b650a5af1658bafe768dc0d5b07af4556ee5776b010bd4da19cd64aefd40f1a0184923774f95b2d3d9e024283db263075e8ed6f793ba4553b9960e638fd90933", - "accept_wire": "d6dbbef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff15000000000000002a0120a0a62adc5092cd3c202aaea0900b5826c242194c5f9984070e833e58b273eda507080000000000000000090800000000000f42400b20343331edc28e500ea03cbce26983583e66fff33dc9f096a3d6924b8239f68eb898fbd933af0a181f69b015f155a48608d1295327607d85bdb03a9598b54dc58f6ac01b0421309cebbbaa596e62113de7e6fe2aafaccc281f535eda535525b3ba", - "activate_wire": "d705bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff15ba34be8afe89e4457543a6e7279f86054f1d6e9038234b786b740e9c905c001f8ae276dd460c7b2030287a608677bc51eb9cdef5b7cfc1f73d005e463076510a723024696ad20b4758b228284620d18fd30750b7051cc64efca23a1417cf1321000c0df003aa4c0c160338dfb3783120761fb4a6053836307e196fe3597032f28715b9a021074ec06ceb9c5f06af9854f8b21ff1b6b74ff3ae326a049de8270f2c8dc559", - "ack_wire": "d707bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff15580a4a958e8131b32452741e51d724e0f231cb518896c743fa59de236ac6e7161a20d93b0e00046483cffe0d9b54b0747a351ccaa3971a92aaf9245eb7cde5734f20a6ed584ccd30d81e833d9e89d9d9e42878b541cce5913a647b5ffc1ca9a3", - "book": "fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff15040100010001a0a62adc5092cd3c202aaea0900b5826c242194c5f9984070e833e58b273eda500000000000f4240000c3500000c31100000000000000000", - "receiver_txid": "c56247558caf290d78e2fe7d948d9605320c012b20fa181db87fc847ce69159b", - "settlement_txid": "d23aaf74371cdda6cbaf75b7390fdd75b3247dfe90c98d2e19e9802783c349b9" - }, - { - "scenario": "D.2", - "init_hash": "19644c62830070c3d7ca623b582f274e6e94669d4938e54262a1c6e6e6ed1791", - "setup_hash": "8465b03bd1ab3001129cd91fffb6e5129460cf82fb396cad2ebbb0ede8916396", - "book_hash": "09e4e3f8cef88225f7401bdcf539cf557932eeb035a265d602afba2083125eec", - "commitment_hash": "e7e996cb809a52736589d78d22b1a7c2a76b2ba07a8ac1f957aabc2651b0b9c5", - "activation_hash": "1d561fb397d1ac599cf6876a4f390d6bd81de4cd425b30383b14a5de4b4b61d4", - "init_wire": "d6d9bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884892436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d60400000000030e9ca2000300000000000854d0000c3110000c3500000003e80000138800000000000000000000091800000000000f2ad000000000000855ca0000000002f71c081950e84703f6ab745d5e93ed17fadd4cb4750b1b4bc04b01b18dd4051b0fc58114f5b24aa52b2a1c5c391d98621ce6a37512049c6dac1cbf048f61cb52868547", - "accept_wire": "d6dbbef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884892436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d6000000000000002a016024e4e3ab2a0cbbe957d5dc41c688721d956cf7babe8f304ad24b4589787bd993a7334c46d83bba22364ed8b863d3052fe24f62b973072776b537aaf707132596934cbfbc3fdfea5fc984e1270f06ebb219beb18ee02e8441acafa05ec7d928f607080000000000000007091800000000000f2ad000000000000855ca0000000002f71c080b2019644c62830070c3d7ca623b582f274e6e94669d4938e54262a1c6e6e6ed179105ec065a191d90cbb8a6adbeb0d1fd2838be33fa511588e2a3ccb83ce7313a0a1946d43ead676cd1be4a6780b5dd6feb03dc71f0c8d98056ce61304ab243d91b", - "activate_wire": "d705bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884892436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d68465b03bd1ab3001129cd91fffb6e5129460cf82fb396cad2ebbb0ede891639609e4e3f8cef88225f7401bdcf539cf557932eeb035a265d602afba2083125eece7e996cb809a52736589d78d22b1a7c2a76b2ba07a8ac1f957aabc2651b0b9c5000c0df01b5ca88c942d833703ab7702576abcbe1694524b37ef6b762f61358c9e2c31710cd2d600c69ef3a810d29a4b25705759de647e7b666909f0baec2a7af766eca4", - "ack_wire": "d707bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884892436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d61d561fb397d1ac599cf6876a4f390d6bd81de4cd425b30383b14a5de4b4b61d40e976c39f969145764a509691cd84aafedaddd873be2e3b35fbb42b55d43087b79aadcd67eb097ea539931314d7b8aee65758147094f78cd43736c0e496d2e66", - "book": "2436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d604010003000124e4e3ab2a0cbbe957d5dc41c688721d956cf7babe8f304ad24b4589787bd99300000000000f2ad0000c3500000c311000000000000000070002a7334c46d83bba22364ed8b863d3052fe24f62b973072776b537aaf70713259600000000000855ca000c3500000c311000000000000000080003934cbfbc3fdfea5fc984e1270f06ebb219beb18ee02e8441acafa05ec7d928f60000000002f71c08000c3500000c31100000000000000009", - "receiver_txid": "2cc9f550a3c31100426d327e5308e0ee875b1c33d2382097027f0db822320b83", - "settlement_txid": "69e73334f97a3fe4ddcde4f3161ba1112d6d5bc6953e95199f0ec0bb1b552e2c" - }, - { - "scenario": "D.3", - "init_hash": "196ce823fe02a2daea0a6a544f05f7b1c51520c0c323836663b31af3318a9cf0", - "setup_hash": "43bce577aa29c966a8c9a8bc614a9edfc4994f8ee4954414bc635cb6dcd745ff", - "book_hash": "5bb093a59800241c8f94a2ab083fadadda85a5d08e81b046e0dcf4623310819b", - "commitment_hash": "c034dadab45e153536220113ba43e4aeaf2a87391cda1fa33b9d7d89472ce5ad", - "activation_hash": "bcb6c726241e7b35d19c5e0f952271531cf2e61b4979c61cf8c817ebcd22da03", - "init_wire": "d6d9bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884898685960c118c83535c1bcc47e33666a775e26e9d1ca6cbca79aade33f9fe3ecb0400000000030e9ca2000300000000000854d0000c3110000c3500000003e80000138800000000000000000000091800000000000f2ad000000000000855ca0000000002f71c08b1ab264efb4d47b962fe9f5aeb72b5c1d67e5f0cd7709835f07a6bd58450fa9a048758571653c17a8c91eacd8750cbc7efe0bd648caca5c1861ed14ccad2c885", - "accept_wire": "d6dbbef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884898685960c118c83535c1bcc47e33666a775e26e9d1ca6cbca79aade33f9fe3ecb000000000000002a016021f2705152e9753f5d63b5a0bdbb1d3cd0da4d56d83e3d3853cd44c12dfe6f8458bdb1620d8f37d85a846d3f80a4e43ca084cbe1743d043d429de8dcfb0843c0dfe77b118b59852036e38b7409770e502899c35e8aced4a5d405ebecc167d0ea07080000000000000000091800000000000f2ad000000000000855ca0000000002f71c080b20196ce823fe02a2daea0a6a544f05f7b1c51520c0c323836663b31af3318a9cf025b2bb680e71adfd82af8c7ff8a10a871775112ca01901a0dc56895b5e1217567c4f5dada7a4da2f3e4211d7faf6db8664f59d1062d31b31ba83d4233462b07d", - "activate_wire": "d705bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884898685960c118c83535c1bcc47e33666a775e26e9d1ca6cbca79aade33f9fe3ecb43bce577aa29c966a8c9a8bc614a9edfc4994f8ee4954414bc635cb6dcd745ff5bb093a59800241c8f94a2ab083fadadda85a5d08e81b046e0dcf4623310819bc034dadab45e153536220113ba43e4aeaf2a87391cda1fa33b9d7d89472ce5ad000c0df0872c5320024fbba9ee9ee119fc2b61b48107ee6aed280b508eae7d233e8bc9db501f0541eb37c0649ef9526e75f035d355a6dede257c86d1222a728814cd8d82", - "ack_wire": "d707bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884898685960c118c83535c1bcc47e33666a775e26e9d1ca6cbca79aade33f9fe3ecbbcb6c726241e7b35d19c5e0f952271531cf2e61b4979c61cf8c817ebcd22da03b07d07776548693e5a9ceace1cca137794ef60f13c53d67ad6ac4baa64518e8576fa49931dd22c60ccd8b66d1319834cb6993d41df0b281dd45930f1b5e475b4", - "book": "8685960c118c83535c1bcc47e33666a775e26e9d1ca6cbca79aade33f9fe3ecb04010003000121f2705152e9753f5d63b5a0bdbb1d3cd0da4d56d83e3d3853cd44c12dfe6f8400000000000f2ad0000c3500000c31100000000000000000000258bdb1620d8f37d85a846d3f80a4e43ca084cbe1743d043d429de8dcfb0843c000000000000855ca000c3500000c311000000000000000010003dfe77b118b59852036e38b7409770e502899c35e8aced4a5d405ebecc167d0ea0000000002f71c08000c3500000c31100000000000000002", - "receiver_txid": "a351e3b1b081a909e61b6106a8c4684aebbb40c32f597aa2d2a0494eea7468c6", - "settlement_txid": "37f499ed459db4049a86c452b91740a254f550f678a0e85b6bbf063b1dbedada" - }, - { - "scenario": "D.4", - "init_hash": "b4a90a0eb194b5d7d283a45707f5778bfca2eda67c762e3f548520024f0cc257", - "setup_hash": "29b3ef77eec90177a34bf4d9785afd1fd6ffb2a28bad4ed571f295fe711c35a9", - "book_hash": "6beceabe7c2571353e03fdb7f82e3f4241c2467967ba67c2b9efebf32a4a6839", - "commitment_hash": "06602b655a1dbeb1059e87e9f8c0fefd89bed2a6501a5df25724e54d1c2e56fe", - "activation_hash": "982ee14fdd19b21558b89adda15b53f54122fc1c5a72bae5ef4261ee154cc776", - "init_wire": "d6d9bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848987cb2b4c1f4e6b9246bc30f7a7cb145dff0ad8b75153061fe08a31344ec15a0d0400000000000854d0000100000000000854d0000c3110000c3500000003e80000138800000000000000000000090800000000000854d0babbf4d69c60af8d49aaca2364c217375b270927d0340b18f630396bb6d9b1cd5490997cf98fc8ea3d38ef2f517e3a6e26e0ec9ce8c33ae503625e40a46fa451", - "accept_wire": "d6dbbef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848987cb2b4c1f4e6b9246bc30f7a7cb145dff0ad8b75153061fe08a31344ec15a0d000000000000002a0120ec5abb4d6d027af6f9bc6451f6cc98d95ad28fb9c20d67d3e51cb30614c15a6e07080000000000000000090800000000000854d00b20b4a90a0eb194b5d7d283a45707f5778bfca2eda67c762e3f548520024f0cc2575f178313b04c050db7aa8053d8fd271677247d8058ac49f3096d0f7abc1d09f75c82c95bcb0702117007a7ca143b106392a006f81cde971644bdad587af2cc7f", - "activate_wire": "d705bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848987cb2b4c1f4e6b9246bc30f7a7cb145dff0ad8b75153061fe08a31344ec15a0d29b3ef77eec90177a34bf4d9785afd1fd6ffb2a28bad4ed571f295fe711c35a96beceabe7c2571353e03fdb7f82e3f4241c2467967ba67c2b9efebf32a4a683906602b655a1dbeb1059e87e9f8c0fefd89bed2a6501a5df25724e54d1c2e56fe000c0df02938ff94475f468d79e2503c5159adc276699ea1681343cf53169b350b3046ae09015bb2005c24c4f8d9f19649523ed164abbe7f367ff2687cf8a27ebacfff91", - "ack_wire": "d707bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848987cb2b4c1f4e6b9246bc30f7a7cb145dff0ad8b75153061fe08a31344ec15a0d982ee14fdd19b21558b89adda15b53f54122fc1c5a72bae5ef4261ee154cc776e551b9a3347022ed74ca206fe49684de31f1150e6d3aace73807bf6055b24942176c45fb7eb36a13f2c1e3866e223cef4617b6740621c114f7278f68d283d000", - "book": "87cb2b4c1f4e6b9246bc30f7a7cb145dff0ad8b75153061fe08a31344ec15a0d040100010001ec5abb4d6d027af6f9bc6451f6cc98d95ad28fb9c20d67d3e51cb30614c15a6e00000000000854d0000c3500000c31100000000000000000", - "receiver_txid": "6c5524cdfb79105aa2431263458cc44849c91f1af2218c5bede450935b40b8e3", - "settlement_txid": "6fd405cb69cf32914b5de6c56cf73a034c29a1b8472edf5ef6a4d840535ed995" - }, - { - "scenario": "D.5", - "init_hash": "e0c80af58e7643d04abbd00763600d69e7ad33429020676adae4d4d341201b3e", - "setup_hash": "e012e565964e65d584adbc7138f1d115f6e00c27a90903e6c1b1657982fe5cff", - "book_hash": "556ddfc432a327526e84bbfe843070d8fca46b05d25b3a0dfccd406ec4f03a29", - "commitment_hash": "61034010d3510c0a44a53e0ef4a086226bfa039708f944ce4da9833f1ad5e5f3", - "activation_hash": "7d36f8f1728a75368594fed88f57f4b24113c1a76d673264b1a2b89c4a233365", - "init_wire": "d6d9bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848957d51e11b3a8feac73e8ea58aa6824b7bba69e045416f6ba4a586348adc40da604000000000fb8047001e300000000000854d0000c3110000c3500000003e8000013880000000000000000000009fd0f1800000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d017a3e152d4dca7cfe072f02634463aa789ac5e5a0c4dbaed0ce8cb7baf418ffa25f7b1a4f8d10e36916250b3cb6098ca4fb177335d1c24c78ed9e69cc3939805", - "accept_wire": "d6dbbef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848957d51e11b3a8feac73e8ea58aa6824b7bba69e045416f6ba4a586348adc40da6000000000000002a01fd3c607305b11a874bbd995b29b11d109eff432402136904a3bab1f7bd9f869109cfc1cbf141d9ad3b41938c25083328862acf711291bb47ab53b33dc37898ef9326b462e42ded68912761a397087a1310af972f9022330fbbbf24c580b9e71479fd87754bccfbb8e3ee44224e0290582ada7cb1dcb05ad968df63ac0aa01e81298f1b7c23775d75a9e3e878e5e33ba9cf7da51c78a4d54431bc1981c8524eda36bb0dbcd5fb4815309c1de8fbc2867eb93635d8764c565d952917457874fc6eceda18e3ae54a63b8f9526f7955db3417b5a1f6743b99ad0ca7cc92c3fb199c149b768bd471262b242310a4d1b298156a5fc3469395eb2fa3d28be02b88d8d0c970811397b027bfa95da000fac9dac5d41825bb54a0cd0071a262be00d6d4445f3e2e9db17a978e9e05effc0d7f4c33f7fb78898deec1a0b44212bf943818091fbc09e0fc65290b128e63d598e654d4a7bbe14cc3ef946f6fb6fe0d5b46cb9e0e263883f951c45254175c92549460b5a89d5d3c61a950e7e366f992dac9f7f7a7e6ce7fa9d083023788850024c7eb588fae1c7542604d23cc12f44ddd568c2320a754f3aa43187fba488b5530dbc366257f82058801f6983509716a9e9bf00db680f187404ba142c5ad30fb6769e7f6a84843d4c437bd50254a72815c8b75c1deb0c4b28ced94a2fc573f8c251838edfc807be39d1b910d18ede82aa7a127c5888b3a3a6a8f52194b94c6d0b521756e5c2c20ab5b6da8a2483e44aeb48447112c068042014e1e38afe84f6da72dbab22108ec05f60555504bac35bbfc7c62d1ac3621113667fb8817a03dd2f5fd97325ffaec561f597256d58742291c569ee251257fb30ec738e4babdd6d0b18b82a7f5a7415074cd283260197c4efe5d439671c9abc3e9e411c4bcb405bda68f31ef974cf91e5dc7a530fa8580048c4bc742805125a7aacb62cd9c823ddf004ca04fb936c3093029ddceaef569190534039885b1b8da9e7ea66fc54f78b5a4441c5ca6ebaa742485894509dc379783c52d881fad9e4063c74693de53572427dd71747f7a47f95a72b83b95f62b60be7479516593c53454a89ae1311664f5155c45544265e5b33911a120db3b683522ac590e64528b2db1d9b5644e6d6b7befb0f222e567bef23ec977fd3d5a6f146b6b195f48384e454755b708745f22c50b1ded9d0f24bc9b87d93d95046accde5e0d0c1b27ee3a01b29bd0c8af340c2ede7c9552fd9410b4c731a47156f54ae2fe2ef6ab170a3f93b68781e8a122e02fdbade74d727eedafc80663a79c32726b7d555e730f6b00c508a679821f8e0b736552664ffd03360eb8e08acc5ac5c653ce280885222ca80599fd0cf5ce9487aae5390f0aa7e2f55bbf36c7fab020879c262c74f0659b80d1d35e8a88f39201d36bb5d8603822ee851e4f936cc9d941eda8f0c132be674ed8b0edea50f2efd820966577036831199400b94a6a5ddc1efde5caa77d770fd938267950cb8f56aee3a11f312d601c4e2f49e229db76eebe9df97b467478fe8043e52b8a3ff4d5f870fa11ba4e1a7ffc5f35310840f33f01da137e5802ad25db1bb7507ed8a6155c9d7d58e9798f1822e143da862250702f237267f91bf1f6bc33dbe9c22ae8f8733c3a60d21c30bf7e51a304f53bac7c4a2c6e3cbe61157679598a89d04d4471120348d82a09c6588128fe927d38dfe26d98932961fc842764036a20a0e1e99e0e8f0f9d389acb4ce07130e58276e847f52bf8d17c2bf36fc33f5d5256f0c02a8e76d9de795b0a9a6624e694f2df4d106bfe32fa46b60400a3308853d9812b63820a7c738e5fc15ab6181cd391a059c248b700657d4b21b4e5db49ad03151b291227dc42af60dd203c373a9533afdc9875f4c02d08560c4533fdc43a956e852c354169466af263dd38c4f278c18fb54d209d9b65ef1828cbec21c3da0878d813c406978c150f03595e9a26d582ad426d14ef279edd0e8f452fbdccf935988fc435b5713186e04f48e53fe514466473a48ad5e0afad0d22fe464e113b733aec9e1d33dfd95556e546977a6b907fda9164c86f41dff868d4a2cc3f6fd342210a6de76e74742ecb52b84f482aeed8d2e0b0a843cd8167b4554f5d6882620e0b4f1feee1c51202c3b9623135d41499777d1341f63730f98dad4702cba08838987d86618a0dc670de584e3cb9602dd2f60278ea817a31d7f311e634d3f4fce46408992a463e8d153537aa72ed50c22cd6c76648191a9cb4b43de2b9b2f030755b807abfb879b08abe7a8b15adcd9efd2f419214fa5d02d099fda96106918f0ebfa087f6b72399aca062f7bde3577c44fd288090b9ff2718fc6f5cc81f70d71f8c06e27b949b91c969119a7cb85a4779bef0397a4d2a6847b49d128dd31f229a547af63a7d20a9603285eb3793ed667b25f4a22bd8cd94344665f31a8d872bb09a3b355b08b3bf759b436cbc36f6d645fc01dccf919454235b8cf509b7bf97f96e2d82c0396dd60d8a32ca53a9034f4fafb590a6f8b3f02e3d16a26bd1d44995dc923077293887ec5dab71deac2055a37fdf727d5a2c0d216bbb5a25f6fc5c81c3b0c8b25375f88a324a38ad92d72a73d2771886704e24ed7ab53595164a7f4d976600e0d3670d51fd1f1a08937a5247cd7314e78fc85a6c692cee5b1304c70efe0cdf3be30823623953ef905d37831a080ae3ae1d0c261f7921ab76752f4c9863d3ae76c166247d9225b19a416ad39f7f46ff197bc95f695201dafe676fbeb3ff3afa2700541928f23f11d59f8a56d40abd6b10086d70ff568c1b39d83a811fe4aa924595c69a33b636f0410f8da6c59683048f6a63d011eecd0fc74ab8a8b2d7759b68431f7539de4e7c40fcf22a15cbb3d472dd20a31d690bd6960c1305b0e61a9ea2da6d693234a815de4e712aa7a38fecf60291bb0e0af44276a434b51045e65ddc0caf75b30495e146c0076fc28951d7396a23ecff1690e2f8a9df059ca0da40a29d8afeebcfcd02cefa7cdef598a2673065f7d55b6617884c0ca418d14c97d01e4c0aad582a5174c09a21638342681bbcdf02ef3ff5f56cab237594556a4f9feebc7b9efaf166d36bc7011be23886a2c89e590f9ad2a4665a5ad51420b09a07509e900aaa4fcc6cf6bace255fef4d89c31727806446edbba51c733ad0dee32d9a52a4d62c9c96186e1afd55d35f54ddc21fa980f771e17fd7f464b71d7e34e559d518b268791aa82b54cec4fc2aefa34cc66fe9f21eb90948acc2d776c5d23a365ffcb4604ee65a09cce259c871dab39b04939d4f3d2252a3d6469912e6512ff7a494d00d8ab8977f1378479cdd5919a687a6b4912af46667e378f6cfb89eef63eaf08aa5d3f5d83c90698ba63169104be2a28e46f9d1ccca709e07c300817fd060a676575d6376ea8deaccbe2eec3714954f953826781831caf5d250305bc69ba1cd8af664d439f6469f65361e3f5fe870b765f99bf9c840aaf472cbaa57978e18a7beb1aab7b3231d10ca16d6900093ded5c8ac76aee2fdcd208f7cc7c79c678fa3d3af63e43f8145e12c509786de6d484f0fa9eefd4df65e28a412244c366997dc906ad005406191314722626a355dbbaa982075b3195b9164032bafa294351f3da469060dabc9ed49a700436b28e33ba98eb19575777b2f7c31d1c01ea89f750fd13cea775b223d7b33fb72787d312a5491919dbf1ccbe25364a6509a5cad019874190a2d2575fed1814db0ec63349e0d45da1ddef18ccd67bbaecdc0ceda6c4c73f713e4f2b0720eb3d477f514148f2dac77cc50ea510f6bb6e89201ad4cf91179cb56daa171bd244803fd2d2552f81d9e8613b9a3991e8f85ca405596250eaa741554d424ed40359b7bc9a29df7116ecf508708d8a70c87b12700480cfac14a3b6abeee0ce1529d0c8dcfe84a8ba767e9766247665362caeafc4b9bf93574c0f8d4bc94f57a85d9f23efc58c769e716f0d7b18b38dd969126f4e713f05ba71906ad30b5673030fe0d3f82d874741e2dc94bdcea49e102bb80bd902fbb168b3121206e194f44db5ef0cbaaa3198ecb971466d128f17514082cad26a5c16ddc9f61d81b789669510a05281c338443b02ba356ea887c02f265d9a20fafd71d0aa9696c1aaa52a8b570d3cd3aecff66d6a2ccbfa71e14d39ae6967ae7b6549b26551f919039be40948601cf75d580bd8a7273102944d505d98476373841a91019fc58c72deef72db55794552bbe06b99a0cc70b545c257c782011b8a630ed5a1d31aed2f71d4007347e50c49d0273abc634d77b7fd732c570ad83b5a1329af2453f9a24a11729b565921757b15b24342cfa0cd68926626d9e98244eb7266bac47dfe83ad32ea0aa6c2a71c6c6efeeb6fc94dbec2888836a0c0bd14f5ac63c549c1df54d26ebd93b57f93f3610f87dc19b15d6178e97a3c87c56dcdbbffe2ac6cd828e1885083843d6dff6883b91a547ed66ab4315d7e3bf72e89e3b0df85fbe1eff77b8841b90b0fbc491812b75ac167e0df33b212447b539ed51626c3998d5bb14679fe39d2fc267375317eec386d12d9805e45bd9082d5071eda545451d9d4b94201fb5b5f6623d970d3839e46f870d7fa3bf2d2cf7ece93865c1b6f4070c9bc30c2f29286d8eea729414d2828c3827d7add206c22b5d8fdf7662574899c10d0b4b4119824dc80a26595b4b44284ab1a6d95bf48da05e3f6e8deab009f70f621e12f36a033dc1bf7ce211100ec76ebbbdfbe1a8a296bbf3c96ed2d4e91b99750437ed50e90544e3ace2456c7fdef5e6d81d1c69797041264507da352e9f70ab879dbc4a2aa073630b6d61dd0e1eaf5bc215510ae551ed0523085244700d8bc42a08c99dab05caa6d4e00a0a3e974355e6a5cadd6a86049843e8fb4fde1fb78042e83c43096cc68ab22b2d41b945781c674dff62520d3cfd4c3b0a4e860f9d09f8e28525c44e2b32684d8ba68228a3f1f79db0598b2eea7bfbb7a54fc65f2cc087f91d4a54e89c6a9189994fdf807cbace173f150527f420940c82246dec163f6bcea0066dc1b8e42153a9a89cb87335bc874fef74954ae10fe330651d32c31fcab4753b4467cac95586300c264136f8f86aac3721fb165a82c392b6da093a4bd54e33100ea7cd77b5bdcf688b1fb691b474a10e0bc36362f743009fa6915ebadbfbfa27399c3a30f9b29b48e39e8e76a6bef920de3a577815323725370ca6a62de58f815237acb314bc3c4ca763f5c260923f0dd2b4fcc6727a4ae8c2ebb1d033eb969abd7476e90dc6b40b54f75b5f4d6d755679f6c097e07701c7cde480625ca923bebaa832bc17a6abed269b7b1d3601175c2ff6cfa4173d50ab54b95deae539126064ca1a69a831ac4bfc2c9cc419c6f1da7572f51c8bba7fb3d037affa75e8ab2b6784f404e9ef50f7a76b9382874b24086d5b5dd1cae9402e611c095147a803f5b408d27ebaa9344cb951fb073666fd308fa5574fad15542780583e26b83691532044687dce16a82ea097c13d339da899a5ee02a245471ab91354bcc2c2bb09ab2a2b96840d5447ba9e3b9db177f63bf1894ab759a09a07adac2cf5ee978ef0898161ab40e666963976b0dcbbbaae8a0d7f8b28014e2ab39cda053ec7cb75fdda214d03f85c30d9d137083645ec85d70119906c2768ff392d5d959b68e4eab28b75c719679c43ccc65060d8d9d1b55c56e410b12aed573b2efbc90729cc17c11f1947952c29ba79945746be3a3635cac70dcbef9b5f95315fb041ea28586d613d2a976d82d348d960989c7b9416fb3d56104da1640d95f0c5da8d32977131dc41a05a92f1a6876ca043a638240c2b1c8521f1114e58f57a865ff75144bdd08549c83c656431b680ab2e57f55501308f826216b83d7f2320258354c20a9069262030e08e266332394343063bcea0cb1869f329ea267c32697927595a930d79d6af1b4d0ef7eb48eca60a66b0b9295e281b05534527954935a783c0293c7338c17e95ca0e84642aee0b1357adda4264d19d6dfc5a1649f6f69ab5b4062727281bfc562602b8d23866140ff820bae3629c28d2b68bb8646a8a5d0c0a6d6abfc5db6d6f7f0675e2e502248d2c9868363efc858272cc637ccf66214c602f0155d3350d8410b27f835f10d7ed7480d38697ee5928580aa4dc5c25b4e7e181bacc5dc94f96cea194f4ad582ff9082e49f43af951f5f2c72b4a423a735be5b23a37b7e502b1d730cdf511b5f5dcd7e3dac491dbf42454bd7d0300ea2db54c07c732c435c8b390eff7af09d1fec7d4183f26c11af6f06ae8406eb3dd61b8993afd0aad49eaab19f9b363acc565df1eee863fd0cf189ae9e79e2f3402d5ec19a1b0c2df4f3bb8f8375692869e820a1e4071468c85bbfd561e1a4210d942e844c1b72a9418b1b4b3c4b0f6e514fe3b0f75e3d7df746e95326e400608445dec5823810557eb56baddb365cdba4e465f2498abe69cd50b0333f3fcd2a4d3bb7d1b6be07dd1f3c90d3cca5f5b3cedb97379673782f5afb23c30200254a9a75cdf945525320029f7e8c5aba45dfd5b4a426f346603348a66b7dd45325074aff4e140b03cc239486412dd0a130e3ec93b2648b95569ae55e5782af05d8a8b5c8d165c407816b8eb5e89adb271a1078cdca4f29315161593ec86f7695aa968f2a69bb072521c766134d9c1521b5d49a368b0dd00ea3b4e21883f7c55692d8e3576e981f7ef9fafcb12635311778fa6e9bd7544bb502e310113869bb2bfce131a771b67463a1bf5b20192031a7780c14e21a74d4ac5a0c4468a45998ae52267d49a6543212b263beea088dfe20227f97d1cc1a3b935bbfa0e11e47548413496ffa2340f37f578504f289f12a5fc253e2e534f83ed2878e342bd0187c8ac168e856e6948c8eb949d48e53f1c64266341a91c7995547f441d3f2bb7db66efd09c7a336cc28e97b5ff1e5c83d05c3e68257ddb2587b0c20338833512dcdce256bb41df47f9dc594b20b7aed566d3457cd3feab6a2ac54021ac61a65b74cd51304a8cec2aaa0fb13195fc09a06d571fcc8ac066797be25754ce7da18783c4682e93cabb272fbed2f0954226d7871dadbcc06fb772436fe4bd5e79a509dc33a67f4fce731aef9e25e456361ebaa95b8dd448180b3780a57dd5e996e155fc12aee0a1488e1db16efd197fe3307bb1ef3fecc70be961fb5d83f57293b23897427d8b7f44f79f3825b0898063c9be52e8430a4fc6556380971e45ba2bff87d41f30cd85fbd33a658c370afb29f2c452a9c176e6b15b85cb999c849c3e5fcc3571672fb1ed9a9b17e17dc9252ab1b6c57842adef570518d2ddb9ca68fbd6cd6864f0e18954a79e2d97b37db47861208d7191933965b8a66a8fa2192c2f037065395f2891d485e7fd8fb3acffbe35e13ee5fbc7ab4bd4b856819d6cff8e9b46054c1b17223b7dc16a717a0143c327abdf0ecec7a56ce9d4a42a187228d4764f3adaae664a80ba167ef9a42fe98f9e60997922edf02ab62b9515f3cc04cd742bbc0a01dc5c03a9ee981ff70177b153af6073d60b5dd62f266b099e2a9a4d4db5228b51cb37a70492764ccb4e974c7d238592775eccce4089735e69dddad210d68642d0821c9c1ef4a9e69d5f508ce86029fdc4601e2a1faeed4a8bbc0617640f5cea6be06ffb02722e4f7b8b02d33806acdf4fc6050e20ae54bb1955aafd407a8be9d7518052cc5fc266960839ca65bbdcb933b36eaf9b2f61c37192e6ba5effca7669ea401167af230c9b049bd02d00e1636f21319cdab24c46937d04d8a4ddb41eb1f1b2f6031fd9c8380f15b2928f042e9645332d6e0bc2f55e06778108ece17ffb8410e7035d2c12ede9ebc2c6bc81af83b27d172db05b66b10c7c7f48c57222e1b3fb423b0daed330e204543b2fab0fa9aafff31b387535ad322f5743c7b78d82c9d6f58fd85711958fcfd2824474818828d5ecb641bdeed41c988b6eec5c22e015bda7aab18ccb03f23a253bd3b94ec400a67d057cadeb092167e45001856df35eab6ff950e9ec80d4563255908a7ea09a5ae5dfbd0e3c2944295709ecd7e35957cfa2365934f72f6559f95997c60304b0a98e3c9e0dc99ab7ea6554bb0a2611012ec8ec892231dbc8ce89d60f3bb9b375718e460e7b5bd1b8e84631e291a9bca5d940ca7b8fb92c4baecb5a6e464d3c3b5aa5c12e67d9e70650327b858ebcc9d5412f7b492ac0203d7a82aabe23f8aa7776d173df7da0afc42147476614d9111d3ab9e16a2c977acd6d131284d2fc8c0d1af94dd6191d6a84344bc47221354eb9a2b0b9a696572dfc2adebd5f9177abda3769f294babd21480a6839801474b078be2982828fed4bb25781dc9bccaa624f6058c722eac222b4a659e9489edbe819151088a9e7aada910f638ca1a248bc94b4878b1031a41c101af8f35c9aef1f28de95ff9677f064bb695651008dd14631262188ce64c392f192d6efdf8d4f19ea9e5ffbeda1fd8846698475c057567d1f2c133c6d548f3e9722b3b8b9ec6960689a54e3457da2e4ec2a9b07cad6b256469eab859e2e8354b27bbb9f08c3ca7460f9ac1863a0d05133befb41a245dba3e136fe3e9de43e1000316991d135c420006cb6b8ace8eb6f74e97e484bb283a123132eb4976f0c665de5394ed0b02217b38bdf8c89b9b0db4eb62ff10e98308e9ce8647200922d8d790b7dac81bef8e81157bfa3420a29f5124ddf629161d0b986616e8aaac86278b838b7caadd8508b67b3796c8da1c71ac82a89c2fa7a8b40e2ee560c6df7ac42cc99e51645918c138b2fb62c7aa54b15597b8422142cd49a776092d55f92d5dad31e417844c63b31e7701adb1fc46ca8f1c24d0e24cc40fe7d9daff7deeb63211fbf5bc1c123c02e2cb25607821cfb7e67028ef96056f6e3f81f72db1bf8851432472936bbc6476d9392204247dfded154f8863582e44f0acd0a30a3bc8a38ab35ab744b73ca18192e42b479904bc5abf82fbb467577afe7f53632f5a8e89be6791a36b49c326f54412ac310f1fbc90220101c29f748304f9496740c3eee14b64c8fc17155835275fdd380ed620c5abc8a4160f18375928c0ed6a926043eaf91365dfdefddef93f0c8edc3204a6c47c4e9115755a24c31763efa78db17bfaa9645aabeb4972c89a83e02383dcaff8714d79fc6df28563584f79be4435bb3632958eb98649d214e3e136d56b460bf32ae200ce0fe980817a2bbc78112c9969c70db679c2aa30d73b4cd753d127fca8a62042ce2dae9cf035561530b3e2e95f60fe74245ce293dfb62ee0ed4ba29122e130bfd9a32626fd46ac15ce11642d63e5c596b7d23d6b142392b59998dda902b1005f23746254e625f4a70103d02b0ee3cd7cf6f19425188d040c9ed7f1af1ab9d41814211ca770b40c250364d4a26bd0d57b4d6de3d406c8cf54bb912bf745b8761c10c6ed566edc96e82ad8e7a9bc3fcd22fb0e3974cfb4a81886dd94579a2ebe3c14379490aec6fd8bc8bcd7981094f23011e1690bf6a853b474acbb9539a6631f51144498802e9eeb237d8ba7d11ac050c4a392129afcc08ec36aaac6cb7b82a482059dd448f61a9b25249bc5d4e6be5517baf5f5e242693de4032c7083eed7fb1803c6d5624a7e32d3107bd7a3939cc89a083388264bf9756396a45f40c30da3a0a67d4dc1838b20a098e5351a1e4f344dbbe00dcb86780b34031a24731ecf7e0e6844097a8e62b43267d565dd6d3a4886e556206789bcc1624846430d2de342f54d7b8e61d264bfa39528a1fcc63532b291c994c2ab7a217a4e347a614aa9e651bd234eb0c3d49830a6a804e7ede79c77959ce14f731d361f1504f6625c8289222276f9bceafa3281738e27e15f6547884c9ca7a5dcfe2501594d57b4c25674b575507428defccdccfe69543a182575dd4817b9179d45eebf5678f6a340be93a51d2e263e764839c2454a10bf8853fac76861923dc3a5d81abfe6b8ac5e302b89b6057f782aad54a65adb51ed77e50d3a366db9d7a20e6556de4b9b77a9d866d3bda9f4a87208766b0cc701414a4deaac8da2acfe5abc0a8294b1cc8edafc68b3a95d9263f1499a02d63c93a0fe30633ad556a5b446a1078aac0d34309b68c23dcc3d7da53e7f88c24e6ed76850021ab5ac808d6b1a412173ca93c1a3db66b0695504c0151d21f1e65e417d5671dde59931256ac4fde601a904450d4f2e168e917bf4438d900cd17f571719d60bb900fb0e0740d65251a8a26ec67cb3ba4b95f2ac4be486745285e9434dcebd8f7df9f743dbd36530d20396497388e26286a2a9bfef575a2269ea85b36b4005d294302aa053758620abd37177996abc69e2ca24b121f45b32718a095250c0d533a89785fbc1692f819491b3619b64e09337ff2b31ed0a98c07d47c910346fa6e43ff4da9c16a43170fa38ff7b2f735c6d7ed6697968cc2792e9ed4c2cc9957e2152c773a23e109690d82876021b20bad6dd3833506b1e2c7ddb85e8fc754b11f0099c558ad676db0d3f7a7c70b38e38652fe97203085488d82bc013586ad8a093aeceb4deaf2ee93bc0e5f3123292708627ace19965a0fb622f8eef55e33dd21febe02b2fb6dc2025e9c9a099ac9f0603aa645a573caf5b99f8eb03b28e881010b96d87e1ed55ce341d875edf3e4221594917fca299d02b1c3f1990f6069f944330c3b2ad9edcba7b48640f071c6bcd7c1a94314bd8615a6f6493919fc216a555b9dabca0121775f5457e830ff6e6eae43268bb92d5fecdbf8832a37b575285b1e113b29ea7e5f8c955b2b3b16fa963985019f4a6cac17b51fd164b85efd22180221311ba043e1688eed5a265e23b6fee64a6800737f859959582dae01bf804141b7dc199139dc15a7920b76551c9c0381e120a166e200e20e3b2e0d54267bb3c9f69a0a374af5f53550cb6ae690524fbf6a6845f2df5da2d7e4aacc218263bc216db4be3c269ae52b3ee1187678512ea1324b3e746df8c76d52008263fd5ed6ee3307608b02a7e66c0c14618d3cc34a24260fcb5c7819db74e65d8eca279451104abcf31eb27a7fe64108c7261a446b472fa45a0c29af4b821210c8aabe6c113d292b6300224dc2cbcf946cf2868a4749a156c578a4a6e40a2a7bc1716b35880eb9d87a4748ae09673465d6084beef0010d1ac1e7e2fcbc6783d854f3927e6d51f272c52bcc6bda16d175c4b0f2a9dbe8437a34789c0343991526bc83bdece3c71963f86cc51183a34b71da18c6f995f91aac176268d6ac0a3a2acf2717801ebf23b9d91926e9144f380ea1d68711d9db0ab45e3397dbd39b5e728dcec8d55930f6f2affb0ca12c5834434e1f82adc0b7ac0bd9ec0d4179d36e2a611ede605a42977de8a9e466c453479e0daf8b53f8c04ba0eddac3848dd88ae4cafeecc59bdec10b881101c883b61b82153bc8cf58c32d180d4f46da8f40ca6d8a3fff5ed4d5ba16a1fa73d4e67d00cfde8a915c23cda075f4a88dc0ae74792bd8ece23dcfdba72f56b9813b0ebd559de901dbb27927b10cbb56d57e3b244a9f5c248ac8d85221547ba9dabfb3dc68cdfd7846524e752cbb7ba1aa0e495352325399e03d91a7016e3671fd964d591141f42588c3501522fa211cdffd484c7889c2cc6716de25654169609657a49e6ef1453006abcf1da0dfe1572ef43a2082f91f230b0f14ce55a6361e8650b9212266945cef53747caa27b2ecb733afa5afa42bac3afee722206d2d0c5abb811c7ca46af876947a45686fa1f11f8842a6ba14ef982a40f5138bf420c6f7317623f1661f5435f16624c0e51a7a644ece8fe3d7f84af61a410787b30feeeb966d0cadaf4cd6e8d42db0682daba7a65e6a51434b843b5cf75213ea4427e0319f4efbc7d18e01e7f39891798b254ec5fbfd4066bfa49c634f8e3eac8fb5329aeb7439cc3be9f123da1cbb918809e10bee97bdc9fa805b06c557bea2210471fa33078f1a7aae9140ccfd98127b56044d9104e9de5fbcd682859598eef0d6f9ca92aabfaa9ff70179a7b0eff8bef5d45379521f1110e8db1ee03b96d579764c8c344c607d1b09149d5c5275f85a10d8cd9202c9400ca2def4ad6791075291b4c3c5df3ca26248920d3f7592d14d56435f07ccaba5650dfb6a88e4e39075376402718a3dee81eaa499f230eefdac7766ac46fda3d15ce50ba634d5dddaa37ed8b27706fcddf241b4264b637a0ac3315b33d20ad2b4ee78abe9b109cd2ff12910527db052073acd612d2f8b24c44a0d791c8e458cb4209a578c5e54059a80bf078ea62f2f2bc07ca3c0917130c0549bb4528b3d30787e8a58e1a4e395f797ee1d5313c861adb3bb2a1d5a92c063c04d8e4ccba90f31d32705ab9e5bf9937abcae9bd411316d6fc2b34c08eb4c4047768a720a11c236f83a193eddbefb5f177811e1cb748f7ef1e699a40d95dcd4c155bfe82b2458ffc85aad59e9a7b9a38b6f8e20129cbccd2d776df1d26ee861943082701bbb4b69249fadd8f143b597ac73e2af5c1c556ceceb7b569011a449af0c305c8dbd28c6f9703aed425f8373c8497188b2b9eb2f5712de794c52523a62778e83e0e59a1bbc6f06f97b7789670f0e5039ea9a1693cec364193e49f8f6aebf42955d1b49acda6c888a3a6e2ee274e372a45494b4ebc0b53de00bce7dc1119c28cbf0648dc7ad74c976e4b49b1e5ab6d372fe7c87dc46f6965e74bbaeb7789ee532264e843103a39d7a9d75132de7e9b5ef02a5300b1c69e8c2aee8a42f849b56150603e0541ba97656911b2bec92fb6b80e1c47be8e25448c1ed1d4cf6851269c121e1c45e3cd58292714ca7a0afcd8c6813dc142ef09220765bb717aad3c2d4dcfe180e8c0ebd8421df820386626d3a85e4d29595e3312d192af31f4735e7352203a2b3e1ff5c8712247ea76b8d6521328a281bb01550134b55966c3b8a175688d7fbd913988eb37701053abdebcd07b2c0ee2c8b44418ae4ad010eb4a16252265eb6e1ffbb8238ae74055e93170d5aed29599996d1a0367ed61db81a131b5b163de5d6f2f693055b87a3cc7a0c6a15bb72ef826b6c2e89d781417985751c1fde5cd6069167fce4c56138370bcaa24d03ce24b9e7af1b72e21a2785359677d3c101e9029b117a43dba617f8ea56d7dd022d8d72d2e1c3b68cf4b0e8bd492eb5741cf78a3b36847fa0a97c3304a95dcc7a8a1440e06ed3926bb95bf57bc7ee2c9e5f1c5bd661bacb1ce5a1a2deabe6736c83678a6abd820de07f5cda1819cd9a9146f0adf4d8dcc60e3cab50bc5e1abc14891e27ac610ff9d6969b53d28f85a92a1fad522b3bee82f76f010f45ef0a3e4c7c15d7255670d53527fa3d330df360ac6c98d49a6a5ce3f6fdf1de4ca700844c8fa3e1d6d6e357f1227e5e942653388260e8238b5612f4927fbfdce258bc4d0b7594354e9dbc5cf7149868716b45dbdecdc449d0b39b5eafd69e3b49a7ed29d4459aee285b863674b89d72cfb884f5cd5eb523ac0b493661c58f7851c5547a959ed81819532ccb84729849fbc4cce0ac5f8236350c3b67b316338e15cc62b954d30b4d23d76e2a5311b80e76c5229b01cb07f2bb85646fdcb4c62adc82f198b047bebf661b595023ecb62cbf93a64d234dbf889166b48fa8eb394bb9fbe841e1cd5660513cfb510f00b170cf080b3ce2b850cec369d35c0550eff4b8fb2b6c91dfda8b9d1d054cf18aae245c31e11c0d122ed5aadd8a3b35ad39dfe30ac8798a46d23e06b5899ba907b2923ca8677fa61c7f0943cdd7c1c40def5d83aad630bc00eda293692db9029a74afbc68d94e574decdd9aa7f388e8aa344379018c4da18256e6c2b3b309d75e679971f71daae4c69990da8707c359765d2c1f74a56ea45a25191b688dbb1263e7d9c8898a7356dd313e14f066a6db7dbd8a05af623cfb142305d9daa987405ea5c478cc99679df047e1cca5d28b2ae550d88570c9a7b361b462d4bf0f568c72724e502c4ea615d9bdfe76205af4649db3381bb039a6c81d5e7387afecf394d4357604f540282bbd442f84f456c5ff810d7964941b31bc12f5fb19741e64da87111ec3ab8a714db12a89a0e19cf667a0049a9dcd72aa5c30cfe041c4bfedd2c41b363acd3fa746b0e65820abf0259851616c147a62539828606bd4d8149d173108a9685d7305f1624dae2c8dda08e475d90494ff93e3b68d3f06807c6439794cc48e36826e845ad919d758bc0308611f2b2063f35e59badcc28b94b58b11fd0a16ba852395acedb03563c9992103fbb9c4541c0140ad25bba0d98f301ca34257ff0bb60f8a29e8c2e75569f12c2e757cfcd86d441987754db98d33a298ca334510f32a78b0d175ef81aa1283c5a43a0a36997337a188cf28df16535f74d7b79af5ab10020827567deb1d7feb0d68c97b24375444fc91685c3d4cd31ecebd30f110cd6faeddf43207ca9ce128876a99d882d1eb2956ac66baeb680dc9f243390f1e5ff5f824b3558f3a71558ee2b76b6dc85b8767d8c3a762a9935e665b8a599899c0e0c8e20d270917ab23a1c5ec1392031cda458edb1c95f47f390439757563240273ed69da2e0b49158ffac612227c538ab4df21e668f072475199d7a5f1d5702a5e97030d9b85af5459e59ece07c34cf4eba44fc2fbe75f388eb334984eb5a14d76478f8c7e277804cb813c573fe185e1269a0b1b3a7c4af2934a43425a844e0cfcdce1f191ac83c216aabfe9cfae82ad8fb80e5ed32fd953e4182cabfe4bca69a2b2d26394b851e6f313fd7400e34f66b207fa7997336eebeca3c2b2abf74149413af897d503c0940a04e65488cdca79adc80f3a1022fa7358cc7525792ec60cad166479beed863fbda52d75a2b3b21b44cb3948c74c3e5084911fc1e172537175b5cb1ed8e0ecae73beda0b4e5e13b826819e1dfeab3d9415167f1a6ff33eb7eb3a208895a8bc11057d6fc0e3d3cde21eb23a9e8f0428f6bd1174aa697898fbf5fa97167d246f94cc930add143f6b5f3ba1c189160fe99630f10f3c50d48328bf9fb5ecc61598d7fb7da587fb7821e4b42151afc4ee36ddc396d879f620ece1af21eb2ae7af3ccb63520b7e5c2c86ff9704ecfeae2897336074edd4eb00de98fd3f92fa28e76e19abdccc7183a427e0f80f25450992490329bd6b4a05c40900c16d963d4cb2c32cd3b866fd2704809359d4e7be2b4d3c9e0d278e14a883573033cdcdbfe68747f69c02a77aee2631a53854877cd977f8ccc619d317d853e8a8c9605c6b2016c5b77d1ed0ce2764865af5bb58da60d25fe091da59c8e15c2d0b67a0c03241ed38bcaa08afe611e374ff56a04b1493496c051d3aca70ad269740eb8143d40c40ccd9655137e1a797db99c19f0c74496eb02e8b3f8186945c665a130ce8bb5022be66ee9a82d58bd04995dc2ed8817b9cf5382bbf5437a38dff5538c0853418b67eed524fd6ec6730af6e83fd7e1819aa9a9126f1cdc86e71f3a885ba96d926e619de1dc600a5e917556b59349709a09202f42f8ff596b8d24eb09eadb09653801d690e24c1fd00201534185e5d76616e75138866d0eaecd45ba609331dc127de1cc6de73bedf6c8857b39d7bca574d5a959fc5edceefdd5ba830ee696fa281bfaff5f19f03a517bcc14f2a723cb43db17bcabd47e25adb0dc27d1c5a063dd640ee22ac5716a1dcdbe152c27c2a4925deee344080f378bc7003840f1d01437c268fb20c7f9bf4b38cfaf1b18bbcdf7368fc5130157d7beaaf191672619dffdb99204c2ab01b35d299c29b4a4c9f1364570df139c7812864191abffe378364b46c4929ce9f354e57994e56ef8690a9adbdc6b76b343cf768eea6ea9430c144d1f833d2fd19f01a0b8d04e09ad0625c0c1ce47f182f63a888b037893c449126093bb70f97b7360b903cc02705619b8232cac97ddb46b199bd616225b980e5cf964c647898a44241ca45335f679143b0097d4869d4246b117a38112ba1bd600e78490ba170a1d1307043f3c83c2a1a2d50106c905f692aacc895ec966a4c41045525845352c1993f7d5fad4f836f286565c62b9051eb76af4a34d66106771401412f01f4ac661594140355541f815f5e1e74903b7e76d9101f61768d4ea6470dbf632941ad250c20d2ed9b3a9b62fc2ae398c0e5cf571d20737997ec5f0f856dc59a1104d5b153b9377cab0a3107dcef4ee1c340bc27dbed94378bb13a6b1bc1a61789be9572d1d42a7611338d5a3e1351768d00e63f7f71c19b7eda6368a15dc5dc2e7295541888e60738db15b3cb792926170144788ed1842d11dfc84d06d656f2b8390e11778066f21d5f97626bd72b475fdc4642f1fc647f5c8efe7acc255ba6bab55d280a095546aaca38921df154937581e52b4f91a493d81e0ad67231f835c8f1ec2fbab50d1f4e5e324504000ee2445b037cfbfe23395592f82f1ee8d5eab8a2277d8e3644908c775b16396774338f5d3781d0b608c534512544055182fe979fc8d439f5c49b64cc948bd6f7b6314df800b2593788c198f7321ce6424338ef7e83123e241aa7fbe8dc38e7c1e3de86297b0f44eae74f3a3bfba8c15b94663e93067a575289e7034c8ca6fb8fed4d1cd87ab47f0d8959d60eb4147714356816f6fc95b0fb1f92abfb087f9fdd9721a6015c8f1a325c0740647aa9af8d6f41b9b364f2d6509cba4c318784ab5f98664fddd3765c9c21ac14cced443eed9254bb76cf595dd19763bdad8f3762118180c3305dc8d8d03f1d1b79383291c9b91c6dbed7b5868793a94d86f8cd0b02ea9dffb9285e8ae6dac04fec83f99de4cfd81186130119af91f3af4c2c2ed9556cd73b85f9b9c5c46848b51684d15e7a018c16c7433251d4e05bf0a6fbb1c22e26624eb356f2fcca2d099ddf402b0f28922c8be5276c406fb6a262a10e1ba2d37c1310d2361231644dae69b1d58f83ac1795d89062f1d20f1e18bc58a38beeab8a143d5fc410231f56ded5634a2b827172d7ff54326b4d9ba55aff6f76dc0c1d8abe734a3ce42369319689c90263bcf693655b2b5f73426e4299bb3edd27a61fcea84d6529f4cfceb408d8e4db8a93a81948458df679039b6b286ef8e2d6b392e584f7b652a19f302298f2ddedc5bdde42af0c1617f981d5942d33bd7bb436780ef950578799b9c840639e4d0d50fd3e6e4728c62c0bd0378096552acbbd4096572acb88eb8760d4cf5b9077c2871390b10c3af43aa24de7873c145ee0efb6549c7d6ec662d3d9dff9fc1daee14bac71957f258d155d3cf5351507f7f9c75f9fdf97501e0dd1b9635b59c05c79911a8d80bff18b7575e6429c8d709a869e6a06e04c30bfc1ae8163736881a06d397844694f4c1b611b2ec421e3e85830e6a0b06e76264103f4ec21762e94c3a6176166b0112f1dec7ed9cec100950fed6956966a114a747369da11f17dbfdebe6329cef69ae7150b9003b2a03efda39443b9958f8154c93127c2a56004f8d0a7a8fe5866174cea89697bf41df5e122ce7a23215e2d60425e789b4b54bce1b239c8f012e156269e109a865bb46cf6d615412a6bd1e060a15d04d386172988a10fb74a66f2edb3798cae4ae180cbf1b110e1ce447d1cdedd14b28e04b1008b7716368751b7281fd1c56efb14c4bda843b2e3e56a6dd167b08135c514ba440467ae403e0a65e81f359ae4241789416377f7502ce739f6c304d1153cb1b5b24b1d021059395131e760ff239f5ac04487fabed4265acb7f43a59c506c7f1424e8f6a02cbb4b0488a8253ed13971bd35d9857273fc03d9c2e3edefb781b52260a9d6fd63130f1b8d6d603e4247eaae108d635e2e5bc04c5382e5335d54f7383e77089b8fdd6652f3fef12fb0f70fb2a84d2affb193bb1b7095fbf3e5a3c2868086d150869804d22d5da63dba8992833908943ce66f90dad171afed9687c86989821940a117f7fc601ff678677a12178775caca21a09249da22af8ce826220eef839cd84d2b9df12a0f0ee5806b4aa19c173a92308a9f08454311921efda69bc2cf34f1e47081b44b6400291eb927f4980a94f3134b111a2523237c353aaaec137e0c9db2d33427135e6f8473ea35384b6a3eb206af450916ed1f3a2a2e0cebabac99e3d67147da573f39fcab1746d2a05d2e22a060af92efe969375113bf780682741b62602e90cb9b3a50132ef3443924c0b93b7f3c0ea80a6b5085704b4b6b823059acda09858529e4e9fb542b52c668b392bc2900378b079da08774a6ff7e6968eddad0fe921d39051caaf3c2bc2aa047102baa574852e7ee6006b57c070a434ba17e69fc30dfdd88fc3faf7c00465df22f22fc575051deaeb004427e0c3795eeb74ae52d8eb908d6395d93e90d93f113a941d728c7a186625cce4babf6d48d362e642b15f5234e346d0ad56e01eff852be1d4bad5da4d9f7de1734b52094a5045f4d3a962b0811c40148ab37a8cb8949ada98b9bb2ef98ef6bb35d733dafb8788f89a35fc734c6fae0c79c7ef79d9ab79a619331d5991bef608ebfa1bda8ea7044d7614459001856762aea6fa2a8da8a84f63b35b784aa95efd4cd712e8d819797cbdd2e5eb14ab8479581992c5cb4fefb71acd21259c98e469abb620208408b16c28a5b22a408e86745059c187e533738786c8321e62d655b7d42391295649268e33972b9b05e3863b06e927b1b6821f6be5c22cd5b323a71224eb2c47f79b4b7897332b4edf21831b271dd31265ea82a56ee423fbc51d625238678138161a8a222110d7fae260b0d53dd718d8f0bbebc6dbf9f7c603d0998478e02c1ccb514f538e60d2d42bead26565df3239926c7ab2ba9e2556375ccadb273ea32290f6b387130461ebd022c6b559d4485f2dded4dc866794b1952edcc463d2f12315dd333110aec4216d3ad68442ea011ebbf65cc03d6fc4def28d26af9c7b0ad6986d2014ee1c344720f7e5ecb5912d4533e3db9cec5abf3766fc37b0243c9147d7cdb24ab677cb997793184b4c6cbe078a2339c7050553debf100b65bf566728aa8f0bd3b5f8b03efad99fe4d86570c3792a009bbd23aed885167c0f33fb634f5b1cfad7cc130da55119a2fc282e4975d40204af4084a9df275310d4665d3a1c11e4bd872b0b47d822fefc75e8010f2766febba4206548da19e4e5998fdf3f51aedac8c428ebbe458f8039f8da6d996381998888846d7d7eac934aa1e7b220a2ccb3d28ae1fc383aa4319dcb8df1a554f98e360ccb1b630674b00296a7c4204e0380ac993850b4041042fcff74542ba713574070c2982e5fe06d1bcebf698d2ebc669f1e2e3ebbdb2bc9745616b0dddf8503965e9ce1bfbfbd4317ca3153cfcf2c91ab678671c0193785a38fd13243f23bacf712297c1c3161df42aa6f3b6b844e832587ca38676960f99da8a7bf1cd0861f9476e8148056dc7b1677bf1f8138392b8a896be86688d8e2a852c94abed179d515ea75cbb0f011445d07f5c149c59191cf2e949908b485c269c7237df230bcc66bff9d221b912af14af01101f2fadf75503b90fa5519fabc113be039f7cac24d6a425ff6f4d46c91b75ae35825e628b1d386a81c380eac87e5063ea2fb581058a44e67647606faecb3ace4f6e0b4d2504edf42d9fa09490ca53c59401c5ac8eae407a0f0347dc2b4da0319bb42c74cb71cc24608d303045b7c66b21c9cad1bcefa23190ea9008afde61dacb664d45941c172a551ede9e667bcf095113c15bac9217e1069c4549d3a6fea6aac4b9e2c07142170421a6f4f81ba464c8ddc49887d9f0f7826314b772e1df4dfc3f008e8b2348c48574f1c8e555a987162ec8347d793d0ceda160c9528e793bdc7a9b90cfd1a09081912fe24f588947146e99c46f58754cb16f81f6cd9ce19f510bda9ca936f1ce98af2c35aed4c87e98c38927a095b9b94f741305f94c7930f425336449c2c3208d0e0b74d37e1eb5c1c0311e9a77bf00bf103daa2dfc7d9c3fecba42d41c4ceb332133da641fb6cdc531897be0caabbc3261cac5c59871ca2ac3fcb9449b1cd3903798aa08e0e751d505d9aadec2d2b912094dd05c5d1dac0d405505faf085050662a9b20fe2e3398fd01a9b011dda41a55c740485c8aca06e52f2ee6f62f413b23f3bac9b4da3e93a2fb788f34c085be0128a293e650562444dd139b2bc45aa2064e6f945e1b4ff89ceef30842195d7e3766b5a254a3112af2adcf6c56ac46a4cbf93312e2b6b7ea1848185a85273a7b11251c2179fa802b543ca6922c0656320b0761b883b9c1d75228ac9a7e9626ec76a7c84820b2fcc13e833dd5bdd5d3c8ecb1745f50d831619c895ba3dcd24597bc52e0f6751ddb4c5c55ed2746c69088f47da9cf8450cae2cba98e8a997381709e234c8115f88327f3873e70e5742dba0a8a41ab4555ef75ccbd49812534d625a38086465c877f5cb86d90f3acfe194d244ff85604036251e73be7453d043470e8e87d1df11b6468631457a999e13d8556d28875f80d475e4080a822606e53ab4521e612fae27506829355cd7edd93156f5d3d78038a3cfa19d1b94502097914558d7a99d3e922eb6004f428a319dce0b00be94d9d65e8a891e0da8092cd17eb57f42719d72cf19e8b9cdbbce8788a37b61803d8c5ac9b705b451a16cf3fd4e44c98a697846d4c389a327e75e07e26dde46d96ec5f9cbe652798c6e4c4f071e518ca431ae4ea874c9bfb2c727a572b1bb77a3911ddfe4890f0f4cc152b67e4490953fa74133078da4bda050f5f723492d3f63a9f465a3f8352414b9b24747052268e8fd9f00cbf24109afca71dc10ec95847ccd30693a340ac17a1c438de32c9d5ec779ce6d1eebb68e4560bfc706060c2557d03e5fe55e5c76f1b0ef8e8b9102af101e1b660f5505127a1c513a7bf51a506bbd38413db23366690dde3b7a64ada8b38c3bf5f2a77c993b3c8c2860b9b6f661657e2ed9c3fb8a7f07b9f86e6908ff61e5040beeced096c0d29cb3117bd673c5a13fcc06788b4db4d7d24a2bb143cd675772293861f3b9c8f22a3fc7f39be4019cbee78624119caa0ac94b509b691e516835c3083a24fa948cd62be1d683b9cacaba9117c6a95ba3af20f8df2bedefbe34563d12e24a4fc1c9116fc4eeead881d06ea1a7ecfbe5b50a60da676d03906784c355c572a6639f35bec2e4ac4d0afad8f0b1a5b56f379ba18bb0f5a2239bed56845fe0dd02a29705d5066285c49d0f62b8b7703247af1ca3f3ef80c72d2e73d4b09270a3d63c0ee9b56b60d8422820e7d421d7cb5edd90bd36f171f3a248ca8ba22a0ad7dd4c1094a8d2cad3b5f58c2f6521a82fcda6145f50a67031c99fac5ee33bbc593f697857610ae607fcc8aaadfbfa9ccc8b666c1610fe58638c4b81b39d62ef8ef79e3f3125048e96e178148b987ad8ce75ddb86f226c9422f88311a1e54584d870aff1afb86ba69ddc59fe8f846915e32928ec2b41ecfdcec2ddf6e03483f220ce62505abf5bd046d810d0a73121491fc5845984c6fc49d95fff620acf0239fd782432147be7b01479d1bbf685ad71220496c5fbe49751f43661ad97ffa452dbbf443cb66f47d94219064c1e11d02a35eee965b7f853b387676a12fc1bbc92ca5e3fea2af39b5290131d48ae5ac6b8b3da7f8939f3416427aa7bf23c495532077ccb94f30ba0e14ae25792e182fd3a9c89a136112a7875a582877d067c9994fd79bcbb666c872f3cf82331b4890fd26895337b42a7f957e3da716d9dedeb9563d83c0644780a2f7dc8441b391e23ad1a9d86a23b965931c13d66e41314ea356c15452b8070d12716b40768018b3315c33b00a40c54fc825a77e16189b5850d2f5e73eeac5e1263a0df8a53ba2b1deb5b8e7815c61bd8d70015ed1912fa27498c3f78d908282db2e0ad1a117dad769c93569014fa2500d266959dc58f9d70708000000000000000009fd0f1800000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d000000000000854d00b20e0c80af58e7643d04abbd00763600d69e7ad33429020676adae4d4d341201b3e8f809b84eb46a8e2f6891931ceb07e375c8f7df92beae625f3a2193c77a1f5da52bf64dd15d0231dbb13c5594a519c0eaee2ed23ff4d269a9919b204c3aae789", - "activate_wire": "d705bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848957d51e11b3a8feac73e8ea58aa6824b7bba69e045416f6ba4a586348adc40da6e012e565964e65d584adbc7138f1d115f6e00c27a90903e6c1b1657982fe5cff556ddfc432a327526e84bbfe843070d8fca46b05d25b3a0dfccd406ec4f03a2961034010d3510c0a44a53e0ef4a086226bfa039708f944ce4da9833f1ad5e5f3000c0df05779c6f246b9d57721a604df34edb76ca1669844ee5799dc2eadacb6ba6d531254e954c09d705cdf51e5ea9728528cccc4af0bdebe4dd29853e8315e96edca63", - "ack_wire": "d707bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848957d51e11b3a8feac73e8ea58aa6824b7bba69e045416f6ba4a586348adc40da67d36f8f1728a75368594fed88f57f4b24113c1a76d673264b1a2b89c4a2333658196bbac6ca9362246f559f4c15d5645ef908635dc460bdaea0f3e8e860460215c584b017c77a3a2254a39d5832206367aa74cd262def4e8a819c60a2360b988", - "book": "57d51e11b3a8feac73e8ea58aa6824b7bba69e045416f6ba4a586348adc40da6040101e300017305b11a874bbd995b29b11d109eff432402136904a3bab1f7bd9f869109cfc100000000000854d0000c3500000c311000000000000000000002cbf141d9ad3b41938c25083328862acf711291bb47ab53b33dc37898ef9326b400000000000854d0000c3500000c31100000000000000001000362e42ded68912761a397087a1310af972f9022330fbbbf24c580b9e71479fd8700000000000854d0000c3500000c311000000000000000020004754bccfbb8e3ee44224e0290582ada7cb1dcb05ad968df63ac0aa01e81298f1b00000000000854d0000c3500000c3110000000000000000300057c23775d75a9e3e878e5e33ba9cf7da51c78a4d54431bc1981c8524eda36bb0d00000000000854d0000c3500000c311000000000000000040006bcd5fb4815309c1de8fbc2867eb93635d8764c565d952917457874fc6eceda1800000000000854d0000c3500000c311000000000000000050007e3ae54a63b8f9526f7955db3417b5a1f6743b99ad0ca7cc92c3fb199c149b76800000000000854d0000c3500000c311000000000000000060008bd471262b242310a4d1b298156a5fc3469395eb2fa3d28be02b88d8d0c97081100000000000854d0000c3500000c311000000000000000070009397b027bfa95da000fac9dac5d41825bb54a0cd0071a262be00d6d4445f3e2e900000000000854d0000c3500000c31100000000000000008000adb17a978e9e05effc0d7f4c33f7fb78898deec1a0b44212bf943818091fbc09e00000000000854d0000c3500000c31100000000000000009000b0fc65290b128e63d598e654d4a7bbe14cc3ef946f6fb6fe0d5b46cb9e0e2638800000000000854d0000c3500000c3110000000000000000a000c3f951c45254175c92549460b5a89d5d3c61a950e7e366f992dac9f7f7a7e6ce700000000000854d0000c3500000c3110000000000000000b000dfa9d083023788850024c7eb588fae1c7542604d23cc12f44ddd568c2320a754f00000000000854d0000c3500000c3110000000000000000c000e3aa43187fba488b5530dbc366257f82058801f6983509716a9e9bf00db680f1800000000000854d0000c3500000c3110000000000000000d000f7404ba142c5ad30fb6769e7f6a84843d4c437bd50254a72815c8b75c1deb0c4b00000000000854d0000c3500000c3110000000000000000e001028ced94a2fc573f8c251838edfc807be39d1b910d18ede82aa7a127c5888b3a300000000000854d0000c3500000c3110000000000000000f0011a6a8f52194b94c6d0b521756e5c2c20ab5b6da8a2483e44aeb48447112c0680400000000000854d0000c3500000c3110000000000000001000122014e1e38afe84f6da72dbab22108ec05f60555504bac35bbfc7c62d1ac3621100000000000854d0000c3500000c31100000000000000011001313667fb8817a03dd2f5fd97325ffaec561f597256d58742291c569ee251257fb00000000000854d0000c3500000c31100000000000000012001430ec738e4babdd6d0b18b82a7f5a7415074cd283260197c4efe5d439671c9abc00000000000854d0000c3500000c3110000000000000001300153e9e411c4bcb405bda68f31ef974cf91e5dc7a530fa8580048c4bc742805125a00000000000854d0000c3500000c3110000000000000001400167aacb62cd9c823ddf004ca04fb936c3093029ddceaef569190534039885b1b8d00000000000854d0000c3500000c311000000000000000150017a9e7ea66fc54f78b5a4441c5ca6ebaa742485894509dc379783c52d881fad9e400000000000854d0000c3500000c311000000000000000160018063c74693de53572427dd71747f7a47f95a72b83b95f62b60be7479516593c5300000000000854d0000c3500000c311000000000000000170019454a89ae1311664f5155c45544265e5b33911a120db3b683522ac590e64528b200000000000854d0000c3500000c31100000000000000018001adb1d9b5644e6d6b7befb0f222e567bef23ec977fd3d5a6f146b6b195f48384e400000000000854d0000c3500000c31100000000000000019001b54755b708745f22c50b1ded9d0f24bc9b87d93d95046accde5e0d0c1b27ee3a000000000000854d0000c3500000c3110000000000000001a001c1b29bd0c8af340c2ede7c9552fd9410b4c731a47156f54ae2fe2ef6ab170a3f900000000000854d0000c3500000c3110000000000000001b001d3b68781e8a122e02fdbade74d727eedafc80663a79c32726b7d555e730f6b00c00000000000854d0000c3500000c3110000000000000001c001e508a679821f8e0b736552664ffd03360eb8e08acc5ac5c653ce280885222ca8000000000000854d0000c3500000c3110000000000000001d001f599fd0cf5ce9487aae5390f0aa7e2f55bbf36c7fab020879c262c74f0659b80d00000000000854d0000c3500000c3110000000000000001e00201d35e8a88f39201d36bb5d8603822ee851e4f936cc9d941eda8f0c132be674ed00000000000854d0000c3500000c3110000000000000001f00218b0edea50f2efd820966577036831199400b94a6a5ddc1efde5caa77d770fd9300000000000854d0000c3500000c3110000000000000002000228267950cb8f56aee3a11f312d601c4e2f49e229db76eebe9df97b467478fe80400000000000854d0000c3500000c3110000000000000002100233e52b8a3ff4d5f870fa11ba4e1a7ffc5f35310840f33f01da137e5802ad25db100000000000854d0000c3500000c311000000000000000220024bb7507ed8a6155c9d7d58e9798f1822e143da862250702f237267f91bf1f6bc300000000000854d0000c3500000c3110000000000000002300253dbe9c22ae8f8733c3a60d21c30bf7e51a304f53bac7c4a2c6e3cbe61157679500000000000854d0000c3500000c31100000000000000024002698a89d04d4471120348d82a09c6588128fe927d38dfe26d98932961fc842764000000000000854d0000c3500000c31100000000000000025002736a20a0e1e99e0e8f0f9d389acb4ce07130e58276e847f52bf8d17c2bf36fc3300000000000854d0000c3500000c311000000000000000260028f5d5256f0c02a8e76d9de795b0a9a6624e694f2df4d106bfe32fa46b60400a3300000000000854d0000c3500000c31100000000000000027002908853d9812b63820a7c738e5fc15ab6181cd391a059c248b700657d4b21b4e5d00000000000854d0000c3500000c31100000000000000028002ab49ad03151b291227dc42af60dd203c373a9533afdc9875f4c02d08560c4533f00000000000854d0000c3500000c31100000000000000029002bdc43a956e852c354169466af263dd38c4f278c18fb54d209d9b65ef1828cbec200000000000854d0000c3500000c3110000000000000002a002c1c3da0878d813c406978c150f03595e9a26d582ad426d14ef279edd0e8f452fb00000000000854d0000c3500000c3110000000000000002b002ddccf935988fc435b5713186e04f48e53fe514466473a48ad5e0afad0d22fe46400000000000854d0000c3500000c3110000000000000002c002ee113b733aec9e1d33dfd95556e546977a6b907fda9164c86f41dff868d4a2cc300000000000854d0000c3500000c3110000000000000002d002ff6fd342210a6de76e74742ecb52b84f482aeed8d2e0b0a843cd8167b4554f5d600000000000854d0000c3500000c3110000000000000002e0030882620e0b4f1feee1c51202c3b9623135d41499777d1341f63730f98dad4702c00000000000854d0000c3500000c3110000000000000002f0031ba08838987d86618a0dc670de584e3cb9602dd2f60278ea817a31d7f311e634d00000000000854d0000c3500000c3110000000000000003000323f4fce46408992a463e8d153537aa72ed50c22cd6c76648191a9cb4b43de2b9b00000000000854d0000c3500000c3110000000000000003100332f030755b807abfb879b08abe7a8b15adcd9efd2f419214fa5d02d099fda961000000000000854d0000c3500000c3110000000000000003200346918f0ebfa087f6b72399aca062f7bde3577c44fd288090b9ff2718fc6f5cc8100000000000854d0000c3500000c311000000000000000330035f70d71f8c06e27b949b91c969119a7cb85a4779bef0397a4d2a6847b49d128dd00000000000854d0000c3500000c31100000000000000034003631f229a547af63a7d20a9603285eb3793ed667b25f4a22bd8cd94344665f31a800000000000854d0000c3500000c311000000000000000350037d872bb09a3b355b08b3bf759b436cbc36f6d645fc01dccf919454235b8cf509b00000000000854d0000c3500000c3110000000000000003600387bf97f96e2d82c0396dd60d8a32ca53a9034f4fafb590a6f8b3f02e3d16a26bd00000000000854d0000c3500000c3110000000000000003700391d44995dc923077293887ec5dab71deac2055a37fdf727d5a2c0d216bbb5a25f00000000000854d0000c3500000c31100000000000000038003a6fc5c81c3b0c8b25375f88a324a38ad92d72a73d2771886704e24ed7ab53595100000000000854d0000c3500000c31100000000000000039003b64a7f4d976600e0d3670d51fd1f1a08937a5247cd7314e78fc85a6c692cee5b100000000000854d0000c3500000c3110000000000000003a003c304c70efe0cdf3be30823623953ef905d37831a080ae3ae1d0c261f7921ab76700000000000854d0000c3500000c3110000000000000003b003d52f4c9863d3ae76c166247d9225b19a416ad39f7f46ff197bc95f695201dafe600000000000854d0000c3500000c3110000000000000003c003e76fbeb3ff3afa2700541928f23f11d59f8a56d40abd6b10086d70ff568c1b39d00000000000854d0000c3500000c3110000000000000003d003f83a811fe4aa924595c69a33b636f0410f8da6c59683048f6a63d011eecd0fc7400000000000854d0000c3500000c3110000000000000003e0040ab8a8b2d7759b68431f7539de4e7c40fcf22a15cbb3d472dd20a31d690bd696000000000000854d0000c3500000c3110000000000000003f0041c1305b0e61a9ea2da6d693234a815de4e712aa7a38fecf60291bb0e0af44276a00000000000854d0000c3500000c311000000000000000400042434b51045e65ddc0caf75b30495e146c0076fc28951d7396a23ecff1690e2f8a00000000000854d0000c3500000c3110000000000000004100439df059ca0da40a29d8afeebcfcd02cefa7cdef598a2673065f7d55b6617884c000000000000854d0000c3500000c311000000000000000420044ca418d14c97d01e4c0aad582a5174c09a21638342681bbcdf02ef3ff5f56cab200000000000854d0000c3500000c31100000000000000043004537594556a4f9feebc7b9efaf166d36bc7011be23886a2c89e590f9ad2a4665a500000000000854d0000c3500000c311000000000000000440046ad51420b09a07509e900aaa4fcc6cf6bace255fef4d89c31727806446edbba5100000000000854d0000c3500000c311000000000000000450047c733ad0dee32d9a52a4d62c9c96186e1afd55d35f54ddc21fa980f771e17fd7f00000000000854d0000c3500000c311000000000000000460048464b71d7e34e559d518b268791aa82b54cec4fc2aefa34cc66fe9f21eb90948a00000000000854d0000c3500000c311000000000000000470049cc2d776c5d23a365ffcb4604ee65a09cce259c871dab39b04939d4f3d2252a3d00000000000854d0000c3500000c31100000000000000048004a6469912e6512ff7a494d00d8ab8977f1378479cdd5919a687a6b4912af46667e00000000000854d0000c3500000c31100000000000000049004b378f6cfb89eef63eaf08aa5d3f5d83c90698ba63169104be2a28e46f9d1ccca700000000000854d0000c3500000c3110000000000000004a004c09e07c300817fd060a676575d6376ea8deaccbe2eec3714954f953826781831c00000000000854d0000c3500000c3110000000000000004b004daf5d250305bc69ba1cd8af664d439f6469f65361e3f5fe870b765f99bf9c840a00000000000854d0000c3500000c3110000000000000004c004eaf472cbaa57978e18a7beb1aab7b3231d10ca16d6900093ded5c8ac76aee2fdc00000000000854d0000c3500000c3110000000000000004d004fd208f7cc7c79c678fa3d3af63e43f8145e12c509786de6d484f0fa9eefd4df6500000000000854d0000c3500000c3110000000000000004e0050e28a412244c366997dc906ad005406191314722626a355dbbaa982075b3195b900000000000854d0000c3500000c3110000000000000004f0051164032bafa294351f3da469060dabc9ed49a700436b28e33ba98eb19575777b200000000000854d0000c3500000c311000000000000000500052f7c31d1c01ea89f750fd13cea775b223d7b33fb72787d312a5491919dbf1ccbe00000000000854d0000c3500000c31100000000000000051005325364a6509a5cad019874190a2d2575fed1814db0ec63349e0d45da1ddef18cc00000000000854d0000c3500000c311000000000000000520054d67bbaecdc0ceda6c4c73f713e4f2b0720eb3d477f514148f2dac77cc50ea51000000000000854d0000c3500000c311000000000000000530055f6bb6e89201ad4cf91179cb56daa171bd244803fd2d2552f81d9e8613b9a399100000000000854d0000c3500000c311000000000000000540056e8f85ca405596250eaa741554d424ed40359b7bc9a29df7116ecf508708d8a7000000000000854d0000c3500000c311000000000000000550057c87b12700480cfac14a3b6abeee0ce1529d0c8dcfe84a8ba767e97662476653600000000000854d0000c3500000c3110000000000000005600582caeafc4b9bf93574c0f8d4bc94f57a85d9f23efc58c769e716f0d7b18b38dd900000000000854d0000c3500000c31100000000000000057005969126f4e713f05ba71906ad30b5673030fe0d3f82d874741e2dc94bdcea49e1000000000000854d0000c3500000c31100000000000000058005a2bb80bd902fbb168b3121206e194f44db5ef0cbaaa3198ecb971466d128f175100000000000854d0000c3500000c31100000000000000059005b4082cad26a5c16ddc9f61d81b789669510a05281c338443b02ba356ea887c02f00000000000854d0000c3500000c3110000000000000005a005c265d9a20fafd71d0aa9696c1aaa52a8b570d3cd3aecff66d6a2ccbfa71e14d3900000000000854d0000c3500000c3110000000000000005b005dae6967ae7b6549b26551f919039be40948601cf75d580bd8a7273102944d505d00000000000854d0000c3500000c3110000000000000005c005e98476373841a91019fc58c72deef72db55794552bbe06b99a0cc70b545c257c700000000000854d0000c3500000c3110000000000000005d005f82011b8a630ed5a1d31aed2f71d4007347e50c49d0273abc634d77b7fd732c5700000000000854d0000c3500000c3110000000000000005e00600ad83b5a1329af2453f9a24a11729b565921757b15b24342cfa0cd68926626d900000000000854d0000c3500000c3110000000000000005f0061e98244eb7266bac47dfe83ad32ea0aa6c2a71c6c6efeeb6fc94dbec2888836a000000000000854d0000c3500000c311000000000000000600062c0bd14f5ac63c549c1df54d26ebd93b57f93f3610f87dc19b15d6178e97a3c8700000000000854d0000c3500000c311000000000000000610063c56dcdbbffe2ac6cd828e1885083843d6dff6883b91a547ed66ab4315d7e3bf700000000000854d0000c3500000c3110000000000000006200642e89e3b0df85fbe1eff77b8841b90b0fbc491812b75ac167e0df33b212447b5300000000000854d0000c3500000c3110000000000000006300659ed51626c3998d5bb14679fe39d2fc267375317eec386d12d9805e45bd9082d500000000000854d0000c3500000c311000000000000000640066071eda545451d9d4b94201fb5b5f6623d970d3839e46f870d7fa3bf2d2cf7ece00000000000854d0000c3500000c31100000000000000065006793865c1b6f4070c9bc30c2f29286d8eea729414d2828c3827d7add206c22b5d800000000000854d0000c3500000c311000000000000000660068fdf7662574899c10d0b4b4119824dc80a26595b4b44284ab1a6d95bf48da05e300000000000854d0000c3500000c311000000000000000670069f6e8deab009f70f621e12f36a033dc1bf7ce211100ec76ebbbdfbe1a8a296bbf00000000000854d0000c3500000c31100000000000000068006a3c96ed2d4e91b99750437ed50e90544e3ace2456c7fdef5e6d81d1c69797041200000000000854d0000c3500000c31100000000000000069006b64507da352e9f70ab879dbc4a2aa073630b6d61dd0e1eaf5bc215510ae551ed000000000000854d0000c3500000c3110000000000000006a006c523085244700d8bc42a08c99dab05caa6d4e00a0a3e974355e6a5cadd6a8604900000000000854d0000c3500000c3110000000000000006b006d843e8fb4fde1fb78042e83c43096cc68ab22b2d41b945781c674dff62520d3cf00000000000854d0000c3500000c3110000000000000006c006ed4c3b0a4e860f9d09f8e28525c44e2b32684d8ba68228a3f1f79db0598b2eea700000000000854d0000c3500000c3110000000000000006d006fbfbb7a54fc65f2cc087f91d4a54e89c6a9189994fdf807cbace173f150527f4200000000000854d0000c3500000c3110000000000000006e00700940c82246dec163f6bcea0066dc1b8e42153a9a89cb87335bc874fef74954ae00000000000854d0000c3500000c3110000000000000006f007110fe330651d32c31fcab4753b4467cac95586300c264136f8f86aac3721fb16500000000000854d0000c3500000c311000000000000000700072a82c392b6da093a4bd54e33100ea7cd77b5bdcf688b1fb691b474a10e0bc363600000000000854d0000c3500000c3110000000000000007100732f743009fa6915ebadbfbfa27399c3a30f9b29b48e39e8e76a6bef920de3a57700000000000854d0000c3500000c311000000000000000720074815323725370ca6a62de58f815237acb314bc3c4ca763f5c260923f0dd2b4fcc00000000000854d0000c3500000c3110000000000000007300756727a4ae8c2ebb1d033eb969abd7476e90dc6b40b54f75b5f4d6d755679f6c0900000000000854d0000c3500000c3110000000000000007400767e07701c7cde480625ca923bebaa832bc17a6abed269b7b1d3601175c2ff6cfa00000000000854d0000c3500000c3110000000000000007500774173d50ab54b95deae539126064ca1a69a831ac4bfc2c9cc419c6f1da7572f5100000000000854d0000c3500000c311000000000000000760078c8bba7fb3d037affa75e8ab2b6784f404e9ef50f7a76b9382874b24086d5b5dd00000000000854d0000c3500000c3110000000000000007700791cae9402e611c095147a803f5b408d27ebaa9344cb951fb073666fd308fa557400000000000854d0000c3500000c31100000000000000078007afad15542780583e26b83691532044687dce16a82ea097c13d339da899a5ee02a00000000000854d0000c3500000c31100000000000000079007b245471ab91354bcc2c2bb09ab2a2b96840d5447ba9e3b9db177f63bf1894ab7500000000000854d0000c3500000c3110000000000000007a007c9a09a07adac2cf5ee978ef0898161ab40e666963976b0dcbbbaae8a0d7f8b28000000000000854d0000c3500000c3110000000000000007b007d14e2ab39cda053ec7cb75fdda214d03f85c30d9d137083645ec85d70119906c200000000000854d0000c3500000c3110000000000000007c007e768ff392d5d959b68e4eab28b75c719679c43ccc65060d8d9d1b55c56e410b1200000000000854d0000c3500000c3110000000000000007d007faed573b2efbc90729cc17c11f1947952c29ba79945746be3a3635cac70dcbef900000000000854d0000c3500000c3110000000000000007e0080b5f95315fb041ea28586d613d2a976d82d348d960989c7b9416fb3d56104da1600000000000854d0000c3500000c3110000000000000007f008140d95f0c5da8d32977131dc41a05a92f1a6876ca043a638240c2b1c8521f111400000000000854d0000c3500000c311000000000000000800082e58f57a865ff75144bdd08549c83c656431b680ab2e57f55501308f826216b8300000000000854d0000c3500000c311000000000000000810083d7f2320258354c20a9069262030e08e266332394343063bcea0cb1869f329ea200000000000854d0000c3500000c31100000000000000082008467c32697927595a930d79d6af1b4d0ef7eb48eca60a66b0b9295e281b055345200000000000854d0000c3500000c3110000000000000008300857954935a783c0293c7338c17e95ca0e84642aee0b1357adda4264d19d6dfc5a100000000000854d0000c3500000c311000000000000000840086649f6f69ab5b4062727281bfc562602b8d23866140ff820bae3629c28d2b68bb00000000000854d0000c3500000c3110000000000000008500878646a8a5d0c0a6d6abfc5db6d6f7f0675e2e502248d2c9868363efc858272cc600000000000854d0000c3500000c31100000000000000086008837ccf66214c602f0155d3350d8410b27f835f10d7ed7480d38697ee5928580aa00000000000854d0000c3500000c3110000000000000008700894dc5c25b4e7e181bacc5dc94f96cea194f4ad582ff9082e49f43af951f5f2c7200000000000854d0000c3500000c31100000000000000088008ab4a423a735be5b23a37b7e502b1d730cdf511b5f5dcd7e3dac491dbf42454bd700000000000854d0000c3500000c31100000000000000089008bd0300ea2db54c07c732c435c8b390eff7af09d1fec7d4183f26c11af6f06ae8400000000000854d0000c3500000c3110000000000000008a008c06eb3dd61b8993afd0aad49eaab19f9b363acc565df1eee863fd0cf189ae9e7900000000000854d0000c3500000c3110000000000000008b008de2f3402d5ec19a1b0c2df4f3bb8f8375692869e820a1e4071468c85bbfd561e100000000000854d0000c3500000c3110000000000000008c008ea4210d942e844c1b72a9418b1b4b3c4b0f6e514fe3b0f75e3d7df746e95326e400000000000854d0000c3500000c3110000000000000008d008f00608445dec5823810557eb56baddb365cdba4e465f2498abe69cd50b0333f3f00000000000854d0000c3500000c3110000000000000008e0090cd2a4d3bb7d1b6be07dd1f3c90d3cca5f5b3cedb97379673782f5afb23c3020000000000000854d0000c3500000c3110000000000000008f0091254a9a75cdf945525320029f7e8c5aba45dfd5b4a426f346603348a66b7dd45300000000000854d0000c3500000c31100000000000000090009225074aff4e140b03cc239486412dd0a130e3ec93b2648b95569ae55e5782af0500000000000854d0000c3500000c311000000000000000910093d8a8b5c8d165c407816b8eb5e89adb271a1078cdca4f29315161593ec86f769500000000000854d0000c3500000c311000000000000000920094aa968f2a69bb072521c766134d9c1521b5d49a368b0dd00ea3b4e21883f7c55600000000000854d0000c3500000c31100000000000000093009592d8e3576e981f7ef9fafcb12635311778fa6e9bd7544bb502e310113869bb2b00000000000854d0000c3500000c311000000000000000940096fce131a771b67463a1bf5b20192031a7780c14e21a74d4ac5a0c4468a45998ae00000000000854d0000c3500000c31100000000000000095009752267d49a6543212b263beea088dfe20227f97d1cc1a3b935bbfa0e11e47548400000000000854d0000c3500000c31100000000000000096009813496ffa2340f37f578504f289f12a5fc253e2e534f83ed2878e342bd0187c8a00000000000854d0000c3500000c311000000000000000970099c168e856e6948c8eb949d48e53f1c64266341a91c7995547f441d3f2bb7db66e00000000000854d0000c3500000c31100000000000000098009afd09c7a336cc28e97b5ff1e5c83d05c3e68257ddb2587b0c20338833512dcdce00000000000854d0000c3500000c31100000000000000099009b256bb41df47f9dc594b20b7aed566d3457cd3feab6a2ac54021ac61a65b74cd500000000000854d0000c3500000c3110000000000000009a009c1304a8cec2aaa0fb13195fc09a06d571fcc8ac066797be25754ce7da18783c4600000000000854d0000c3500000c3110000000000000009b009d82e93cabb272fbed2f0954226d7871dadbcc06fb772436fe4bd5e79a509dc33a00000000000854d0000c3500000c3110000000000000009c009e67f4fce731aef9e25e456361ebaa95b8dd448180b3780a57dd5e996e155fc12a00000000000854d0000c3500000c3110000000000000009d009fee0a1488e1db16efd197fe3307bb1ef3fecc70be961fb5d83f57293b2389742700000000000854d0000c3500000c3110000000000000009e00a0d8b7f44f79f3825b0898063c9be52e8430a4fc6556380971e45ba2bff87d41f300000000000854d0000c3500000c3110000000000000009f00a10cd85fbd33a658c370afb29f2c452a9c176e6b15b85cb999c849c3e5fcc3571600000000000854d0000c3500000c311000000000000000a000a272fb1ed9a9b17e17dc9252ab1b6c57842adef570518d2ddb9ca68fbd6cd6864f00000000000854d0000c3500000c311000000000000000a100a30e18954a79e2d97b37db47861208d7191933965b8a66a8fa2192c2f03706539500000000000854d0000c3500000c311000000000000000a200a4f2891d485e7fd8fb3acffbe35e13ee5fbc7ab4bd4b856819d6cff8e9b46054c100000000000854d0000c3500000c311000000000000000a300a5b17223b7dc16a717a0143c327abdf0ecec7a56ce9d4a42a187228d4764f3adaa00000000000854d0000c3500000c311000000000000000a400a6e664a80ba167ef9a42fe98f9e60997922edf02ab62b9515f3cc04cd742bbc0a000000000000854d0000c3500000c311000000000000000a500a71dc5c03a9ee981ff70177b153af6073d60b5dd62f266b099e2a9a4d4db5228b500000000000854d0000c3500000c311000000000000000a600a81cb37a70492764ccb4e974c7d238592775eccce4089735e69dddad210d68642d00000000000854d0000c3500000c311000000000000000a700a90821c9c1ef4a9e69d5f508ce86029fdc4601e2a1faeed4a8bbc0617640f5cea600000000000854d0000c3500000c311000000000000000a800aabe06ffb02722e4f7b8b02d33806acdf4fc6050e20ae54bb1955aafd407a8be9d00000000000854d0000c3500000c311000000000000000a900ab7518052cc5fc266960839ca65bbdcb933b36eaf9b2f61c37192e6ba5effca76600000000000854d0000c3500000c311000000000000000aa00ac9ea401167af230c9b049bd02d00e1636f21319cdab24c46937d04d8a4ddb41eb00000000000854d0000c3500000c311000000000000000ab00ad1f1b2f6031fd9c8380f15b2928f042e9645332d6e0bc2f55e06778108ece17ff00000000000854d0000c3500000c311000000000000000ac00aeb8410e7035d2c12ede9ebc2c6bc81af83b27d172db05b66b10c7c7f48c57222e00000000000854d0000c3500000c311000000000000000ad00af1b3fb423b0daed330e204543b2fab0fa9aafff31b387535ad322f5743c7b78d800000000000854d0000c3500000c311000000000000000ae00b02c9d6f58fd85711958fcfd2824474818828d5ecb641bdeed41c988b6eec5c22e00000000000854d0000c3500000c311000000000000000af00b1015bda7aab18ccb03f23a253bd3b94ec400a67d057cadeb092167e45001856df00000000000854d0000c3500000c311000000000000000b000b235eab6ff950e9ec80d4563255908a7ea09a5ae5dfbd0e3c2944295709ecd7e3500000000000854d0000c3500000c311000000000000000b100b3957cfa2365934f72f6559f95997c60304b0a98e3c9e0dc99ab7ea6554bb0a26100000000000854d0000c3500000c311000000000000000b200b41012ec8ec892231dbc8ce89d60f3bb9b375718e460e7b5bd1b8e84631e291a9b00000000000854d0000c3500000c311000000000000000b300b5ca5d940ca7b8fb92c4baecb5a6e464d3c3b5aa5c12e67d9e70650327b858ebcc00000000000854d0000c3500000c311000000000000000b400b69d5412f7b492ac0203d7a82aabe23f8aa7776d173df7da0afc42147476614d9100000000000854d0000c3500000c311000000000000000b500b711d3ab9e16a2c977acd6d131284d2fc8c0d1af94dd6191d6a84344bc4722135400000000000854d0000c3500000c311000000000000000b600b8eb9a2b0b9a696572dfc2adebd5f9177abda3769f294babd21480a6839801474b00000000000854d0000c3500000c311000000000000000b700b9078be2982828fed4bb25781dc9bccaa624f6058c722eac222b4a659e9489edbe00000000000854d0000c3500000c311000000000000000b800ba819151088a9e7aada910f638ca1a248bc94b4878b1031a41c101af8f35c9aef100000000000854d0000c3500000c311000000000000000b900bbf28de95ff9677f064bb695651008dd14631262188ce64c392f192d6efdf8d4f100000000000854d0000c3500000c311000000000000000ba00bc9ea9e5ffbeda1fd8846698475c057567d1f2c133c6d548f3e9722b3b8b9ec69600000000000854d0000c3500000c311000000000000000bb00bd0689a54e3457da2e4ec2a9b07cad6b256469eab859e2e8354b27bbb9f08c3ca700000000000854d0000c3500000c311000000000000000bc00be460f9ac1863a0d05133befb41a245dba3e136fe3e9de43e1000316991d135c4200000000000854d0000c3500000c311000000000000000bd00bf0006cb6b8ace8eb6f74e97e484bb283a123132eb4976f0c665de5394ed0b022100000000000854d0000c3500000c311000000000000000be00c07b38bdf8c89b9b0db4eb62ff10e98308e9ce8647200922d8d790b7dac81bef8e00000000000854d0000c3500000c311000000000000000bf00c181157bfa3420a29f5124ddf629161d0b986616e8aaac86278b838b7caadd850800000000000854d0000c3500000c311000000000000000c000c2b67b3796c8da1c71ac82a89c2fa7a8b40e2ee560c6df7ac42cc99e51645918c100000000000854d0000c3500000c311000000000000000c100c338b2fb62c7aa54b15597b8422142cd49a776092d55f92d5dad31e417844c63b300000000000854d0000c3500000c311000000000000000c200c41e7701adb1fc46ca8f1c24d0e24cc40fe7d9daff7deeb63211fbf5bc1c123c0200000000000854d0000c3500000c311000000000000000c300c5e2cb25607821cfb7e67028ef96056f6e3f81f72db1bf8851432472936bbc647600000000000854d0000c3500000c311000000000000000c400c6d9392204247dfded154f8863582e44f0acd0a30a3bc8a38ab35ab744b73ca18100000000000854d0000c3500000c311000000000000000c500c792e42b479904bc5abf82fbb467577afe7f53632f5a8e89be6791a36b49c326f500000000000854d0000c3500000c311000000000000000c600c84412ac310f1fbc90220101c29f748304f9496740c3eee14b64c8fc171558352700000000000854d0000c3500000c311000000000000000c700c95fdd380ed620c5abc8a4160f18375928c0ed6a926043eaf91365dfdefddef93f00000000000854d0000c3500000c311000000000000000c800ca0c8edc3204a6c47c4e9115755a24c31763efa78db17bfaa9645aabeb4972c89a00000000000854d0000c3500000c311000000000000000c900cb83e02383dcaff8714d79fc6df28563584f79be4435bb3632958eb98649d214e300000000000854d0000c3500000c311000000000000000ca00cce136d56b460bf32ae200ce0fe980817a2bbc78112c9969c70db679c2aa30d73b00000000000854d0000c3500000c311000000000000000cb00cd4cd753d127fca8a62042ce2dae9cf035561530b3e2e95f60fe74245ce293dfb600000000000854d0000c3500000c311000000000000000cc00ce2ee0ed4ba29122e130bfd9a32626fd46ac15ce11642d63e5c596b7d23d6b142300000000000854d0000c3500000c311000000000000000cd00cf92b59998dda902b1005f23746254e625f4a70103d02b0ee3cd7cf6f19425188d00000000000854d0000c3500000c311000000000000000ce00d0040c9ed7f1af1ab9d41814211ca770b40c250364d4a26bd0d57b4d6de3d406c800000000000854d0000c3500000c311000000000000000cf00d1cf54bb912bf745b8761c10c6ed566edc96e82ad8e7a9bc3fcd22fb0e3974cfb400000000000854d0000c3500000c311000000000000000d000d2a81886dd94579a2ebe3c14379490aec6fd8bc8bcd7981094f23011e1690bf6a800000000000854d0000c3500000c311000000000000000d100d353b474acbb9539a6631f51144498802e9eeb237d8ba7d11ac050c4a392129afc00000000000854d0000c3500000c311000000000000000d200d4c08ec36aaac6cb7b82a482059dd448f61a9b25249bc5d4e6be5517baf5f5e24200000000000854d0000c3500000c311000000000000000d300d5693de4032c7083eed7fb1803c6d5624a7e32d3107bd7a3939cc89a083388264b00000000000854d0000c3500000c311000000000000000d400d6f9756396a45f40c30da3a0a67d4dc1838b20a098e5351a1e4f344dbbe00dcb8600000000000854d0000c3500000c311000000000000000d500d7780b34031a24731ecf7e0e6844097a8e62b43267d565dd6d3a4886e55620678900000000000854d0000c3500000c311000000000000000d600d8bcc1624846430d2de342f54d7b8e61d264bfa39528a1fcc63532b291c994c2ab00000000000854d0000c3500000c311000000000000000d700d97a217a4e347a614aa9e651bd234eb0c3d49830a6a804e7ede79c77959ce14f7300000000000854d0000c3500000c311000000000000000d800da1d361f1504f6625c8289222276f9bceafa3281738e27e15f6547884c9ca7a5dc00000000000854d0000c3500000c311000000000000000d900dbfe2501594d57b4c25674b575507428defccdccfe69543a182575dd4817b9179d00000000000854d0000c3500000c311000000000000000da00dc45eebf5678f6a340be93a51d2e263e764839c2454a10bf8853fac76861923dc300000000000854d0000c3500000c311000000000000000db00dda5d81abfe6b8ac5e302b89b6057f782aad54a65adb51ed77e50d3a366db9d7a200000000000854d0000c3500000c311000000000000000dc00de0e6556de4b9b77a9d866d3bda9f4a87208766b0cc701414a4deaac8da2acfe5a00000000000854d0000c3500000c311000000000000000dd00dfbc0a8294b1cc8edafc68b3a95d9263f1499a02d63c93a0fe30633ad556a5b44600000000000854d0000c3500000c311000000000000000de00e0a1078aac0d34309b68c23dcc3d7da53e7f88c24e6ed76850021ab5ac808d6b1a00000000000854d0000c3500000c311000000000000000df00e1412173ca93c1a3db66b0695504c0151d21f1e65e417d5671dde59931256ac4fd00000000000854d0000c3500000c311000000000000000e000e2e601a904450d4f2e168e917bf4438d900cd17f571719d60bb900fb0e0740d65200000000000854d0000c3500000c311000000000000000e100e351a8a26ec67cb3ba4b95f2ac4be486745285e9434dcebd8f7df9f743dbd3653000000000000854d0000c3500000c311000000000000000e200e4d20396497388e26286a2a9bfef575a2269ea85b36b4005d294302aa05375862000000000000854d0000c3500000c311000000000000000e300e5abd37177996abc69e2ca24b121f45b32718a095250c0d533a89785fbc1692f8100000000000854d0000c3500000c311000000000000000e400e69491b3619b64e09337ff2b31ed0a98c07d47c910346fa6e43ff4da9c16a4317000000000000854d0000c3500000c311000000000000000e500e7fa38ff7b2f735c6d7ed6697968cc2792e9ed4c2cc9957e2152c773a23e10969000000000000854d0000c3500000c311000000000000000e600e8d82876021b20bad6dd3833506b1e2c7ddb85e8fc754b11f0099c558ad676db0d00000000000854d0000c3500000c311000000000000000e700e93f7a7c70b38e38652fe97203085488d82bc013586ad8a093aeceb4deaf2ee93b00000000000854d0000c3500000c311000000000000000e800eac0e5f3123292708627ace19965a0fb622f8eef55e33dd21febe02b2fb6dc202500000000000854d0000c3500000c311000000000000000e900ebe9c9a099ac9f0603aa645a573caf5b99f8eb03b28e881010b96d87e1ed55ce3400000000000854d0000c3500000c311000000000000000ea00ec1d875edf3e4221594917fca299d02b1c3f1990f6069f944330c3b2ad9edcba7b00000000000854d0000c3500000c311000000000000000eb00ed48640f071c6bcd7c1a94314bd8615a6f6493919fc216a555b9dabca0121775f500000000000854d0000c3500000c311000000000000000ec00ee457e830ff6e6eae43268bb92d5fecdbf8832a37b575285b1e113b29ea7e5f8c900000000000854d0000c3500000c311000000000000000ed00ef55b2b3b16fa963985019f4a6cac17b51fd164b85efd22180221311ba043e168800000000000854d0000c3500000c311000000000000000ee00f0eed5a265e23b6fee64a6800737f859959582dae01bf804141b7dc199139dc15a00000000000854d0000c3500000c311000000000000000ef00f17920b76551c9c0381e120a166e200e20e3b2e0d54267bb3c9f69a0a374af5f5300000000000854d0000c3500000c311000000000000000f000f2550cb6ae690524fbf6a6845f2df5da2d7e4aacc218263bc216db4be3c269ae5200000000000854d0000c3500000c311000000000000000f100f3b3ee1187678512ea1324b3e746df8c76d52008263fd5ed6ee3307608b02a7e6600000000000854d0000c3500000c311000000000000000f200f4c0c14618d3cc34a24260fcb5c7819db74e65d8eca279451104abcf31eb27a7fe00000000000854d0000c3500000c311000000000000000f300f564108c7261a446b472fa45a0c29af4b821210c8aabe6c113d292b6300224dc2c00000000000854d0000c3500000c311000000000000000f400f6bcf946cf2868a4749a156c578a4a6e40a2a7bc1716b35880eb9d87a4748ae09600000000000854d0000c3500000c311000000000000000f500f773465d6084beef0010d1ac1e7e2fcbc6783d854f3927e6d51f272c52bcc6bda100000000000854d0000c3500000c311000000000000000f600f86d175c4b0f2a9dbe8437a34789c0343991526bc83bdece3c71963f86cc51183a00000000000854d0000c3500000c311000000000000000f700f934b71da18c6f995f91aac176268d6ac0a3a2acf2717801ebf23b9d91926e914400000000000854d0000c3500000c311000000000000000f800faf380ea1d68711d9db0ab45e3397dbd39b5e728dcec8d55930f6f2affb0ca12c500000000000854d0000c3500000c311000000000000000f900fb834434e1f82adc0b7ac0bd9ec0d4179d36e2a611ede605a42977de8a9e466c4500000000000854d0000c3500000c311000000000000000fa00fc3479e0daf8b53f8c04ba0eddac3848dd88ae4cafeecc59bdec10b881101c883b00000000000854d0000c3500000c311000000000000000fb00fd61b82153bc8cf58c32d180d4f46da8f40ca6d8a3fff5ed4d5ba16a1fa73d4e6700000000000854d0000c3500000c311000000000000000fc00fed00cfde8a915c23cda075f4a88dc0ae74792bd8ece23dcfdba72f56b9813b0eb00000000000854d0000c3500000c311000000000000000fd00ffd559de901dbb27927b10cbb56d57e3b244a9f5c248ac8d85221547ba9dabfb3d00000000000854d0000c3500000c311000000000000000fe0100c68cdfd7846524e752cbb7ba1aa0e495352325399e03d91a7016e3671fd964d500000000000854d0000c3500000c311000000000000000ff010191141f42588c3501522fa211cdffd484c7889c2cc6716de25654169609657a4900000000000854d0000c3500000c311000000000000001000102e6ef1453006abcf1da0dfe1572ef43a2082f91f230b0f14ce55a6361e8650b9200000000000854d0000c3500000c31100000000000000101010312266945cef53747caa27b2ecb733afa5afa42bac3afee722206d2d0c5abb81100000000000854d0000c3500000c311000000000000001020104c7ca46af876947a45686fa1f11f8842a6ba14ef982a40f5138bf420c6f73176200000000000854d0000c3500000c3110000000000000010301053f1661f5435f16624c0e51a7a644ece8fe3d7f84af61a410787b30feeeb966d000000000000854d0000c3500000c311000000000000001040106cadaf4cd6e8d42db0682daba7a65e6a51434b843b5cf75213ea4427e0319f4ef00000000000854d0000c3500000c311000000000000001050107bc7d18e01e7f39891798b254ec5fbfd4066bfa49c634f8e3eac8fb5329aeb74300000000000854d0000c3500000c3110000000000000010601089cc3be9f123da1cbb918809e10bee97bdc9fa805b06c557bea2210471fa3307800000000000854d0000c3500000c311000000000000001070109f1a7aae9140ccfd98127b56044d9104e9de5fbcd682859598eef0d6f9ca92aab00000000000854d0000c3500000c31100000000000000108010afaa9ff70179a7b0eff8bef5d45379521f1110e8db1ee03b96d579764c8c344c600000000000854d0000c3500000c31100000000000000109010b07d1b09149d5c5275f85a10d8cd9202c9400ca2def4ad6791075291b4c3c5df300000000000854d0000c3500000c3110000000000000010a010cca26248920d3f7592d14d56435f07ccaba5650dfb6a88e4e39075376402718a300000000000854d0000c3500000c3110000000000000010b010ddee81eaa499f230eefdac7766ac46fda3d15ce50ba634d5dddaa37ed8b27706f00000000000854d0000c3500000c3110000000000000010c010ecddf241b4264b637a0ac3315b33d20ad2b4ee78abe9b109cd2ff12910527db0500000000000854d0000c3500000c3110000000000000010d010f2073acd612d2f8b24c44a0d791c8e458cb4209a578c5e54059a80bf078ea62f200000000000854d0000c3500000c3110000000000000010e0110f2bc07ca3c0917130c0549bb4528b3d30787e8a58e1a4e395f797ee1d5313c8600000000000854d0000c3500000c3110000000000000010f01111adb3bb2a1d5a92c063c04d8e4ccba90f31d32705ab9e5bf9937abcae9bd411300000000000854d0000c3500000c31100000000000000110011216d6fc2b34c08eb4c4047768a720a11c236f83a193eddbefb5f177811e1cb74800000000000854d0000c3500000c311000000000000001110113f7ef1e699a40d95dcd4c155bfe82b2458ffc85aad59e9a7b9a38b6f8e20129cb00000000000854d0000c3500000c311000000000000001120114ccd2d776df1d26ee861943082701bbb4b69249fadd8f143b597ac73e2af5c1c500000000000854d0000c3500000c31100000000000000113011556ceceb7b569011a449af0c305c8dbd28c6f9703aed425f8373c8497188b2b9e00000000000854d0000c3500000c311000000000000001140116b2f5712de794c52523a62778e83e0e59a1bbc6f06f97b7789670f0e5039ea9a100000000000854d0000c3500000c311000000000000001150117693cec364193e49f8f6aebf42955d1b49acda6c888a3a6e2ee274e372a45494b00000000000854d0000c3500000c3110000000000000011601184ebc0b53de00bce7dc1119c28cbf0648dc7ad74c976e4b49b1e5ab6d372fe7c800000000000854d0000c3500000c3110000000000000011701197dc46f6965e74bbaeb7789ee532264e843103a39d7a9d75132de7e9b5ef02a5300000000000854d0000c3500000c31100000000000000118011a00b1c69e8c2aee8a42f849b56150603e0541ba97656911b2bec92fb6b80e1c4700000000000854d0000c3500000c31100000000000000119011bbe8e25448c1ed1d4cf6851269c121e1c45e3cd58292714ca7a0afcd8c6813dc100000000000854d0000c3500000c3110000000000000011a011c42ef09220765bb717aad3c2d4dcfe180e8c0ebd8421df820386626d3a85e4d2900000000000854d0000c3500000c3110000000000000011b011d595e3312d192af31f4735e7352203a2b3e1ff5c8712247ea76b8d6521328a28100000000000854d0000c3500000c3110000000000000011c011ebb01550134b55966c3b8a175688d7fbd913988eb37701053abdebcd07b2c0ee200000000000854d0000c3500000c3110000000000000011d011fc8b44418ae4ad010eb4a16252265eb6e1ffbb8238ae74055e93170d5aed2959900000000000854d0000c3500000c3110000000000000011e0120996d1a0367ed61db81a131b5b163de5d6f2f693055b87a3cc7a0c6a15bb72ef800000000000854d0000c3500000c3110000000000000011f012126b6c2e89d781417985751c1fde5cd6069167fce4c56138370bcaa24d03ce24b00000000000854d0000c3500000c3110000000000000012001229e7af1b72e21a2785359677d3c101e9029b117a43dba617f8ea56d7dd022d8d700000000000854d0000c3500000c3110000000000000012101232d2e1c3b68cf4b0e8bd492eb5741cf78a3b36847fa0a97c3304a95dcc7a8a14400000000000854d0000c3500000c3110000000000000012201240e06ed3926bb95bf57bc7ee2c9e5f1c5bd661bacb1ce5a1a2deabe6736c8367800000000000854d0000c3500000c311000000000000001230125a6abd820de07f5cda1819cd9a9146f0adf4d8dcc60e3cab50bc5e1abc14891e200000000000854d0000c3500000c3110000000000000012401267ac610ff9d6969b53d28f85a92a1fad522b3bee82f76f010f45ef0a3e4c7c15d00000000000854d0000c3500000c3110000000000000012501277255670d53527fa3d330df360ac6c98d49a6a5ce3f6fdf1de4ca700844c8fa3e00000000000854d0000c3500000c3110000000000000012601281d6d6e357f1227e5e942653388260e8238b5612f4927fbfdce258bc4d0b7594300000000000854d0000c3500000c31100000000000000127012954e9dbc5cf7149868716b45dbdecdc449d0b39b5eafd69e3b49a7ed29d4459ae00000000000854d0000c3500000c31100000000000000128012ae285b863674b89d72cfb884f5cd5eb523ac0b493661c58f7851c5547a959ed8100000000000854d0000c3500000c31100000000000000129012b819532ccb84729849fbc4cce0ac5f8236350c3b67b316338e15cc62b954d30b400000000000854d0000c3500000c3110000000000000012a012cd23d76e2a5311b80e76c5229b01cb07f2bb85646fdcb4c62adc82f198b047beb00000000000854d0000c3500000c3110000000000000012b012df661b595023ecb62cbf93a64d234dbf889166b48fa8eb394bb9fbe841e1cd56600000000000854d0000c3500000c3110000000000000012c012e0513cfb510f00b170cf080b3ce2b850cec369d35c0550eff4b8fb2b6c91dfda800000000000854d0000c3500000c3110000000000000012d012fb9d1d054cf18aae245c31e11c0d122ed5aadd8a3b35ad39dfe30ac8798a46d2300000000000854d0000c3500000c3110000000000000012e0130e06b5899ba907b2923ca8677fa61c7f0943cdd7c1c40def5d83aad630bc00eda00000000000854d0000c3500000c3110000000000000012f0131293692db9029a74afbc68d94e574decdd9aa7f388e8aa344379018c4da18256e00000000000854d0000c3500000c3110000000000000013001326c2b3b309d75e679971f71daae4c69990da8707c359765d2c1f74a56ea45a25100000000000854d0000c3500000c31100000000000000131013391b688dbb1263e7d9c8898a7356dd313e14f066a6db7dbd8a05af623cfb1423000000000000854d0000c3500000c3110000000000000013201345d9daa987405ea5c478cc99679df047e1cca5d28b2ae550d88570c9a7b361b4600000000000854d0000c3500000c3110000000000000013301352d4bf0f568c72724e502c4ea615d9bdfe76205af4649db3381bb039a6c81d5e700000000000854d0000c3500000c311000000000000001340136387afecf394d4357604f540282bbd442f84f456c5ff810d7964941b31bc12f5f00000000000854d0000c3500000c311000000000000001350137b19741e64da87111ec3ab8a714db12a89a0e19cf667a0049a9dcd72aa5c30cfe00000000000854d0000c3500000c311000000000000001360138041c4bfedd2c41b363acd3fa746b0e65820abf0259851616c147a6253982860600000000000854d0000c3500000c311000000000000001370139bd4d8149d173108a9685d7305f1624dae2c8dda08e475d90494ff93e3b68d3f000000000000854d0000c3500000c31100000000000000138013a6807c6439794cc48e36826e845ad919d758bc0308611f2b2063f35e59badcc2800000000000854d0000c3500000c31100000000000000139013bb94b58b11fd0a16ba852395acedb03563c9992103fbb9c4541c0140ad25bba0d00000000000854d0000c3500000c3110000000000000013a013c98f301ca34257ff0bb60f8a29e8c2e75569f12c2e757cfcd86d441987754db9800000000000854d0000c3500000c3110000000000000013b013dd33a298ca334510f32a78b0d175ef81aa1283c5a43a0a36997337a188cf28df100000000000854d0000c3500000c3110000000000000013c013e6535f74d7b79af5ab10020827567deb1d7feb0d68c97b24375444fc91685c3d400000000000854d0000c3500000c3110000000000000013d013fcd31ecebd30f110cd6faeddf43207ca9ce128876a99d882d1eb2956ac66baeb600000000000854d0000c3500000c3110000000000000013e014080dc9f243390f1e5ff5f824b3558f3a71558ee2b76b6dc85b8767d8c3a762a9900000000000854d0000c3500000c3110000000000000013f014135e665b8a599899c0e0c8e20d270917ab23a1c5ec1392031cda458edb1c95f4700000000000854d0000c3500000c311000000000000001400142f390439757563240273ed69da2e0b49158ffac612227c538ab4df21e668f072400000000000854d0000c3500000c31100000000000000141014375199d7a5f1d5702a5e97030d9b85af5459e59ece07c34cf4eba44fc2fbe75f300000000000854d0000c3500000c31100000000000000142014488eb334984eb5a14d76478f8c7e277804cb813c573fe185e1269a0b1b3a7c4af00000000000854d0000c3500000c3110000000000000014301452934a43425a844e0cfcdce1f191ac83c216aabfe9cfae82ad8fb80e5ed32fd9500000000000854d0000c3500000c3110000000000000014401463e4182cabfe4bca69a2b2d26394b851e6f313fd7400e34f66b207fa7997336ee00000000000854d0000c3500000c311000000000000001450147beca3c2b2abf74149413af897d503c0940a04e65488cdca79adc80f3a1022fa700000000000854d0000c3500000c311000000000000001460148358cc7525792ec60cad166479beed863fbda52d75a2b3b21b44cb3948c74c3e500000000000854d0000c3500000c311000000000000001470149084911fc1e172537175b5cb1ed8e0ecae73beda0b4e5e13b826819e1dfeab3d900000000000854d0000c3500000c31100000000000000148014a415167f1a6ff33eb7eb3a208895a8bc11057d6fc0e3d3cde21eb23a9e8f0428f00000000000854d0000c3500000c31100000000000000149014b6bd1174aa697898fbf5fa97167d246f94cc930add143f6b5f3ba1c189160fe9900000000000854d0000c3500000c3110000000000000014a014c630f10f3c50d48328bf9fb5ecc61598d7fb7da587fb7821e4b42151afc4ee36d00000000000854d0000c3500000c3110000000000000014b014ddc396d879f620ece1af21eb2ae7af3ccb63520b7e5c2c86ff9704ecfeae2897300000000000854d0000c3500000c3110000000000000014c014e36074edd4eb00de98fd3f92fa28e76e19abdccc7183a427e0f80f2545099249000000000000854d0000c3500000c3110000000000000014d014f329bd6b4a05c40900c16d963d4cb2c32cd3b866fd2704809359d4e7be2b4d3c900000000000854d0000c3500000c3110000000000000014e0150e0d278e14a883573033cdcdbfe68747f69c02a77aee2631a53854877cd977f8c00000000000854d0000c3500000c3110000000000000014f0151cc619d317d853e8a8c9605c6b2016c5b77d1ed0ce2764865af5bb58da60d25fe00000000000854d0000c3500000c311000000000000001500152091da59c8e15c2d0b67a0c03241ed38bcaa08afe611e374ff56a04b1493496c000000000000854d0000c3500000c31100000000000000151015351d3aca70ad269740eb8143d40c40ccd9655137e1a797db99c19f0c74496eb0200000000000854d0000c3500000c311000000000000001520154e8b3f8186945c665a130ce8bb5022be66ee9a82d58bd04995dc2ed8817b9cf5300000000000854d0000c3500000c31100000000000000153015582bbf5437a38dff5538c0853418b67eed524fd6ec6730af6e83fd7e1819aa9a900000000000854d0000c3500000c311000000000000001540156126f1cdc86e71f3a885ba96d926e619de1dc600a5e917556b59349709a09202f00000000000854d0000c3500000c31100000000000000155015742f8ff596b8d24eb09eadb09653801d690e24c1fd00201534185e5d76616e75100000000000854d0000c3500000c31100000000000000156015838866d0eaecd45ba609331dc127de1cc6de73bedf6c8857b39d7bca574d5a95900000000000854d0000c3500000c311000000000000001570159fc5edceefdd5ba830ee696fa281bfaff5f19f03a517bcc14f2a723cb43db17bc00000000000854d0000c3500000c31100000000000000158015aabd47e25adb0dc27d1c5a063dd640ee22ac5716a1dcdbe152c27c2a4925deee300000000000854d0000c3500000c31100000000000000159015b44080f378bc7003840f1d01437c268fb20c7f9bf4b38cfaf1b18bbcdf7368fc500000000000854d0000c3500000c3110000000000000015a015c130157d7beaaf191672619dffdb99204c2ab01b35d299c29b4a4c9f1364570df00000000000854d0000c3500000c3110000000000000015b015d139c7812864191abffe378364b46c4929ce9f354e57994e56ef8690a9adbdc6b00000000000854d0000c3500000c3110000000000000015c015e76b343cf768eea6ea9430c144d1f833d2fd19f01a0b8d04e09ad0625c0c1ce4700000000000854d0000c3500000c3110000000000000015d015ff182f63a888b037893c449126093bb70f97b7360b903cc02705619b8232cac9700000000000854d0000c3500000c3110000000000000015e0160ddb46b199bd616225b980e5cf964c647898a44241ca45335f679143b0097d48600000000000854d0000c3500000c3110000000000000015f01619d4246b117a38112ba1bd600e78490ba170a1d1307043f3c83c2a1a2d50106c900000000000854d0000c3500000c31100000000000000160016205f692aacc895ec966a4c41045525845352c1993f7d5fad4f836f286565c62b900000000000854d0000c3500000c311000000000000001610163051eb76af4a34d66106771401412f01f4ac661594140355541f815f5e1e7490300000000000854d0000c3500000c311000000000000001620164b7e76d9101f61768d4ea6470dbf632941ad250c20d2ed9b3a9b62fc2ae398c0e00000000000854d0000c3500000c3110000000000000016301655cf571d20737997ec5f0f856dc59a1104d5b153b9377cab0a3107dcef4ee1c3400000000000854d0000c3500000c3110000000000000016401660bc27dbed94378bb13a6b1bc1a61789be9572d1d42a7611338d5a3e1351768d000000000000854d0000c3500000c3110000000000000016501670e63f7f71c19b7eda6368a15dc5dc2e7295541888e60738db15b3cb79292617000000000000854d0000c3500000c311000000000000001660168144788ed1842d11dfc84d06d656f2b8390e11778066f21d5f97626bd72b475fd00000000000854d0000c3500000c311000000000000001670169c4642f1fc647f5c8efe7acc255ba6bab55d280a095546aaca38921df1549375800000000000854d0000c3500000c31100000000000000168016a1e52b4f91a493d81e0ad67231f835c8f1ec2fbab50d1f4e5e324504000ee244500000000000854d0000c3500000c31100000000000000169016bb037cfbfe23395592f82f1ee8d5eab8a2277d8e3644908c775b16396774338f500000000000854d0000c3500000c3110000000000000016a016cd3781d0b608c534512544055182fe979fc8d439f5c49b64cc948bd6f7b6314df00000000000854d0000c3500000c3110000000000000016b016d800b2593788c198f7321ce6424338ef7e83123e241aa7fbe8dc38e7c1e3de86200000000000854d0000c3500000c3110000000000000016c016e97b0f44eae74f3a3bfba8c15b94663e93067a575289e7034c8ca6fb8fed4d1cd00000000000854d0000c3500000c3110000000000000016d016f87ab47f0d8959d60eb4147714356816f6fc95b0fb1f92abfb087f9fdd9721a6000000000000854d0000c3500000c3110000000000000016e017015c8f1a325c0740647aa9af8d6f41b9b364f2d6509cba4c318784ab5f98664fd00000000000854d0000c3500000c3110000000000000016f0171dd3765c9c21ac14cced443eed9254bb76cf595dd19763bdad8f3762118180c3300000000000854d0000c3500000c31100000000000000170017205dc8d8d03f1d1b79383291c9b91c6dbed7b5868793a94d86f8cd0b02ea9dffb00000000000854d0000c3500000c3110000000000000017101739285e8ae6dac04fec83f99de4cfd81186130119af91f3af4c2c2ed9556cd73b800000000000854d0000c3500000c3110000000000000017201745f9b9c5c46848b51684d15e7a018c16c7433251d4e05bf0a6fbb1c22e26624eb00000000000854d0000c3500000c311000000000000001730175356f2fcca2d099ddf402b0f28922c8be5276c406fb6a262a10e1ba2d37c1310d00000000000854d0000c3500000c3110000000000000017401762361231644dae69b1d58f83ac1795d89062f1d20f1e18bc58a38beeab8a143d500000000000854d0000c3500000c311000000000000001750177fc410231f56ded5634a2b827172d7ff54326b4d9ba55aff6f76dc0c1d8abe73400000000000854d0000c3500000c311000000000000001760178a3ce42369319689c90263bcf693655b2b5f73426e4299bb3edd27a61fcea84d600000000000854d0000c3500000c311000000000000001770179529f4cfceb408d8e4db8a93a81948458df679039b6b286ef8e2d6b392e584f7b00000000000854d0000c3500000c31100000000000000178017a652a19f302298f2ddedc5bdde42af0c1617f981d5942d33bd7bb436780ef950500000000000854d0000c3500000c31100000000000000179017b78799b9c840639e4d0d50fd3e6e4728c62c0bd0378096552acbbd4096572acb800000000000854d0000c3500000c3110000000000000017a017c8eb8760d4cf5b9077c2871390b10c3af43aa24de7873c145ee0efb6549c7d6ec00000000000854d0000c3500000c3110000000000000017b017d662d3d9dff9fc1daee14bac71957f258d155d3cf5351507f7f9c75f9fdf9750100000000000854d0000c3500000c3110000000000000017c017ee0dd1b9635b59c05c79911a8d80bff18b7575e6429c8d709a869e6a06e04c30b00000000000854d0000c3500000c3110000000000000017d017ffc1ae8163736881a06d397844694f4c1b611b2ec421e3e85830e6a0b06e7626400000000000854d0000c3500000c3110000000000000017e0180103f4ec21762e94c3a6176166b0112f1dec7ed9cec100950fed6956966a114a700000000000854d0000c3500000c3110000000000000017f018147369da11f17dbfdebe6329cef69ae7150b9003b2a03efda39443b9958f8154c00000000000854d0000c3500000c31100000000000000180018293127c2a56004f8d0a7a8fe5866174cea89697bf41df5e122ce7a23215e2d60400000000000854d0000c3500000c31100000000000000181018325e789b4b54bce1b239c8f012e156269e109a865bb46cf6d615412a6bd1e060a00000000000854d0000c3500000c31100000000000000182018415d04d386172988a10fb74a66f2edb3798cae4ae180cbf1b110e1ce447d1cded00000000000854d0000c3500000c311000000000000001830185d14b28e04b1008b7716368751b7281fd1c56efb14c4bda843b2e3e56a6dd167b00000000000854d0000c3500000c31100000000000000184018608135c514ba440467ae403e0a65e81f359ae4241789416377f7502ce739f6c3000000000000854d0000c3500000c3110000000000000018501874d1153cb1b5b24b1d021059395131e760ff239f5ac04487fabed4265acb7f43a00000000000854d0000c3500000c31100000000000000186018859c506c7f1424e8f6a02cbb4b0488a8253ed13971bd35d9857273fc03d9c2e3e00000000000854d0000c3500000c311000000000000001870189defb781b52260a9d6fd63130f1b8d6d603e4247eaae108d635e2e5bc04c5382e00000000000854d0000c3500000c31100000000000000188018a5335d54f7383e77089b8fdd6652f3fef12fb0f70fb2a84d2affb193bb1b7095f00000000000854d0000c3500000c31100000000000000189018bbf3e5a3c2868086d150869804d22d5da63dba8992833908943ce66f90dad171a00000000000854d0000c3500000c3110000000000000018a018cfed9687c86989821940a117f7fc601ff678677a12178775caca21a09249da22a00000000000854d0000c3500000c3110000000000000018b018df8ce826220eef839cd84d2b9df12a0f0ee5806b4aa19c173a92308a9f084543100000000000854d0000c3500000c3110000000000000018c018e1921efda69bc2cf34f1e47081b44b6400291eb927f4980a94f3134b111a2523200000000000854d0000c3500000c3110000000000000018d018f37c353aaaec137e0c9db2d33427135e6f8473ea35384b6a3eb206af450916ed100000000000854d0000c3500000c3110000000000000018e0190f3a2a2e0cebabac99e3d67147da573f39fcab1746d2a05d2e22a060af92efe9600000000000854d0000c3500000c3110000000000000018f01919375113bf780682741b62602e90cb9b3a50132ef3443924c0b93b7f3c0ea80a600000000000854d0000c3500000c311000000000000001900192b5085704b4b6b823059acda09858529e4e9fb542b52c668b392bc2900378b07900000000000854d0000c3500000c311000000000000001910193da08774a6ff7e6968eddad0fe921d39051caaf3c2bc2aa047102baa574852e7e00000000000854d0000c3500000c311000000000000001920194e6006b57c070a434ba17e69fc30dfdd88fc3faf7c00465df22f22fc575051dea00000000000854d0000c3500000c311000000000000001930195eb004427e0c3795eeb74ae52d8eb908d6395d93e90d93f113a941d728c7a186600000000000854d0000c3500000c31100000000000000194019625cce4babf6d48d362e642b15f5234e346d0ad56e01eff852be1d4bad5da4d9f00000000000854d0000c3500000c3110000000000000019501977de1734b52094a5045f4d3a962b0811c40148ab37a8cb8949ada98b9bb2ef98e00000000000854d0000c3500000c311000000000000001960198f6bb35d733dafb8788f89a35fc734c6fae0c79c7ef79d9ab79a619331d5991be00000000000854d0000c3500000c311000000000000001970199f608ebfa1bda8ea7044d7614459001856762aea6fa2a8da8a84f63b35b784aa900000000000854d0000c3500000c31100000000000000198019a5efd4cd712e8d819797cbdd2e5eb14ab8479581992c5cb4fefb71acd21259c9800000000000854d0000c3500000c31100000000000000199019be469abb620208408b16c28a5b22a408e86745059c187e533738786c8321e62d600000000000854d0000c3500000c3110000000000000019a019c55b7d42391295649268e33972b9b05e3863b06e927b1b6821f6be5c22cd5b32300000000000854d0000c3500000c3110000000000000019b019da71224eb2c47f79b4b7897332b4edf21831b271dd31265ea82a56ee423fbc51d00000000000854d0000c3500000c3110000000000000019c019e625238678138161a8a222110d7fae260b0d53dd718d8f0bbebc6dbf9f7c603d000000000000854d0000c3500000c3110000000000000019d019f998478e02c1ccb514f538e60d2d42bead26565df3239926c7ab2ba9e2556375c00000000000854d0000c3500000c3110000000000000019e01a0cadb273ea32290f6b387130461ebd022c6b559d4485f2dded4dc866794b1952e00000000000854d0000c3500000c3110000000000000019f01a1dcc463d2f12315dd333110aec4216d3ad68442ea011ebbf65cc03d6fc4def28d00000000000854d0000c3500000c311000000000000001a001a226af9c7b0ad6986d2014ee1c344720f7e5ecb5912d4533e3db9cec5abf3766fc00000000000854d0000c3500000c311000000000000001a101a337b0243c9147d7cdb24ab677cb997793184b4c6cbe078a2339c7050553debf1000000000000854d0000c3500000c311000000000000001a201a40b65bf566728aa8f0bd3b5f8b03efad99fe4d86570c3792a009bbd23aed8851600000000000854d0000c3500000c311000000000000001a301a57c0f33fb634f5b1cfad7cc130da55119a2fc282e4975d40204af4084a9df275300000000000854d0000c3500000c311000000000000001a401a610d4665d3a1c11e4bd872b0b47d822fefc75e8010f2766febba4206548da19e400000000000854d0000c3500000c311000000000000001a501a7e5998fdf3f51aedac8c428ebbe458f8039f8da6d996381998888846d7d7eac9300000000000854d0000c3500000c311000000000000001a601a84aa1e7b220a2ccb3d28ae1fc383aa4319dcb8df1a554f98e360ccb1b630674b000000000000854d0000c3500000c311000000000000001a701a90296a7c4204e0380ac993850b4041042fcff74542ba713574070c2982e5fe06d00000000000854d0000c3500000c311000000000000001a801aa1bcebf698d2ebc669f1e2e3ebbdb2bc9745616b0dddf8503965e9ce1bfbfbd4300000000000854d0000c3500000c311000000000000001a901ab17ca3153cfcf2c91ab678671c0193785a38fd13243f23bacf712297c1c3161df00000000000854d0000c3500000c311000000000000001aa01ac42aa6f3b6b844e832587ca38676960f99da8a7bf1cd0861f9476e8148056dc7b00000000000854d0000c3500000c311000000000000001ab01ad1677bf1f8138392b8a896be86688d8e2a852c94abed179d515ea75cbb0f0114400000000000854d0000c3500000c311000000000000001ac01ae5d07f5c149c59191cf2e949908b485c269c7237df230bcc66bff9d221b912af100000000000854d0000c3500000c311000000000000001ad01af4af01101f2fadf75503b90fa5519fabc113be039f7cac24d6a425ff6f4d46c9100000000000854d0000c3500000c311000000000000001ae01b0b75ae35825e628b1d386a81c380eac87e5063ea2fb581058a44e67647606faec00000000000854d0000c3500000c311000000000000001af01b1b3ace4f6e0b4d2504edf42d9fa09490ca53c59401c5ac8eae407a0f0347dc2b400000000000854d0000c3500000c311000000000000001b001b2da0319bb42c74cb71cc24608d303045b7c66b21c9cad1bcefa23190ea9008afd00000000000854d0000c3500000c311000000000000001b101b3e61dacb664d45941c172a551ede9e667bcf095113c15bac9217e1069c4549d3a00000000000854d0000c3500000c311000000000000001b201b46fea6aac4b9e2c07142170421a6f4f81ba464c8ddc49887d9f0f7826314b772e00000000000854d0000c3500000c311000000000000001b301b51df4dfc3f008e8b2348c48574f1c8e555a987162ec8347d793d0ceda160c952800000000000854d0000c3500000c311000000000000001b401b6e793bdc7a9b90cfd1a09081912fe24f588947146e99c46f58754cb16f81f6cd900000000000854d0000c3500000c311000000000000001b501b7ce19f510bda9ca936f1ce98af2c35aed4c87e98c38927a095b9b94f741305f9400000000000854d0000c3500000c311000000000000001b601b8c7930f425336449c2c3208d0e0b74d37e1eb5c1c0311e9a77bf00bf103daa2df00000000000854d0000c3500000c311000000000000001b701b9c7d9c3fecba42d41c4ceb332133da641fb6cdc531897be0caabbc3261cac5c5900000000000854d0000c3500000c311000000000000001b801ba871ca2ac3fcb9449b1cd3903798aa08e0e751d505d9aadec2d2b912094dd05c500000000000854d0000c3500000c311000000000000001b901bbd1dac0d405505faf085050662a9b20fe2e3398fd01a9b011dda41a55c740485c00000000000854d0000c3500000c311000000000000001ba01bc8aca06e52f2ee6f62f413b23f3bac9b4da3e93a2fb788f34c085be0128a293e600000000000854d0000c3500000c311000000000000001bb01bd50562444dd139b2bc45aa2064e6f945e1b4ff89ceef30842195d7e3766b5a25400000000000854d0000c3500000c311000000000000001bc01bea3112af2adcf6c56ac46a4cbf93312e2b6b7ea1848185a85273a7b11251c217900000000000854d0000c3500000c311000000000000001bd01bffa802b543ca6922c0656320b0761b883b9c1d75228ac9a7e9626ec76a7c8482000000000000854d0000c3500000c311000000000000001be01c0b2fcc13e833dd5bdd5d3c8ecb1745f50d831619c895ba3dcd24597bc52e0f67500000000000854d0000c3500000c311000000000000001bf01c11ddb4c5c55ed2746c69088f47da9cf8450cae2cba98e8a997381709e234c811500000000000854d0000c3500000c311000000000000001c001c2f88327f3873e70e5742dba0a8a41ab4555ef75ccbd49812534d625a38086465c00000000000854d0000c3500000c311000000000000001c101c3877f5cb86d90f3acfe194d244ff85604036251e73be7453d043470e8e87d1df100000000000854d0000c3500000c311000000000000001c201c41b6468631457a999e13d8556d28875f80d475e4080a822606e53ab4521e612fa00000000000854d0000c3500000c311000000000000001c301c5e27506829355cd7edd93156f5d3d78038a3cfa19d1b94502097914558d7a99d300000000000854d0000c3500000c311000000000000001c401c6e922eb6004f428a319dce0b00be94d9d65e8a891e0da8092cd17eb57f42719d700000000000854d0000c3500000c311000000000000001c501c72cf19e8b9cdbbce8788a37b61803d8c5ac9b705b451a16cf3fd4e44c98a6978400000000000854d0000c3500000c311000000000000001c601c86d4c389a327e75e07e26dde46d96ec5f9cbe652798c6e4c4f071e518ca431ae400000000000854d0000c3500000c311000000000000001c701c9ea874c9bfb2c727a572b1bb77a3911ddfe4890f0f4cc152b67e4490953fa741300000000000854d0000c3500000c311000000000000001c801ca3078da4bda050f5f723492d3f63a9f465a3f8352414b9b24747052268e8fd9f000000000000854d0000c3500000c311000000000000001c901cb0cbf24109afca71dc10ec95847ccd30693a340ac17a1c438de32c9d5ec779ce600000000000854d0000c3500000c311000000000000001ca01ccd1eebb68e4560bfc706060c2557d03e5fe55e5c76f1b0ef8e8b9102af101e1b600000000000854d0000c3500000c311000000000000001cb01cd60f5505127a1c513a7bf51a506bbd38413db23366690dde3b7a64ada8b38c3bf00000000000854d0000c3500000c311000000000000001cc01ce5f2a77c993b3c8c2860b9b6f661657e2ed9c3fb8a7f07b9f86e6908ff61e504000000000000854d0000c3500000c311000000000000001cd01cfbeeced096c0d29cb3117bd673c5a13fcc06788b4db4d7d24a2bb143cd675772200000000000854d0000c3500000c311000000000000001ce01d093861f3b9c8f22a3fc7f39be4019cbee78624119caa0ac94b509b691e516835c00000000000854d0000c3500000c311000000000000001cf01d13083a24fa948cd62be1d683b9cacaba9117c6a95ba3af20f8df2bedefbe3456300000000000854d0000c3500000c311000000000000001d001d2d12e24a4fc1c9116fc4eeead881d06ea1a7ecfbe5b50a60da676d03906784c3500000000000854d0000c3500000c311000000000000001d101d35c572a6639f35bec2e4ac4d0afad8f0b1a5b56f379ba18bb0f5a2239bed5684500000000000854d0000c3500000c311000000000000001d201d4fe0dd02a29705d5066285c49d0f62b8b7703247af1ca3f3ef80c72d2e73d4b0900000000000854d0000c3500000c311000000000000001d301d5270a3d63c0ee9b56b60d8422820e7d421d7cb5edd90bd36f171f3a248ca8ba2200000000000854d0000c3500000c311000000000000001d401d6a0ad7dd4c1094a8d2cad3b5f58c2f6521a82fcda6145f50a67031c99fac5ee3300000000000854d0000c3500000c311000000000000001d501d7bbc593f697857610ae607fcc8aaadfbfa9ccc8b666c1610fe58638c4b81b39d600000000000854d0000c3500000c311000000000000001d601d82ef8ef79e3f3125048e96e178148b987ad8ce75ddb86f226c9422f88311a1e5400000000000854d0000c3500000c311000000000000001d701d9584d870aff1afb86ba69ddc59fe8f846915e32928ec2b41ecfdcec2ddf6e034800000000000854d0000c3500000c311000000000000001d801da3f220ce62505abf5bd046d810d0a73121491fc5845984c6fc49d95fff620acf000000000000854d0000c3500000c311000000000000001d901db239fd782432147be7b01479d1bbf685ad71220496c5fbe49751f43661ad97ffa00000000000854d0000c3500000c311000000000000001da01dc452dbbf443cb66f47d94219064c1e11d02a35eee965b7f853b387676a12fc1bb00000000000854d0000c3500000c311000000000000001db01ddc92ca5e3fea2af39b5290131d48ae5ac6b8b3da7f8939f3416427aa7bf23c49500000000000854d0000c3500000c311000000000000001dc01de532077ccb94f30ba0e14ae25792e182fd3a9c89a136112a7875a582877d067c900000000000854d0000c3500000c311000000000000001dd01df994fd79bcbb666c872f3cf82331b4890fd26895337b42a7f957e3da716d9dede00000000000854d0000c3500000c311000000000000001de01e0b9563d83c0644780a2f7dc8441b391e23ad1a9d86a23b965931c13d66e41314e00000000000854d0000c3500000c311000000000000001df01e1a356c15452b8070d12716b40768018b3315c33b00a40c54fc825a77e16189b5800000000000854d0000c3500000c311000000000000001e001e250d2f5e73eeac5e1263a0df8a53ba2b1deb5b8e7815c61bd8d70015ed1912fa200000000000854d0000c3500000c311000000000000001e101e37498c3f78d908282db2e0ad1a117dad769c93569014fa2500d266959dc58f9d700000000000854d0000c3500000c311000000000000001e2", - "receiver_txid": "31d842bb6d8a1529dd54d013c7a439827864c4ff4867a8af5080a68b0f25aed4", - "settlement_txid": "b772ab617557851d0f05d96026f51ff9b7baf43c6ef3ee72153495c41c8c2b09" - }, - { - "scenario": "D.6", - "init_hash": "d290b308abba2f4346a5fb0e2477d54eada86eb455d298a14570a8c90aae4d7d", - "setup_hash": "eb4e7e4a56d8612ce7dddd7ed5b8b31d22a9c394560909d5f0a8a7cfc6fb5a71", - "book_hash": "dc2a6687c21e580e3cacc642c8f877ded7278fc9b87e7b388ecee30142ecebb7", - "commitment_hash": "8f19b491169952d5c5852cb9c722ec7f9e52b4bf1627f2c80377f0e7bdbe9287", - "activation_hash": "65129deb07cd484e12e9badcbc11dace2e724e9a1ccba6b40ec92d57dbaaf028", - "init_wire": "d6d9bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848975ac6c4567bb853d69517b2fe7660700e83262c129fb80c5f3e7b8705d2b2f930400000000000f4240000100000000000854d0000c3110000c3500000003e80000138800000000000000000000090800000000000f4240e0e99ff77a29e85142624109ed949a2beb6a35de044c32f884e22a67f72308526065b8e983625c675361332e2a10fa506af6b402b069471921b4a366765edf7f", - "accept_wire": "d6dbbef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848975ac6c4567bb853d69517b2fe7660700e83262c129fb80c5f3e7b8705d2b2f93000000000000002a012064c356aaa2f7789a1ddf82024f18b37dfc22b4fb3ec7583ab7b09f8a9ad1312a07080000000000000000090800000000000f42400b20d290b308abba2f4346a5fb0e2477d54eada86eb455d298a14570a8c90aae4d7d5ff3c9076c82f18847c629529938cb5d03cf3136d8b87be432f9ed0bc81a40e231a83a0146d5c1d0473cc11990623ca31ab3defa22e9c96142fc6340e55aac23", - "activate_wire": "d705bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848975ac6c4567bb853d69517b2fe7660700e83262c129fb80c5f3e7b8705d2b2f93eb4e7e4a56d8612ce7dddd7ed5b8b31d22a9c394560909d5f0a8a7cfc6fb5a71dc2a6687c21e580e3cacc642c8f877ded7278fc9b87e7b388ecee30142ecebb78f19b491169952d5c5852cb9c722ec7f9e52b4bf1627f2c80377f0e7bdbe9287000c0df09b032fc561367edc1f08074a67a670db9d5220826d2f5ea2768ea0ae8485bf9367549039a160219ba6393387728aeb66cdf27647fe07d3d201b0c7d2df813051", - "ack_wire": "d707bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a48848975ac6c4567bb853d69517b2fe7660700e83262c129fb80c5f3e7b8705d2b2f9365129deb07cd484e12e9badcbc11dace2e724e9a1ccba6b40ec92d57dbaaf0281b127837288c4ae30686b772f13791954b42dafb2b318eba7f91545e3a09089543a8e85aedf462ecd5a1338602329a036c49c41473420ac1f2d2b911898baacc", - "book": "75ac6c4567bb853d69517b2fe7660700e83262c129fb80c5f3e7b8705d2b2f9304010001000164c356aaa2f7789a1ddf82024f18b37dfc22b4fb3ec7583ab7b09f8a9ad1312a00000000000f4240000c3500000c31100000000000000000", - "receiver_txid": "5a294b75a1b592d11670560d0e7976fabaac0779292f2aaa4096e81f73e591c7", - "settlement_txid": "d76e272a1353cd34eb8ee2eeefe8783f24b77a0d5424ffb253961af387d7de82" - } -] diff --git a/crates/ffor-protocol/tests/data/beignet-lifecycle.json b/crates/ffor-protocol/tests/data/beignet-lifecycle.json deleted file mode 100644 index 10981d3c19..0000000000 --- a/crates/ffor-protocol/tests/data/beignet-lifecycle.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "source_revision": "8aee31d18e596fe49a0d195b325a6e757d7a009b", - "fixtures": [ - { - "name": "abort", - "message_type": 55049, - "signer": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c", - "wire": "d70901010101010101010101010101010101010101010101010101010101010101010202020202020202020202020202020202020202020202020202020202020202080808080808080808080808080808080808080808080808080808080808080800060019646973636f6e6e656374656420647572696e672073657475708e68c4763d4ebe74817028d6a02fc833da4ca71b584623466ede149efeaa17c86ed6adfcca9e8c3770b5ca620ca3b15bc4d28a033357d2dfc640ee2b81a77893" - }, - { - "name": "close", - "message_type": 55051, - "signer": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c", - "wire": "d70b010101010101010101010101010101010101010101010101010101010101010102020202020202020202020202020202020202020202020202020202020202020909090909090909090909090909090909090909090909090909090909090909b8da4a492327808e2b591f2db8be5451b07c3d54feb5d719937804e3126501e4092d425044ce969609d8583736e9693602bc6da36eebfa5bf12c99bc57a5440c" - }, - { - "name": "close_ack_paid", - "message_type": 55053, - "signer": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c", - "wire": "d70d010101010101010101010101010101010101010101010101010101010101010102020202020202020202020202020202020202020202020202020202020202020909090909090909090909090909090909090909090909090909090909090909000201012200010505050505050505050505050505050505050505050505050505050505050505b7ddea980cd1041b4dd42c200b4a1f23e84bf9c78526f25a60b46bac63d9694e27170e46aa38740eeda54b20324eafa15074324b53f43339f54375eee3c93683" - }, - { - "name": "close_ack_unpaid_absent_tlv", - "message_type": 55053, - "signer": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c", - "wire": "d70d010101010101010101010101010101010101010101010101010101010101010102020202020202020202020202020202020202020202020202020202020202020909090909090909090909090909090909090909090909090909090909090909000200b5ef97faf9628a1baf0bb5911c52cc21482c750da7107d10f12c064b2ae0e7f04bfda970e26140b436fb5a5855c0374b502b21448f92ae990d202d120670beed" - }, - { - "name": "close_ack_unpaid_empty_tlv", - "message_type": 55053, - "signer": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c", - "wire": "d70d010101010101010101010101010101010101010101010101010101010101010102020202020202020202020202020202020202020202020202020202020202020909090909090909090909090909090909090909090909090909090909090909000200010050a03c5bd10fffb8e15f81cdf2868320fcb86f235654c6f3ee60d56deda66a4e756c3f5f01cf83f9c15791f8f2df269167fd4a06f3477d8041ac2bf2079e0458" - } - ], - "reestablish": [ - { - "state": 0, - "type": 55001, - "value": "02020202020202020202020202020202020202020202020202020202020202020000000000000000000000000000000000000000000000000000000000000000000000" - }, - { - "state": 1, - "type": 55001, - "value": "02020202020202020202020202020202020202020202020202020202020202020100000000000000000000000000000000000000000000000000000000000000000000" - }, - { - "state": 2, - "type": 55001, - "value": "02020202020202020202020202020202020202020202020202020202020202020200000909090909090909090909090909090909090909090909090909090909090909" - }, - { - "state": 3, - "type": 55001, - "value": "02020202020202020202020202020202020202020202020202020202020202020300000909090909090909090909090909090909090909090909090909090909090909" - }, - { - "state": 4, - "type": 55001, - "value": "02020202020202020202020202020202020202020202020202020202020202020400000909090909090909090909090909090909090909090909090909090909090909" - }, - { - "state": 5, - "type": 55001, - "value": "02020202020202020202020202020202020202020202020202020202020202020500000909090909090909090909090909090909090909090909090909090909090909" - }, - { - "state": 6, - "type": 55001, - "value": "02020202020202020202020202020202020202020202020202020202020202020600000000000000000000000000000000000000000000000000000000000000000000" - } - ] -} diff --git a/crates/ffor-protocol/tests/data/extract_appendix_d.py b/crates/ffor-protocol/tests/data/extract_appendix_d.py deleted file mode 100644 index 215a8be4bf..0000000000 --- a/crates/ffor-protocol/tests/data/extract_appendix_d.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Extract pinned public Appendix D fixtures, reconstructing abbreviated D.5 bytes. - -Usage: python3 extract_appendix_d.py /path/to/ffor-variant-d-vectors.md -Writes appendix-d.json beside this script. Every reconstruction is checked against -the independently published hashes, and uses published signatures without signing. -""" - -import hashlib -import json -import pathlib -import re -import struct -import sys - - -def sha(data): - return hashlib.sha256(data).digest() - - -def field(section, name): - return re.search(r"\| `" + re.escape(name) + r"` \| `([0-9a-f]+)`", section)[1] - - -def tlv(kind, value): - length = len(value) - encoded_length = bytes([length]) if length < 253 else b"\xfd" + struct.pack(">H", length) - return bytes([kind]) + encoded_length + value - - -def extract(source): - fixtures = [] - for number in range(1, 7): - section = source.split(f"## D.{number} ")[1].split("\n## D.")[0] - fixture = {"scenario": f"D.{number}"} - for label, name in [ - ("init_hash", "T_init"), ("setup_hash", "T_setup"), - ("book_hash", "H_book"), ("commitment_hash", "H_commit"), - ("activation_hash", "H_act"), - ]: - fixture[label] = field(section, name) - for label, name in [ - ("init_wire", "ff_init"), ("accept_wire", "ff_accept"), - ("activate_wire", "ff_activate"), ("ack_wire", "ff_activate_ack"), - ]: - message = section.split(f"**`{name}` (type ")[1].split("**`ff_")[0] - if number == 5 and name in ("ff_init", "ff_accept"): - header = bytes.fromhex(fixtures[0]["init_wire"])[2:34] - epoch = bytes.fromhex(field(section, "epoch_id")) - amounts = struct.pack(">Q", 546000) * 483 - hashes = b"".join( - sha(sha(b"ffor/vector/D.5/preimage" + struct.pack(">H", k))) - for k in range(1, 484) - ) - if name == "ff_init": - wire = struct.pack(">H", 55001) + header + epoch - wire += struct.pack(">BQHQIIIIQH", 4, 263718000, 483, 546000, - 798992, 800000, 1000, 5000, 0, 0) - wire += tlv(9, amounts) - else: - wire = struct.pack(">H", 55003) + header + epoch + struct.pack(">Q", 42) - wire += tlv(1, hashes) + tlv(7, struct.pack(">Q", 0)) - wire += tlv(9, amounts) + tlv(11, bytes.fromhex(fixture["init_hash"])) - signature = re.search(r"signature \(final 64 bytes\) \| `([0-9a-f]+)`", message)[1] - wire += bytes.fromhex(signature) - else: - wire = bytes.fromhex(re.search(r"Wire bytes:\s*```\s*([0-9a-f]+)\s*```", message)[1]) - expected = re.search(r"SHA256\(wire bytes\) \| `([0-9a-f]+)`", message)[1] - assert sha(wire).hex() == expected, (number, name) - fixture[label] = wire.hex() - if number == 5: - book = epoch + struct.pack(">BBH", 4, 1, 483) - for k in range(1, 484): - payment_hash = hashes[(k - 1) * 32:k * 32] - book += struct.pack(">H", k) + payment_hash - book += struct.pack(">QIIQ", 546000, 800000, 798992, k - 1) - else: - book_section = section.split(f"### D.{number}.4 ")[1].split(f"### D.{number}.5 ")[0] - book = bytes.fromhex(re.search(r"```\s*([0-9a-f]+)\s*```", book_section)[1]) - assert sha(b"ffor/book" + book).hex() == fixture["book_hash"] - fixture["book"] = book.hex() - txids = re.findall( - r"\| `n_[RS]\^act` / `txid\(C\^[RS]\)` internal byte order \| 43 / `([0-9a-f]+)`", section - ) - assert len(txids) == 2 - fixture["receiver_txid"], fixture["settlement_txid"] = txids - fixtures.append(fixture) - return fixtures - - -if __name__ == "__main__": - fixture_data = extract(pathlib.Path(sys.argv[1]).read_text()) - pathlib.Path(__file__).with_name("appendix-d.json").write_text( - json.dumps(fixture_data, indent=2) + "\n" - ) diff --git a/crates/ffor-protocol/tests/data/generate_beignet_lifecycle.cjs b/crates/ffor-protocol/tests/data/generate_beignet_lifecycle.cjs deleted file mode 100644 index c3851ef1b2..0000000000 --- a/crates/ffor-protocol/tests/data/generate_beignet_lifecycle.cjs +++ /dev/null @@ -1,50 +0,0 @@ -// Reproduce lifecycle interoperability fixtures using the pinned Beignet implementation. -// Usage: node -r /path/to/beignet/node_modules/ts-node/register \ -// generate_beignet_lifecycle.cjs /path/to/beignet /path/to/output.json -const fs = require('node:fs'); -const path = require('node:path'); -const { execFileSync } = require('node:child_process'); - -const reference = path.resolve(process.argv[2]); -const expectedRevision = '8aee31d18e596fe49a0d195b325a6e757d7a009b'; -const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: reference, encoding: 'utf8' }).trim(); -if (revision !== expectedRevision) throw new Error('Beignet revision differs from the fixture baseline'); -const codecs = require(path.join(reference, 'src/lightning/ffor/messages.ts')); -const { getPublicKey } = require(path.join(reference, 'src/lightning/crypto/ecdh.ts')); - -// Deterministic test-only signing material. These fixtures contain no wallet keys or funds. -const secret = Buffer.alloc(32, 42); -const publicKey = getPublicKey(secret); -const header = { channelId: Buffer.alloc(32, 1), epochId: Buffer.alloc(32, 2) }; -const activation = { ...header, activationHash: Buffer.alloc(32, 9) }; -const emptyClose = codecs.encodeFforCloseAckUnsigned({ - ...activation, numSlots: 2, settled: Buffer.from([0]), preimages: [] -}); -const cases = [ - ['abort', 55049, codecs.encodeFforAbortUnsigned({ - ...header, transcriptHash: Buffer.alloc(32, 8), reason: 6, - data: Buffer.from('disconnected during setup', 'utf8') - })], - ['close', 55051, codecs.encodeFforCloseUnsigned(activation)], - ['close_ack_paid', 55053, codecs.encodeFforCloseAckUnsigned({ - ...activation, numSlots: 2, settled: Buffer.from([1]), - preimages: [{ k: 1, preimage: Buffer.alloc(32, 5) }] - })], - ['close_ack_unpaid_absent_tlv', 55053, emptyClose], - ['close_ack_unpaid_empty_tlv', 55053, Buffer.concat([emptyClose, Buffer.from([1, 0])])] -]; -const fixtures = cases.map(([name, type, unsigned]) => { - const body = codecs.signFforMessage(type, unsigned, secret); - if (!codecs.verifyFforMessage(type, body, publicKey)) throw new Error('Fixture authentication failed'); - return { name, message_type: type, signer: publicKey.toString('hex'), wire: codecs.fforWireBytes(type, body).toString('hex') }; -}); -const reestablish = Array.from({ length: 7 }, (_, state) => { - // Beignet retains hAct while ACTIVATING, even though section 11.1 says zero before ACTIVE. - // Preserve that input as an interoperability case, without treating it as activation proof. - const activationHash = Buffer.alloc(32, state >= 2 && state <= 5 ? 9 : 0); - const tlv = codecs.encodeFforReestablishTlv({ epochId: header.epochId, state, lastSeq: 0, activationHash }); - const decoded = codecs.decodeFforReestablishTlv(tlv.value); - if (decoded.state !== state || !decoded.activationHash.equals(activationHash)) throw new Error('Reconnect fixture mismatch'); - return { state, type: Number(tlv.type), value: tlv.value.toString('hex') }; -}); -fs.writeFileSync(process.argv[3], JSON.stringify({ source_revision: revision, fixtures, reestablish }, null, 2) + '\n'); diff --git a/crates/ffor-protocol/tests/reestablish.rs b/crates/ffor-protocol/tests/reestablish.rs deleted file mode 100644 index c35b0ec05d..0000000000 --- a/crates/ffor-protocol/tests/reestablish.rs +++ /dev/null @@ -1,84 +0,0 @@ -use ffor_protocol::reestablish::{ - Reestablish, ReestablishError, ReportedState, TLV_TYPE, VALUE_LEN, -}; -use proptest::prelude::*; -use serde::Deserialize; - -#[derive(Deserialize)] -struct Reference { - source_revision: String, - reestablish: Vec, -} - -#[derive(Deserialize)] -struct Fixture { - state: u8, - r#type: u64, - value: String, -} - -#[test] -fn all_states_match_the_pinned_reference() { - let reference: Reference = - serde_json::from_str(include_str!("data/beignet-lifecycle.json")).unwrap(); - assert_eq!(reference.source_revision, "8aee31d18e596fe49a0d195b325a6e757d7a009b"); - assert_eq!(reference.reestablish.len(), 7); - for fixture in reference.reestablish { - assert_eq!(fixture.r#type, TLV_TYPE); - let bytes: Vec = (0..fixture.value.len()) - .step_by(2) - .map(|i| u8::from_str_radix(&fixture.value[i..i + 2], 16).unwrap()) - .collect(); - let report = Reestablish::decode(&bytes).unwrap(); - assert_eq!(report.epoch_id, [2; 32]); - assert_eq!(report.state as u8, fixture.state); - assert_eq!(report.encode().as_slice(), bytes); - if report.state == ReportedState::Activating { - // This unsigned report is deliberately preserved, not accepted as activation evidence. - assert_eq!(report.activation_hash, [9; 32]); - } - } -} - -#[test] -fn every_length_and_unknown_state_boundary_is_rejected() { - let value = [0; VALUE_LEN]; - for length in 0..VALUE_LEN { - assert_eq!(Reestablish::decode(&value[..length]), Err(ReestablishError::Length)); - } - assert_eq!(Reestablish::decode(&[0; VALUE_LEN + 1]), Err(ReestablishError::Length)); - for state in 7..=255 { - let mut value = value; - value[32] = state; - assert_eq!(Reestablish::decode(&value), Err(ReestablishError::State)); - } -} - -#[test] -fn nonzero_sequence_is_never_variant_d_settlement_evidence() { - for sequence in 1_u16..=u16::MAX { - let mut value = [0; VALUE_LEN]; - value[32] = ReportedState::Active as u8; - value[33..35].copy_from_slice(&sequence.to_be_bytes()); - assert_eq!(Reestablish::decode(&value), Err(ReestablishError::Sequence)); - } -} - -proptest! { - #[test] - fn round_trip_preserves_all_report_bytes(epoch in any::<[u8; 32]>(), hash in any::<[u8; 32]>(), state in 0_u8..7) { - let mut bytes = [0; VALUE_LEN]; - bytes[..32].copy_from_slice(&epoch); - bytes[32] = state; - bytes[35..].copy_from_slice(&hash); - let report = Reestablish::decode(&bytes).unwrap(); - prop_assert_eq!(report.encode(), bytes); - } - - #[test] - fn arbitrary_inputs_never_panic(bytes in proptest::collection::vec(any::(), 0..256)) { - if let Ok(report) = Reestablish::decode(&bytes) { - prop_assert_eq!(report.encode().to_vec(), bytes); - } - } -} diff --git a/crates/ffor-protocol/tests/reference_lifecycle.rs b/crates/ffor-protocol/tests/reference_lifecycle.rs deleted file mode 100644 index bbdbaea0f3..0000000000 --- a/crates/ffor-protocol/tests/reference_lifecycle.rs +++ /dev/null @@ -1,37 +0,0 @@ -use bitcoin::secp256k1::PublicKey; -use ffor_protocol::wire::Message; -use serde::Deserialize; - -#[derive(Deserialize)] -struct Reference { - source_revision: String, - fixtures: Vec, -} - -#[derive(Deserialize)] -struct Fixture { - name: String, - message_type: u16, - signer: String, - wire: String, -} - -fn hex(value: &str) -> Vec { - (0..value.len()).step_by(2).map(|i| u8::from_str_radix(&value[i..i + 2], 16).unwrap()).collect() -} - -#[test] -fn beignet_lifecycle_messages_preserve_signed_wire_bytes() { - let reference: Reference = - serde_json::from_str(include_str!("data/beignet-lifecycle.json")).unwrap(); - assert_eq!(reference.source_revision, "8aee31d18e596fe49a0d195b325a6e757d7a009b"); - assert_eq!(reference.fixtures.len(), 5); - for fixture in reference.fixtures { - let wire = hex(&fixture.wire); - let decoded = - Message::decode(&wire).unwrap_or_else(|error| panic!("{}: {error}", fixture.name)); - assert_eq!(decoded.message_type(), fixture.message_type); - decoded.verify_signature(&PublicKey::from_slice(&hex(&fixture.signer)).unwrap()).unwrap(); - assert_eq!(decoded.encode().unwrap(), wire, "{}", fixture.name); - } -} diff --git a/crates/ffor-protocol/tests/setup.rs b/crates/ffor-protocol/tests/setup.rs deleted file mode 100644 index c5985dd1e5..0000000000 --- a/crates/ffor-protocol/tests/setup.rs +++ /dev/null @@ -1,337 +0,0 @@ -use bitcoin::hashes::{sha256, Hash}; -use bitcoin::secp256k1::{Message as SecpMessage, PublicKey, Secp256k1, SecretKey}; -use ffor_protocol::setup::{AuthenticatedSetup, SetupError}; -use ffor_protocol::transcript; -use ffor_protocol::wire::{ - Accept, Activate, CloseAck, Header, Init, Message, Payload, Preimage, Tlv, -}; -use proptest::prelude::*; -use serde::Deserialize; - -fn key(value: u8) -> PublicKey { - PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[value; 32]).unwrap()) -} - -fn sign(message: &mut Message, value: u8) { - let digest = message.signature_digest().unwrap(); - message.signature = Secp256k1::new() - .sign_ecdsa( - &SecpMessage::from_digest(digest), - &SecretKey::from_slice(&[value; 32]).unwrap(), - ) - .serialize_compact(); -} - -fn message(payload: Payload, signer: u8) -> Message { - let mut message = Message { - header: Header { channel_id: [1; 32], epoch_id: [2; 32] }, - payload, - extensions: Vec::new(), - signature: [0; 64], - }; - sign(&mut message, signer); - message -} - -fn setup_messages() -> (Message, Message) { - let init = message( - Payload::Init(Init { - budget_msat: 2_000_000, - min_payment_msat: 354_000, - settlement_deadline: 1000, - voucher_expiry: 2008, - fee_base_msat: 1000, - fee_proportional_millionths: 100, - amounts_msat: vec![1_000_000; 2], - witness_peers: None, - hash_chain: false, - }), - 42, - ); - let accept = message( - Payload::Accept(Accept { - s_commitment_number: 3, - payment_hashes: vec![ - sha256::Hash::hash(&[5; 32]).to_byte_array(), - sha256::Hash::hash(&[6; 32]).to_byte_array(), - ], - s_htlc_id_base: 8, - amounts_msat: vec![1_000_000; 2], - init_hash: transcript::init_hash(&init.encode().unwrap()), - }), - 43, - ); - (init, accept) -} - -fn setup() -> AuthenticatedSetup { - let (init, accept) = setup_messages(); - AuthenticatedSetup::new(&init, &accept, key(42), key(43)).unwrap() -} - -fn activate(setup: &AuthenticatedSetup, height: u32) -> Message { - message( - Payload::Activate(Activate { - setup_hash: setup.setup_hash(), - book_hash: setup.book_hash(), - commit_hash: [9; 32], - epoch_start_height: height, - }), - 42, - ) -} - -fn refresh_accept(init: &Message, accept: &mut Message) { - let Payload::Accept(accepted) = &mut accept.payload else { unreachable!() }; - accepted.init_hash = transcript::init_hash(&init.encode().unwrap()); - sign(accept, 43); -} - -fn hex(value: &str) -> Vec { - (0..value.len()).step_by(2).map(|i| u8::from_str_radix(&value[i..i + 2], 16).unwrap()).collect() -} - -#[derive(Deserialize)] -struct Fixture { - init_wire: String, - accept_wire: String, - activate_wire: String, - ack_wire: String, - book: String, - book_hash: String, - setup_hash: String, - commitment_hash: String, - activation_hash: String, -} - -#[test] -fn all_public_books_and_activation_statements_authenticate() { - let fixtures: Vec = - serde_json::from_str(include_str!("data/appendix-d.json")).unwrap(); - assert_eq!(fixtures.len(), 6); - let receiver = PublicKey::from_slice(&hex( - "039fca7f8157aa768708894ffd92550fe970edd18526a5f936583ea3b54dab3228", - )) - .unwrap(); - let settlement = PublicKey::from_slice(&hex( - "02087b7d1b4789170f6e374f0a0e58a1b7a899e34929795314ab6964e69609e9c0", - )) - .unwrap(); - for fixture in fixtures { - let init = Message::decode(&hex(&fixture.init_wire)).unwrap(); - let accept = Message::decode(&hex(&fixture.accept_wire)).unwrap(); - let setup = AuthenticatedSetup::new(&init, &accept, receiver, settlement).unwrap(); - assert_eq!(setup.canonical_book(), hex(&fixture.book)); - assert_eq!(setup.book_hash().as_slice(), hex(&fixture.book_hash)); - assert_eq!(setup.setup_hash().as_slice(), hex(&fixture.setup_hash)); - let activation = Message::decode(&hex(&fixture.activate_wire)).unwrap(); - let expected_commitment = hex(&fixture.commitment_hash).try_into().unwrap(); - let hash = setup.validate_activation(&activation, expected_commitment, 790_000).unwrap(); - assert_eq!(hash.as_slice(), hex(&fixture.activation_hash)); - let ack = Message::decode(&hex(&fixture.ack_wire)).unwrap(); - setup.validate_activation_ack(&ack, hash).unwrap(); - } -} - -#[test] -fn setup_requires_distinct_expected_signers_and_unique_hashes() { - let (init, mut accept) = setup_messages(); - assert!(matches!( - AuthenticatedSetup::new(&init, &accept, key(42), key(42)), - Err(SetupError::Roles) - )); - assert!(matches!( - AuthenticatedSetup::new(&init, &accept, key(43), key(42)), - Err(SetupError::Wire(_)) - )); - let Payload::Accept(accepted) = &mut accept.payload else { unreachable!() }; - accepted.payment_hashes[1] = accepted.payment_hashes[0]; - sign(&mut accept, 43); - assert!(matches!( - AuthenticatedSetup::new(&init, &accept, key(42), key(43)), - Err(SetupError::DuplicateHash) - )); -} - -#[test] -fn setup_rejects_signed_fee_overflow() { - let (mut init, mut accept) = setup_messages(); - let Payload::Init(terms) = &mut init.payload else { unreachable!() }; - terms.amounts_msat = vec![u64::MAX]; - terms.budget_msat = u64::MAX; - terms.fee_proportional_millionths = 2; - sign(&mut init, 42); - let Payload::Accept(accepted) = &mut accept.payload else { unreachable!() }; - accepted.amounts_msat = vec![u64::MAX]; - accepted.payment_hashes.truncate(1); - refresh_accept(&init, &mut accept); - assert!(matches!( - AuthenticatedSetup::new(&init, &accept, key(42), key(43)), - Err(SetupError::FeeOverflow) - )); -} - -#[test] -fn setup_copies_signed_inputs_and_preserves_optional_extensions() { - let (mut init, mut accept) = setup_messages(); - init.extensions.push(Tlv { kind: 99, value: vec![1, 2, 3] }); - sign(&mut init, 42); - refresh_accept(&init, &mut accept); - let setup = AuthenticatedSetup::new(&init, &accept, key(42), key(43)).unwrap(); - let original_wire = setup.init().encode().unwrap(); - init.extensions[0].value[0] = 255; - accept.header.epoch_id = [8; 32]; - assert_eq!(setup.init().encode().unwrap(), original_wire); - assert_eq!(setup.accept().header, setup.header()); - assert_eq!(setup.vouchers()[1].htlc_id, 9); - assert_eq!(setup.terms().fees.base_msat, 1000); -} - -#[test] -fn requested_hash_chain_must_link_every_slot() { - let (mut init, mut accept) = setup_messages(); - let Payload::Init(terms) = &mut init.payload else { unreachable!() }; - terms.hash_chain = true; - sign(&mut init, 42); - refresh_accept(&init, &mut accept); - assert!(matches!( - AuthenticatedSetup::new(&init, &accept, key(42), key(43)), - Err(SetupError::HashChain) - )); - let Payload::Accept(accepted) = &mut accept.payload else { unreachable!() }; - accepted.payment_hashes[0] = sha256::Hash::hash(&accepted.payment_hashes[1]).to_byte_array(); - sign(&mut accept, 43); - AuthenticatedSetup::new(&init, &accept, key(42), key(43)).unwrap(); -} - -#[test] -fn activation_checks_actual_commitments_identity_and_height_boundaries() { - let setup = setup(); - let activation = activate(&setup, 990); - assert_eq!(setup.validate_activation(&activation, [8; 32], 990), Err(SetupError::Transcript)); - for height in [983, 997, 1000, u32::MAX] { - assert_eq!( - setup.validate_activation(&activation, [9; 32], height), - Err(SetupError::Height) - ); - } - for height in [984, 990, 996] { - setup.validate_activation(&activation, [9; 32], height).unwrap(); - } - let mut other_epoch = activation; - other_epoch.header.epoch_id[0] ^= 1; - sign(&mut other_epoch, 42); - assert_eq!(setup.validate_activation(&other_epoch, [9; 32], 990), Err(SetupError::Identity)); - let at_deadline = activate(&setup, 1000); - assert_eq!(setup.validate_activation(&at_deadline, [9; 32], 999), Err(SetupError::Height)); -} - -#[test] -fn activation_ack_is_bound_to_role_type_and_activation_hash() { - let setup = setup(); - let hash = setup.validate_activation(&activate(&setup, 990), [9; 32], 990).unwrap(); - let mut ack = message(Payload::ActivateAck(hash), 43); - setup.validate_activation_ack(&ack, hash).unwrap(); - assert_eq!(setup.validate_activation_ack(&ack, [0; 32]), Err(SetupError::Transcript)); - sign(&mut ack, 42); - assert!(matches!(setup.validate_activation_ack(&ack, hash), Err(SetupError::Wire(_)))); - let close = message(Payload::Close(hash), 43); - assert_eq!(setup.validate_activation_ack(&close, hash), Err(SetupError::MessageType)); -} - -#[test] -fn lifecycle_validation_rejects_signed_wrong_message_types() { - let setup = setup(); - let receiver_close = message(Payload::Close([9; 32]), 42); - let settlement_close = message(Payload::Close([9; 32]), 43); - assert_eq!( - setup.validate_activation(&receiver_close, [9; 32], 990), - Err(SetupError::MessageType) - ); - assert_eq!(setup.validate_close_ack(&settlement_close, [9; 32]), Err(SetupError::MessageType)); - assert!(matches!( - AuthenticatedSetup::new(&receiver_close, &settlement_close, key(42), key(43)), - Err(SetupError::MessageType) - )); -} - -#[test] -fn activation_cannot_admit_at_local_deadline_even_when_peer_tip_is_close() { - let setup = setup(); - assert_eq!( - setup.validate_activation(&activate(&setup, 999), [9; 32], 1000), - Err(SetupError::Height) - ); -} - -#[test] -fn close_ack_verifies_book_size_and_preimages() { - let setup = setup(); - let mut ack = message( - Payload::CloseAck(CloseAck { - activation_hash: [9; 32], - num_slots: 2, - settled: vec![1], - preimages_tlv_present: true, - preimages: vec![Preimage { slot: 1, value: [5; 32] }], - }), - 43, - ); - setup.validate_close_ack(&ack, [9; 32]).unwrap(); - let mut missing_preimage_tlv = ack.clone(); - let Payload::CloseAck(close) = &mut missing_preimage_tlv.payload else { unreachable!() }; - close.preimages_tlv_present = false; - assert!(missing_preimage_tlv.signature_digest().is_err()); - assert!(missing_preimage_tlv.encode().is_err()); - assert_eq!(setup.validate_close_ack(&ack, [8; 32]), Err(SetupError::Transcript)); - let Payload::CloseAck(close) = &mut ack.payload else { unreachable!() }; - close.preimages[0].value = [6; 32]; - sign(&mut ack, 43); - assert_eq!(setup.validate_close_ack(&ack, [9; 32]), Err(SetupError::CloseBook)); - let Payload::CloseAck(close) = &mut ack.payload else { unreachable!() }; - close.num_slots = 3; - sign(&mut ack, 43); - assert_eq!(setup.validate_close_ack(&ack, [9; 32]), Err(SetupError::CloseBook)); -} - -#[test] -fn chained_close_cannot_claim_a_later_slot_without_every_prior_slot() { - let (mut init, mut accept) = setup_messages(); - let Payload::Init(terms) = &mut init.payload else { unreachable!() }; - terms.hash_chain = true; - sign(&mut init, 42); - let first_preimage = sha256::Hash::hash(&[6; 32]).to_byte_array(); - let Payload::Accept(accepted) = &mut accept.payload else { unreachable!() }; - accepted.payment_hashes[0] = sha256::Hash::hash(&first_preimage).to_byte_array(); - refresh_accept(&init, &mut accept); - let setup = AuthenticatedSetup::new(&init, &accept, key(42), key(43)).unwrap(); - let mut ack = message( - Payload::CloseAck(CloseAck { - activation_hash: [9; 32], - num_slots: 2, - settled: vec![2], - preimages_tlv_present: true, - preimages: vec![Preimage { slot: 2, value: [6; 32] }], - }), - 43, - ); - assert_eq!(setup.validate_close_ack(&ack, [9; 32]), Err(SetupError::CloseBook)); - let Payload::CloseAck(close) = &mut ack.payload else { unreachable!() }; - close.settled[0] = 3; - close.preimages.insert(0, Preimage { slot: 1, value: first_preimage }); - sign(&mut ack, 43); - setup.validate_close_ack(&ack, [9; 32]).unwrap(); -} - -proptest! { - #[test] - fn signed_wrong_activation_hashes_are_rejected(field in 0_u8..3, index in 0_usize..32, bit in 0_u8..8) { - let setup = setup(); - let mut activation = activate(&setup, 990); - let Payload::Activate(contents) = &mut activation.payload else { unreachable!() }; - let hash = match field { 0 => &mut contents.setup_hash, 1 => &mut contents.book_hash, _ => &mut contents.commit_hash }; - hash[index] ^= 1 << bit; - sign(&mut activation, 42); - prop_assert_eq!(setup.validate_activation(&activation, [9; 32], 990), Err(SetupError::Transcript)); - } -} diff --git a/crates/ffor-protocol/tests/transcript.rs b/crates/ffor-protocol/tests/transcript.rs deleted file mode 100644 index e92b8941ac..0000000000 --- a/crates/ffor-protocol/tests/transcript.rs +++ /dev/null @@ -1,161 +0,0 @@ -use bitcoin::hashes::{sha256, Hash}; -use bitcoin::secp256k1::{Message, PublicKey, Secp256k1, SecretKey}; -use ffor_protocol::transcript; -use ffor_protocol::transcript::{verify_message_signature, SignatureError}; -use proptest::prelude::*; -use serde::Deserialize; - -#[derive(Deserialize)] -struct Fixture { - scenario: String, - init_wire: String, - accept_wire: String, - activate_wire: String, - ack_wire: String, - book: String, - init_hash: String, - setup_hash: String, - book_hash: String, - commitment_hash: String, - activation_hash: String, - receiver_txid: String, - settlement_txid: String, -} - -fn hex(value: &str) -> Vec { - assert_eq!(value.len() % 2, 0); - (0..value.len()).step_by(2).map(|i| u8::from_str_radix(&value[i..i + 2], 16).unwrap()).collect() -} - -fn digest(value: &str) -> [u8; 32] { - hex(value).try_into().unwrap() -} - -#[test] -fn appendix_d_all_six_transcripts_match_published_bytes() { - let fixtures: Vec = - serde_json::from_str(include_str!("data/appendix-d.json")).unwrap(); - assert_eq!(fixtures.len(), 6); - let receiver_key = PublicKey::from_slice(&hex( - "039fca7f8157aa768708894ffd92550fe970edd18526a5f936583ea3b54dab3228", - )) - .unwrap(); - let settlement_key = PublicKey::from_slice(&hex( - "02087b7d1b4789170f6e374f0a0e58a1b7a899e34929795314ab6964e69609e9c0", - )) - .unwrap(); - for fixture in fixtures { - let init = transcript::init_hash(&hex(&fixture.init_wire)); - assert_eq!(init, digest(&fixture.init_hash), "{}", fixture.scenario); - let setup = transcript::setup_hash(&init, &hex(&fixture.accept_wire)); - assert_eq!(setup, digest(&fixture.setup_hash)); - let book = transcript::book_hash(&hex(&fixture.book)); - assert_eq!(book, digest(&fixture.book_hash)); - let commitment = transcript::commitment_hash( - 43, - &digest(&fixture.receiver_txid), - 43, - &digest(&fixture.settlement_txid), - ); - assert_eq!(commitment, digest(&fixture.commitment_hash)); - assert_eq!( - transcript::activation_hash(&setup, &book, &commitment, 790_000), - digest(&fixture.activation_hash) - ); - for (encoded, key) in [ - (&fixture.init_wire, receiver_key), - (&fixture.accept_wire, settlement_key), - (&fixture.activate_wire, receiver_key), - (&fixture.ack_wire, settlement_key), - ] { - let wire = hex(encoded); - let signature_start = wire.len() - 64; - let signature = wire[signature_start..].try_into().unwrap(); - let kind = u16::from_be_bytes(wire[..2].try_into().unwrap()); - let unsigned = &wire[2..signature_start]; - verify_message_signature(kind, unsigned, &signature, &key).unwrap(); - assert_eq!( - verify_message_signature(kind + 2, unsigned, &signature, &key), - Err(SignatureError::Invalid) - ); - } - } -} - -fn test_key() -> (SecretKey, PublicKey) { - let secret = SecretKey::from_slice(&[42; 32]).unwrap(); - let public = PublicKey::from_secret_key(&Secp256k1::new(), &secret); - (secret, public) -} - -fn sign_digest(digest: [u8; 32]) -> [u8; 64] { - Secp256k1::new().sign_ecdsa(&Message::from_digest(digest), &test_key().0).serialize_compact() -} - -#[test] -fn signature_rejects_invalid_compact_scalars() { - assert_eq!( - verify_message_signature(55001, b"body", &[255; 64], &test_key().1), - Err(SignatureError::Malformed) - ); - assert_eq!( - verify_message_signature(55001, b"body", &[0; 64], &test_key().1), - Err(SignatureError::Invalid) - ); -} - -#[test] -fn signature_rejects_high_s_without_normalizing_received_bytes() { - let body = [7; 128]; - let mut signature = sign_digest(transcript::message_digest(55001, &body)); - // Negating s modulo the curve order preserves ECDSA validity but violates canonical low-S. - let order = hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); - let mut borrow = 0_i16; - for index in (0..32).rev() { - let value = i16::from(order[index]) - i16::from(signature[32 + index]) - borrow; - signature[32 + index] = value.rem_euclid(256) as u8; - borrow = i16::from(value < 0); - } - assert_eq!( - verify_message_signature(55001, &body, &signature, &test_key().1), - Err(SignatureError::HighS) - ); -} - -#[test] -fn signature_requires_single_hash_and_ffor_domain() { - let body = [9; 128]; - let digest = transcript::message_digest(55001, &body); - for wrong_digest in - [sha256::Hash::hash(&body).to_byte_array(), sha256::Hash::hash(&digest).to_byte_array()] - { - assert_eq!( - verify_message_signature(55001, &body, &sign_digest(wrong_digest), &test_key().1), - Err(SignatureError::Invalid) - ); - } -} - -proptest! { - #[test] - fn signed_bytes_and_expected_peer_cannot_be_substituted(mut body in prop::collection::vec(any::(), 64..2048), changed_index in any::()) { - let (_, public) = test_key(); - let signature = sign_digest(transcript::message_digest(55001, &body)); - prop_assert_eq!(verify_message_signature(55001, &body, &signature, &public), Ok(())); - let other_secret = SecretKey::from_slice(&[43; 32]).unwrap(); - let other_public = PublicKey::from_secret_key(&Secp256k1::new(), &other_secret); - prop_assert_eq!(verify_message_signature(55001, &body, &signature, &other_public), Err(SignatureError::Invalid)); - let index = changed_index % body.len(); - body[index] ^= 1; - prop_assert_eq!(verify_message_signature(55001, &body, &signature, &public), Err(SignatureError::Invalid)); - } - - #[test] - fn transcript_fields_and_domains_are_bound(bytes in prop::collection::vec(any::(), 0..2048), height in 0_u32..u32::MAX) { - let init = transcript::init_hash(&bytes); - let book = transcript::book_hash(&bytes); - prop_assert_ne!(init, book); - prop_assert_ne!(transcript::message_digest(55001, &bytes), transcript::message_digest(55003, &bytes)); - prop_assert_ne!(transcript::activation_hash(&init, &book, &init, height), transcript::activation_hash(&init, &book, &init, height + 1)); - } -} diff --git a/crates/ffor-protocol/tests/wire.rs b/crates/ffor-protocol/tests/wire.rs deleted file mode 100644 index 9a4b655e65..0000000000 --- a/crates/ffor-protocol/tests/wire.rs +++ /dev/null @@ -1,502 +0,0 @@ -use bitcoin::secp256k1::PublicKey; -use ffor_protocol::transcript; -use ffor_protocol::wire::{ - Abort, CloseAck, Message, Payload, Preimage, Tlv, WireError, MAX_MESSAGE_LEN, MAX_TLV_COUNT, -}; -use proptest::prelude::*; -use serde::Deserialize; - -#[derive(Deserialize)] -struct Fixture { - scenario: String, - init_wire: String, - accept_wire: String, - activate_wire: String, - ack_wire: String, -} - -fn fixtures() -> Vec { - serde_json::from_str(include_str!("data/appendix-d.json")).unwrap() -} - -fn hex(value: &str) -> Vec { - (0..value.len()).step_by(2).map(|i| u8::from_str_radix(&value[i..i + 2], 16).unwrap()).collect() -} - -fn receiver() -> PublicKey { - PublicKey::from_slice(&hex( - "039fca7f8157aa768708894ffd92550fe970edd18526a5f936583ea3b54dab3228", - )) - .unwrap() -} - -fn settlement() -> PublicKey { - PublicKey::from_slice(&hex( - "02087b7d1b4789170f6e374f0a0e58a1b7a899e34929795314ab6964e69609e9c0", - )) - .unwrap() -} - -fn init() -> Message { - Message::decode(&hex(&fixtures()[0].init_wire)).unwrap() -} -fn ack() -> Message { - Message::decode(&hex(&fixtures()[0].ack_wire)).unwrap() -} - -// Modified fixture signatures remain untrusted bytes, never newly signed messages. -fn append_tlv(mut wire: Vec, tlv: &[u8]) -> Vec { - wire.splice(wire.len() - 64..wire.len() - 64, tlv.iter().copied()); - wire -} - -fn close_ack() -> Message { - let mut message = ack(); - message.payload = Payload::CloseAck(CloseAck { - activation_hash: [3; 32], - num_slots: 9, - settled: vec![1, 1], - preimages: vec![Preimage { slot: 1, value: [1; 32] }, Preimage { slot: 9, value: [9; 32] }], - preimages_tlv_present: true, - }); - message -} - -#[test] -fn appendix_d_all_published_setup_and_activation_messages_round_trip() { - for fixture in fixtures() { - let initial = Message::decode(&hex(&fixture.init_wire)).unwrap(); - let accept = Message::decode(&hex(&fixture.accept_wire)).unwrap(); - accept.validate_accept(&initial).unwrap(); - for (wire, signer) in [ - (fixture.init_wire, receiver()), - (fixture.accept_wire, settlement()), - (fixture.activate_wire, receiver()), - (fixture.ack_wire, settlement()), - ] { - let bytes = hex(&wire); - let message = Message::decode(&bytes).unwrap(); - assert_eq!(message.encode().unwrap(), bytes, "{}", fixture.scenario); - message.verify_signature(&signer).unwrap(); - assert_eq!( - message.signature_digest().unwrap(), - transcript::message_digest(message.message_type(), &bytes[2..bytes.len() - 64]) - ); - } - } -} - -#[test] -fn truncation_and_oversized_peer_messages_are_rejected() { - let fixture = &fixtures()[0]; - for encoded in - [&fixture.init_wire, &fixture.accept_wire, &fixture.activate_wire, &fixture.ack_wire] - { - let bytes = hex(encoded); - for end in 0..bytes.len() { - assert!(Message::decode(&bytes[..end]).is_err(), "prefix {end}"); - } - } - assert_eq!(Message::decode(&vec![0; MAX_MESSAGE_LEN + 1]), Err(WireError::SizeLimit)); -} - -#[test] -fn unsupported_variants_and_unassigned_message_types_are_rejected() { - for variant in [0, 1, 2, 3, 5, 255] { - let mut bytes = init().encode().unwrap(); - bytes[66] = variant; - assert_eq!(Message::decode(&bytes), Err(WireError::UnsupportedVariant(variant))); - } - for kind in [55011u16, 55021, 55055, 0, u16::MAX] { - let mut bytes = init().encode().unwrap(); - bytes[..2].copy_from_slice(&kind.to_be_bytes()); - assert_eq!(Message::decode(&bytes), Err(WireError::UnsupportedMessage(kind))); - } -} - -#[test] -fn variant_d_disallows_escape_ladders_and_receiver_points() { - for offset in [108, 110] { - let mut bytes = init().encode().unwrap(); - bytes[offset] = 1; - assert_eq!(Message::decode(&bytes), Err(WireError::InvalidField)); - } - for kind in [1, 3, 5] { - let mut bytes = init().encode().unwrap(); - bytes.splice(111..111, [kind, 0]); - assert_eq!(Message::decode(&bytes), Err(WireError::InvalidTlv(u64::from(kind)))); - } -} - -#[test] -fn slot_count_amount_lengths_minimum_budget_and_deadline_are_checked() { - for count in [0u16, 484, u16::MAX] { - let mut bytes = init().encode().unwrap(); - bytes[75..77].copy_from_slice(&count.to_be_bytes()); - assert!(Message::decode(&bytes).is_err()); - } - let mut bytes = init().encode().unwrap(); - bytes[76] = 2; - assert_eq!(Message::decode(&bytes), Err(WireError::InvalidTlv(9))); - for mutate in [0, 1, 2, 3] { - let mut message = init(); - let Payload::Init(terms) = &mut message.payload else { panic!() }; - match mutate { - 0 => terms.amounts_msat[0] = 0, - 1 => terms.min_payment_msat = u64::MAX, - 2 => terms.budget_msat += 1, - _ => terms.voucher_expiry = terms.settlement_deadline, - } - assert!(message.encode().is_err()); - } -} - -#[test] -fn amount_sum_overflow_is_rejected() { - let mut message = init(); - let Payload::Init(terms) = &mut message.payload else { panic!() }; - terms.amounts_msat = vec![u64::MAX, 1]; - terms.budget_msat = 0; - terms.min_payment_msat = 1; - assert_eq!(message.encode(), Err(WireError::InvalidField)); -} - -#[test] -fn required_tlv_and_canonical_bigsize_rules_are_enforced() { - let mut no_amounts = init().encode().unwrap(); - no_amounts.drain(111..no_amounts.len() - 64); - assert_eq!(Message::decode(&no_amounts), Err(WireError::MissingTlv(9))); - for tlv in [vec![9, 0], vec![7, 0]] { - assert_eq!( - Message::decode(&append_tlv(init().encode().unwrap(), &tlv)), - Err(WireError::TlvOrder) - ); - } - assert_eq!( - Message::decode(&append_tlv(init().encode().unwrap(), &[10, 0])), - Err(WireError::UnknownEvenTlv(10)) - ); - for nonminimal in [ - vec![0xfd, 0, 11, 0], - vec![11, 0xfd, 0, 0], - vec![0xfe, 0, 0, 1, 1, 0], - vec![0xff, 0, 0, 0, 0, 0, 1, 0, 0, 0], - ] { - assert_eq!( - Message::decode(&append_tlv(init().encode().unwrap(), &nonminimal)), - Err(WireError::NonCanonicalBigSize) - ); - } - assert_eq!( - Message::decode(&append_tlv( - init().encode().unwrap(), - &[11, 0xff, 255, 255, 255, 255, 255, 255, 255, 255] - )), - Err(WireError::SizeLimit) - ); - assert!(Message::decode(&append_tlv(init().encode().unwrap(), &[11, 2, 1])).is_err()); -} - -#[test] -fn unknown_odd_extensions_preserve_exact_bytes_and_are_signed() { - let mut message = init(); - message.extensions = - vec![Tlv { kind: 257, value: vec![7; 253] }, Tlv { kind: u64::MAX, value: vec![] }]; - let wire = message.encode().unwrap(); - assert_eq!(Message::decode(&wire).unwrap(), message); - assert_eq!(message.verify_signature(&receiver()), Err(WireError::InvalidSignature)); - assert_ne!(message.signature_digest().unwrap(), init().signature_digest().unwrap()); -} - -#[test] -fn witness_points_require_compressed_valid_curve_points_and_exact_lengths() { - let mut message = init(); - let Payload::Init(terms) = &mut message.payload else { panic!() }; - terms.witness_peers = Some(vec![receiver(), settlement()]); - let wire = message.encode().unwrap(); - assert_eq!(Message::decode(&wire).unwrap(), message); - let mut invalid = vec![13, 35, 0, 1]; - invalid.extend_from_slice(&[0; 33]); - assert_eq!( - Message::decode(&append_tlv(init().encode().unwrap(), &invalid)), - Err(WireError::InvalidPoint) - ); - assert!(Message::decode(&append_tlv(init().encode().unwrap(), &[13, 2, 0, 1])).is_err()); - assert_eq!( - Message::decode(&append_tlv(init().encode().unwrap(), &[13, 3, 0, 0, 0])), - Err(WireError::InvalidTlv(13)) - ); -} - -#[test] -fn hash_chain_profile_has_one_canonical_encoding_and_requires_uniform_amounts() { - for value in [vec![], vec![0], vec![2], vec![1, 1]] { - let mut tlv = vec![15, value.len() as u8]; - tlv.extend(value); - assert_eq!( - Message::decode(&append_tlv(init().encode().unwrap(), &tlv)), - Err(WireError::InvalidTlv(15)) - ); - } - let mut message = init(); - let Payload::Init(terms) = &mut message.payload else { panic!() }; - terms.hash_chain = true; - assert_eq!(Message::decode(&message.encode().unwrap()).unwrap(), message); - let Payload::Init(terms) = &mut message.payload else { panic!() }; - terms.amounts_msat.push(terms.amounts_msat[0] + 1); - terms.budget_msat = terms.amounts_msat.iter().sum(); - assert_eq!(message.encode(), Err(WireError::InvalidTlv(15))); -} - -#[test] -fn accept_requires_each_known_tlv_and_consistent_slot_identity() { - let fixture = &fixtures()[0]; - let original = Message::decode(&hex(&fixture.accept_wire)).unwrap(); - for mutate in [0, 1, 2] { - let mut message = original.clone(); - let Payload::Accept(accept) = &mut message.payload else { panic!() }; - match mutate { - 0 => accept.amounts_msat.clear(), - 1 => accept.amounts_msat[0] = 0, - _ => { - accept.payment_hashes.push([2; 32]); - accept.amounts_msat.push(1); - accept.s_htlc_id_base = u64::MAX; - }, - } - assert!(message.encode().is_err()); - } - for (start, len, kind) in [(74, 34, 1), (108, 10, 7), (118, 10, 9), (128, 34, 11)] { - let mut bytes = original.encode().unwrap(); - bytes.drain(start..start + len); - assert_eq!(Message::decode(&bytes), Err(WireError::MissingTlv(kind))); - } -} - -#[test] -fn accept_binds_header_exact_amounts_and_complete_signed_init_bytes() { - let mut initial = init(); - let accept = Message::decode(&hex(&fixtures()[0].accept_wire)).unwrap(); - accept.validate_accept(&initial).unwrap(); - initial.extensions.push(Tlv { kind: 17, value: vec![1] }); - assert_eq!(accept.validate_accept(&initial), Err(WireError::SetupMismatch)); - let mut other = accept.clone(); - other.header.epoch_id[0] ^= 1; - assert_eq!(other.validate_accept(&init()), Err(WireError::SetupMismatch)); - let Payload::Accept(terms) = &mut other.payload else { panic!() }; - terms.amounts_msat[0] += 1; - other.header = init().header; - assert_eq!(other.validate_accept(&init()), Err(WireError::SetupMismatch)); - assert_eq!(init().validate_accept(&accept), Err(WireError::SetupMismatch)); -} - -#[test] -fn lifecycle_structure_round_trips_but_changed_messages_do_not_authenticate() { - for payload in [ - Payload::Abort(Abort { transcript_hash: [2; 32], reason: 7, data: vec![0, 255, 1] }), - Payload::Close([3; 32]), - close_ack().payload, - ] { - let mut message = ack(); - message.payload = payload; - let bytes = message.encode().unwrap(); - assert_eq!(Message::decode(&bytes).unwrap(), message); - assert_eq!(message.verify_signature(&settlement()), Err(WireError::InvalidSignature)); - } -} - -#[test] -fn close_ack_requires_preimages_matching_every_set_bit_in_order() { - for mutate in [0, 1, 2, 3, 4, 5] { - let mut message = close_ack(); - let Payload::CloseAck(close) = &mut message.payload else { panic!() }; - match mutate { - 0 => close.preimages.clear(), - 1 => close.preimages.swap(0, 1), - 2 => close.preimages[1].slot = 1, - 3 => close.settled[1] = 0x81, - 4 => close.num_slots = 0, - _ => close.settled.push(0), - } - assert!(message.encode().is_err()); - } - let mut message = close_ack(); - let Payload::CloseAck(close) = &mut message.payload else { panic!() }; - close.settled = vec![0, 0]; - close.preimages.clear(); - let mut bytes = message.encode().unwrap(); - assert_eq!(Message::decode(&bytes).unwrap(), message); - let end = bytes.len() - 64; - bytes.drain(end - 2..end); - let absent = Message::decode(&bytes).unwrap(); - let Payload::CloseAck(close) = &absent.payload else { panic!() }; - assert!(!close.preimages_tlv_present); - assert_eq!(absent.encode().unwrap(), bytes); -} - -#[test] -fn encoder_rejects_oversized_data_and_noncanonical_extensions() { - let mut message = ack(); - message.payload = Payload::Abort(Abort { transcript_hash: [2; 32], reason: 8, data: vec![] }); - assert_eq!(message.encode(), Err(WireError::InvalidField)); - let Payload::Abort(abort) = &mut message.payload else { panic!() }; - abort.reason = 0; - abort.data = vec![0; MAX_MESSAGE_LEN]; - assert_eq!(message.encode(), Err(WireError::SizeLimit)); - for extensions in [ - vec![Tlv { kind: 17, value: vec![0; MAX_MESSAGE_LEN] }], - vec![Tlv { kind: 13, value: vec![] }], - vec![Tlv { kind: 15, value: vec![1] }], - vec![Tlv { kind: 17, value: vec![] }, Tlv { kind: 17, value: vec![] }], - vec![Tlv { kind: 19, value: vec![] }, Tlv { kind: 17, value: vec![] }], - vec![Tlv { kind: 18, value: vec![] }], - ] { - let mut message = init(); - message.extensions = extensions; - assert!(message.encode().is_err()); - } -} - -#[test] -fn zero_out_of_range_and_high_s_signatures_are_rejected() { - for bytes in [[0; 64], [255; 64]] { - let mut message = ack(); - message.signature = bytes; - assert_eq!(message.encode(), Err(WireError::InvalidSignature)); - } - let mut message = ack(); - let order = hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); - let mut borrow = 0i16; - for i in (0..32).rev() { - let value = i16::from(order[i]) - i16::from(message.signature[i + 32]) - borrow; - message.signature[i + 32] = value.rem_euclid(256) as u8; - borrow = i16::from(value < 0); - } - assert_eq!(message.encode(), Err(WireError::InvalidSignature)); -} - -#[test] -fn unsigned_digest_can_be_requested_without_manufacturing_a_signature() { - let mut message = init(); - let digest = message.signature_digest().unwrap(); - message.signature = [0; 64]; - assert_eq!(message.signature_digest().unwrap(), digest); - assert_eq!(message.encode(), Err(WireError::InvalidSignature)); - message.extensions.push(Tlv { kind: 17, value: vec![1] }); - assert_ne!(message.signature_digest().unwrap(), digest); -} - -#[test] -fn encoder_reserves_signature_space_and_bounds_collections_before_processing() { - let mut message = ack(); - message.payload = Payload::Abort(Abort { - transcript_hash: [2; 32], - reason: 0, - data: vec![7; MAX_MESSAGE_LEN - 166], - }); - let bytes = message.encode().unwrap(); - assert_eq!(bytes.len(), MAX_MESSAGE_LEN); - assert_eq!(Message::decode(&bytes).unwrap(), message); - let Payload::Abort(abort) = &mut message.payload else { panic!() }; - abort.data.push(1); - assert_eq!(message.signature_digest(), Err(WireError::SizeLimit)); - let mut oversized_extensions = init(); - oversized_extensions.extensions = vec![Tlv { kind: 17, value: vec![] }; MAX_TLV_COUNT + 1]; - assert_eq!(oversized_extensions.encode(), Err(WireError::SizeLimit)); - let mut oversized_points = init(); - let Payload::Init(terms) = &mut oversized_points.payload else { panic!() }; - terms.witness_peers = Some(vec![receiver(); MAX_MESSAGE_LEN / 33 + 1]); - assert_eq!(oversized_points.encode(), Err(WireError::SizeLimit)); - assert_eq!( - Message::decode(&append_tlv(init().encode().unwrap(), &[13, 2, 255, 255])), - Err(WireError::SizeLimit) - ); -} - -#[test] -fn malformed_known_tlv_value_lengths_are_rejected_on_the_wire() { - let mut amounts = init().encode().unwrap(); - amounts[112] = 7; - amounts.remove(120); - assert_eq!(Message::decode(&amounts), Err(WireError::InvalidTlv(9))); - let mut hashes = Message::decode(&hex(&fixtures()[0].accept_wire)).unwrap().encode().unwrap(); - hashes[75] = 31; - hashes.remove(107); - assert_eq!(Message::decode(&hashes), Err(WireError::InvalidTlv(1))); - let mut preimages = close_ack().encode().unwrap(); - preimages[103] = 67; - preimages.remove(171); - assert_eq!(Message::decode(&preimages), Err(WireError::InvalidTlv(1))); - let mut one_slot = close_ack(); - let Payload::CloseAck(close) = &mut one_slot.payload else { panic!() }; - close.num_slots = 1; - close.settled = vec![1]; - close.preimages.truncate(1); - let mut too_many_preimages = one_slot.encode().unwrap(); - too_many_preimages[102] = 68; - too_many_preimages.splice(137..137, [0; 34]); - assert_eq!(Message::decode(&too_many_preimages), Err(WireError::InvalidTlv(1))); - let mut absent_preimages = one_slot.encode().unwrap(); - absent_preimages.drain(101..137); - assert!(Message::decode(&absent_preimages).is_err()); -} - -#[test] -fn invalid_wire_abort_reason_and_oversized_constructed_abort_are_rejected() { - let mut message = ack(); - message.payload = Payload::Abort(Abort { transcript_hash: [2; 32], reason: 0, data: vec![] }); - let mut wire = message.encode().unwrap(); - wire[99] = 8; - assert_eq!(Message::decode(&wire), Err(WireError::InvalidField)); - let Payload::Abort(abort) = &mut message.payload else { panic!() }; - abort.data = vec![0; MAX_MESSAGE_LEN + 1]; - assert_eq!(message.encode(), Err(WireError::SizeLimit)); -} - -#[test] -fn known_extension_collisions_and_zero_s_are_rejected() { - let mut accept = Message::decode(&hex(&fixtures()[0].accept_wire)).unwrap(); - accept.extensions.push(Tlv { kind: 1, value: vec![1] }); - assert_eq!(accept.encode(), Err(WireError::InvalidTlv(1))); - let mut close = close_ack(); - close.extensions.push(Tlv { kind: 1, value: vec![] }); - assert_eq!(close.encode(), Err(WireError::InvalidTlv(1))); - let mut message = ack(); - message.signature[32..].fill(0); - assert_eq!(message.encode(), Err(WireError::InvalidSignature)); - assert!(WireError::InvalidSignature.to_string().contains("InvalidSignature")); -} - -proptest! { - #[test] - fn arbitrary_bounded_input_never_panics_and_accepted_bytes_are_canonical( - bytes in prop::collection::vec(any::(), 0..=MAX_MESSAGE_LEN + 1) - ) { - if let Ok(message) = Message::decode(&bytes) { - prop_assert_eq!(message.encode().unwrap(), bytes); - } - } - - #[test] - fn unknown_odd_tlv_values_round_trip_without_normalization(value in prop::collection::vec(any::(), 0..2048)) { - let mut message = init(); - message.extensions = vec![Tlv { kind: 65_537, value }]; - let bytes = message.encode().unwrap(); - prop_assert_eq!(Message::decode(&bytes).unwrap(), message); - } - - #[test] - fn close_bitmaps_round_trip_with_exactly_the_corresponding_preimages(bits in prop::collection::vec(any::(), 1..=483)) { - let mut close = CloseAck { activation_hash: [3; 32], num_slots: bits.len() as u16, - settled: vec![0; bits.len().div_ceil(8)], preimages: Vec::new(), preimages_tlv_present: true }; - for (index, bit) in bits.into_iter().enumerate() { - if bit { - close.settled[index / 8] |= 1 << (index % 8); - close.preimages.push(Preimage { slot: index as u16 + 1, value: [index as u8; 32] }); - } - } - let mut message = ack(); - message.payload = Payload::CloseAck(close); - prop_assert_eq!(Message::decode(&message.encode().unwrap()).unwrap(), message); - } -} From f0ceabef7451d246fbebc4728e4b680f1ed0fb3e Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Sun, 20 Sep 2026 20:56:09 -0400 Subject: [PATCH 06/23] feat(ffor): connect native setup support and node signing --- Cargo.lock | 59 ++++++++++++++++++++++++++--------------------- Cargo.toml | 27 +++++++++++----------- FFOR.md | 8 ++++++- src/wallet/mod.rs | 54 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 107 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4946f23d57..a2e69fc6cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1988,24 +1988,25 @@ dependencies = [ "libm", "lightning-invoice 0.33.3", "lightning-types 0.2.12", - "possiblyrandom", + "possiblyrandom 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] name = "lightning" version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab16d2a714c0b26d7230bd388ac383a30fce231c8927c62752afc0471a36dc6" +source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" dependencies = [ "bech32", "bitcoin", "dnssec-prover", "hashbrown 0.13.2", "libm", + "lightning-ffor", "lightning-invoice 0.34.1", "lightning-macros 0.2.1", "lightning-types 0.3.5", - "possiblyrandom", + "musig2", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77)", "regex", ] @@ -2025,8 +2026,7 @@ dependencies = [ [[package]] name = "lightning-background-processor" version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "994fb200e17bbe125491cbe3f35602180a360484b9763837b9ea2130b7293b5f" +source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" dependencies = [ "bitcoin", "bitcoin-io", @@ -2034,7 +2034,7 @@ dependencies = [ "lightning 0.2.5", "lightning-liquidity 0.2.3", "lightning-rapid-gossip-sync 0.2.3", - "possiblyrandom", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77)", ] [[package]] @@ -2053,8 +2053,7 @@ dependencies = [ [[package]] name = "lightning-block-sync" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee5069846b07a62aaecdaf25233e067bc69f245b7c8fd00cc9c217053221f875" +source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" dependencies = [ "bitcoin", "chunked_transfer", @@ -2066,7 +2065,7 @@ dependencies = [ [[package]] name = "lightning-ffor" version = "0.1.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=364d8f905743a2bd7a91c1757ef85454c1d258a6#364d8f905743a2bd7a91c1757ef85454c1d258a6" +source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" dependencies = [ "bitcoin", ] @@ -2086,8 +2085,7 @@ dependencies = [ [[package]] name = "lightning-invoice" version = "0.34.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d83bd798e04ab9eecc8bbef1fa17d3808859bcdc0406bd16c55d51c8834444" +source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" dependencies = [ "bech32", "bitcoin", @@ -2113,8 +2111,7 @@ dependencies = [ [[package]] name = "lightning-liquidity" version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42278510c9f71f32c6e292085747d1f9649f8a88a8d9dc32c0440da3c1356345" +source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" dependencies = [ "bitcoin", "chrono", @@ -2140,8 +2137,7 @@ dependencies = [ [[package]] name = "lightning-macros" version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c717494cdc2c8bb85bee7113031248f5f6c64f8802b33c1c9e2d98e594aa71" +source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" dependencies = [ "proc-macro2", "quote", @@ -2162,8 +2158,7 @@ dependencies = [ [[package]] name = "lightning-net-tokio" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8055737e3d2d06240a3fdf10e26b2716110fcea90011a0839e8e82fc6e58ff5e" +source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2184,8 +2179,7 @@ dependencies = [ [[package]] name = "lightning-persister" version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c374a770c3654b00fe3b57e30e79416fb30d773b4fefeea231171d3a82dfa1b" +source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2208,8 +2202,7 @@ dependencies = [ [[package]] name = "lightning-rapid-gossip-sync" version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38ac84d6862b3eaf5129c68a5df0fb8f1760adfccbaafadc18da140325f1168" +source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" dependencies = [ "bitcoin", "bitcoin-io", @@ -2234,8 +2227,7 @@ dependencies = [ [[package]] name = "lightning-transaction-sync" version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7260095a79a44b09ebd97e77855ec8b7996ab939223369f66244974a374bd2c" +source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" dependencies = [ "bitcoin", "electrum-client 0.24.1", @@ -2257,8 +2249,7 @@ dependencies = [ [[package]] name = "lightning-types" version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c77c676d4a34cceb2ae3756916e446b4d17f9430a24107e099981f0f9aec77e6" +source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" dependencies = [ "bitcoin", ] @@ -2449,6 +2440,14 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "musig2" +version = "0.1.0" +source = "git+https://github.com/arik-so/rust-musig2?rev=6f95a05718cbb44d8fe3fa6021aea8117aa38d50#6f95a05718cbb44d8fe3fa6021aea8117aa38d50" +dependencies = [ + "bitcoin", +] + [[package]] name = "nix" version = "0.25.1" @@ -2674,6 +2673,14 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "possiblyrandom" +version = "0.2.1" +source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "potential_utf" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 249504a164..e5b9d5a32a 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,18 +33,19 @@ panic = 'abort' # Abort on panic default = [] [dependencies] -lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "364d8f905743a2bd7a91c1757ef85454c1d258a6" } -lightning = { version = "0.2.0", features = ["std"] } -lightning-types = { version = "0.3.0" } -lightning-invoice = { version = "0.34.0", features = ["std"] } -lightning-net-tokio = { version = "0.2.0" } -lightning-persister = { version = "0.2.0", features = ["tokio"] } -lightning-background-processor = { version = "0.2.0" } -lightning-rapid-gossip-sync = { version = "0.2.0" } -lightning-block-sync = { version = "0.2.0", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { version = "0.2.0", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { version = "0.2.0", features = ["std"] } -lightning-macros = { version = "0.2.0" } +# Pin runtime dependencies directly so downstream consumers use the same native engine. +lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77" } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77", features = ["std"] } +lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77" } +lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77" } +lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77" } +lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77" } +lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77", features = ["std"] } +lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77" } bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} @@ -80,7 +81,7 @@ prost = { version = "0.11.6", default-features = false} winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { version = "0.2.0", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77", features = ["std", "_test_utils"] } proptest = "1.0.0" regex = "1.5.6" criterion = { version = "0.7.0", features = ["async_tokio"] } diff --git a/FFOR.md b/FFOR.md index b3a0024a3a..e7b68aaa5e 100644 --- a/FFOR.md +++ b/FFOR.md @@ -6,11 +6,17 @@ Blocktank's LND v0.21.3-beta baseline. Work is tracked in The shared protocol implementation lives in `lightning-ffor` in the [Rust channel-engine draft](https://github.com/synonymdev/rust-lightning/pull/4). -Node pins an exact commit in Cargo.toml and Cargo.lock. Wire parsers, authenticated +Node pins the channel engine, companion crates and shared protocol to one exact +commit through direct dependencies in Cargo.toml and Cargo.lock. These pins also +apply when another Rust project consumes Node. Wire parsers, authenticated setup derivation, transcript hashes, reference fixtures and fuzz tests belong to that crate. Keeping one implementation lets Node and the channel engine use the same validation rules. +The wallet signer forwards FFOR signing requests to its existing node identity. +The shared protocol validates the resulting single-SHA256 signature domain. No +private keys are exported, and a valid signature alone grants no channel authority. + This dependency does not enable offline receiving. The current native bindings and ordinary invoice behavior remain unchanged. The mobile draft providers expose no production capability until authenticated activation, durable channel ownership diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 98a6f00d55..13a044c86f 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2331,6 +2331,12 @@ impl NodeSigner for WalletKeysManager { self.inner.sign_gossip_message(msg) } + fn sign_ffor_message( + &self, request: &lightning::sign::ffor::FFORSigningRequest<'_>, + ) -> Result { + self.inner.sign_ffor_message(request) + } + fn sign_bolt12_invoice( &self, invoice: &lightning::offers::invoice::UnsignedBolt12Invoice, ) -> Result { @@ -2425,18 +2431,64 @@ impl ChangeDestinationSource for WalletKeysManager { #[cfg(test)] mod tests { use std::str::FromStr; + use std::sync::Arc; use bdk_wallet_aggregate::UtxoPsbtInfo; use bitcoin::{psbt, OutPoint, TxIn, Weight}; + use lightning::sign::ffor::FFORSigningRequest; + use lightning::sign::NodeSigner; + use lightning_ffor::wire::{Header, Init, Message, Payload}; use super::{ additional_input_weight, checked_payment_target, checked_sum, map_wallet_account_error, validate_derivation_index, validate_derivation_range, BIP32_MAX_NORMAL_INDEX, MAX_ADDRESS_INFO_BATCH_COUNT, }; - use crate::config::{AddressType, OnchainWalletAccount}; + use crate::builder::NodeBuilder; + use crate::config::{AddressType, Config, OnchainWalletAccount}; + use crate::io::test_utils::InMemoryStore; use crate::Error; + #[test] + fn ffor_signing_uses_the_built_nodes_actual_identity_and_protocol_domain() { + let config = Config { + network: bitcoin::Network::Regtest, + storage_dir_path: std::env::temp_dir() + .join(format!("ffor-wallet-signer-{}", std::process::id())) + .to_string_lossy() + .into_owned(), + ..Config::default() + }; + let mut builder = NodeBuilder::from_config(config); + builder.set_entropy_seed_bytes([42; 64]); + builder.set_log_facade_logger(); + let node = builder.build_with_store(Arc::new(InMemoryStore::new())).unwrap(); + let mut message = Message { + header: Header { channel_id: [3; 32], epoch_id: [4; 32] }, + payload: Payload::Init(Init { + budget_msat: 2_000_000, + min_payment_msat: 2_000_000, + settlement_deadline: 300, + voucher_expiry: 350, + fee_base_msat: 0, + fee_proportional_millionths: 0, + amounts_msat: vec![2_000_000], + witness_peers: None, + hash_chain: false, + }), + extensions: Vec::new(), + signature: [0; 64], + }; + let unsigned = message.unsigned_wire().unwrap(); + let request = FFORSigningRequest::new(&unsigned).unwrap(); + message.signature = + node.keys_manager.sign_ffor_message(&request).unwrap().serialize_compact(); + let mut decoded = Message::decode(&message.encode().unwrap()).unwrap(); + decoded.verify_signature(&node.node_id()).unwrap(); + decoded.header.epoch_id[0] ^= 1; + assert!(decoded.verify_signature(&node.node_id()).is_err()); + } + #[test] fn derivation_index_validation_rejects_hardened_range() { assert_eq!(validate_derivation_index(BIP32_MAX_NORMAL_INDEX), Ok(())); From 9869bacedb833f9638ea08c5800ede8c323811f7 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Sun, 20 Sep 2026 21:42:55 -0400 Subject: [PATCH 07/23] feat(ffor): compose bounded receive transport with LSPS --- Cargo.lock | 30 ++-- Cargo.toml | 26 +-- FFOR.md | 35 ++++ src/message_handler.rs | 187 ++++++++++++++-------- src/message_handler/ffor.rs | 220 +++++++++++++++++++++++++ src/message_handler/ffor/tests.rs | 169 ++++++++++++++++++++ src/message_handler/test_data.json | 53 +++++++ src/message_handler/tests.rs | 247 +++++++++++++++++++++++++++++ src/types.rs | 2 +- 9 files changed, 878 insertions(+), 91 deletions(-) create mode 100644 src/message_handler/ffor.rs create mode 100644 src/message_handler/ffor/tests.rs create mode 100644 src/message_handler/test_data.json create mode 100644 src/message_handler/tests.rs diff --git a/Cargo.lock b/Cargo.lock index a2e69fc6cf..3bc33982e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1994,7 +1994,7 @@ dependencies = [ [[package]] name = "lightning" version = "0.2.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" +source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" dependencies = [ "bech32", "bitcoin", @@ -2006,7 +2006,7 @@ dependencies = [ "lightning-macros 0.2.1", "lightning-types 0.3.5", "musig2", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c)", "regex", ] @@ -2026,7 +2026,7 @@ dependencies = [ [[package]] name = "lightning-background-processor" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" +source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" dependencies = [ "bitcoin", "bitcoin-io", @@ -2034,7 +2034,7 @@ dependencies = [ "lightning 0.2.5", "lightning-liquidity 0.2.3", "lightning-rapid-gossip-sync 0.2.3", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c)", ] [[package]] @@ -2053,7 +2053,7 @@ dependencies = [ [[package]] name = "lightning-block-sync" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" +source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" dependencies = [ "bitcoin", "chunked_transfer", @@ -2065,7 +2065,7 @@ dependencies = [ [[package]] name = "lightning-ffor" version = "0.1.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" +source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" dependencies = [ "bitcoin", ] @@ -2085,7 +2085,7 @@ dependencies = [ [[package]] name = "lightning-invoice" version = "0.34.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" +source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" dependencies = [ "bech32", "bitcoin", @@ -2111,7 +2111,7 @@ dependencies = [ [[package]] name = "lightning-liquidity" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" +source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" dependencies = [ "bitcoin", "chrono", @@ -2137,7 +2137,7 @@ dependencies = [ [[package]] name = "lightning-macros" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" +source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" dependencies = [ "proc-macro2", "quote", @@ -2158,7 +2158,7 @@ dependencies = [ [[package]] name = "lightning-net-tokio" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" +source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2179,7 +2179,7 @@ dependencies = [ [[package]] name = "lightning-persister" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" +source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2202,7 +2202,7 @@ dependencies = [ [[package]] name = "lightning-rapid-gossip-sync" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" +source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" dependencies = [ "bitcoin", "bitcoin-io", @@ -2227,7 +2227,7 @@ dependencies = [ [[package]] name = "lightning-transaction-sync" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" +source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" dependencies = [ "bitcoin", "electrum-client 0.24.1", @@ -2249,7 +2249,7 @@ dependencies = [ [[package]] name = "lightning-types" version = "0.3.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" +source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" dependencies = [ "bitcoin", ] @@ -2676,7 +2676,7 @@ dependencies = [ [[package]] name = "possiblyrandom" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=9ce29d19de27cbea70698e2ba5531b72d2351c77#9ce29d19de27cbea70698e2ba5531b72d2351c77" +source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" dependencies = [ "getrandom 0.2.17", ] diff --git a/Cargo.toml b/Cargo.toml index e5b9d5a32a..77b85b38fd 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,18 +34,18 @@ default = [] [dependencies] # Pin runtime dependencies directly so downstream consumers use the same native engine. -lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77" } -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77", features = ["std"] } -lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77" } -lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77" } -lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77" } -lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77" } -lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77", features = ["std"] } -lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77" } +lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c" } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c", features = ["std"] } +lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c" } +lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c" } +lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c" } +lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c" } +lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c", features = ["std"] } +lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c" } bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} @@ -81,7 +81,7 @@ prost = { version = "0.11.6", default-features = false} winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "9ce29d19de27cbea70698e2ba5531b72d2351c77", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c", features = ["std", "_test_utils"] } proptest = "1.0.0" regex = "1.5.6" criterion = { version = "0.7.0", features = ["async_tokio"] } diff --git a/FFOR.md b/FFOR.md index e7b68aaa5e..f6b9272e77 100644 --- a/FFOR.md +++ b/FFOR.md @@ -22,6 +22,41 @@ and ordinary invoice behavior remain unchanged. The mobile draft providers expos no production capability until authenticated activation, durable channel ownership and recovery are implemented together. +## Private receive transport + +Node's custom-message composition retains the existing LSPS reader, outbox, features +and peer callbacks. An optional private FFOR receiver can parse the seven supported +signed lifecycle types and witness acknowledgement type 55057 through the shared +canonical codecs. The production builder leaves this receiver disabled. This slice +has no FFOR sender, feature advertisement, protocol transition or public setting. + +The transport accepts peer identity only from PeerManager's authenticated callback. +Each successful connection gets a distinct opaque token. Disconnect or replacement +clears its queued work, and a failed LSPS connection callback cannot establish a new +FFOR connection. Popped inputs retain the original token and exact wire bytes. A +consumer must recheck connection ownership under the actual native channel authority +before applying a transition; a successful parse or point-in-time token check grants +no such authority. Claimed wire identities cannot replace the authenticated peer. + +Frames are limited to 65,535 bytes including the message type. The receive queue +tracks at most 64 peers, eight frames and 256 KiB per peer, and 128 frames and 1 MiB +globally. Those byte limits cover retained wire payloads; bounded peer and frame +metadata adds fixed overhead. Queue refusals preserve existing work and do not +disconnect ordinary peers. Consumers take one frame at a time and remain responsible +for bounding any work they retain after removal from the queue. Debug formatting +exposes only the FFOR type and length, so PeerManager trace logs cannot print +lifecycle preimages or other wire payloads. Malformed recognized frames fail the +custom reader; unknown types keep the +existing ignore behavior. Witness service requests and receipt retrieval are outside +this receiver slice. + +Run `cargo test --lib message_handler` for exact public fixture routing, LSPS +coexistence, disabled behavior, malformed frame bounds, connection replacement, +concurrent disconnect and property-based queue accounting checks. Fixture provenance +is recorded in `src/message_handler/test_data.json`. Parser fuzzing remains in the +shared `lightning-ffor` wire and witness targets; Node also runs arbitrary-byte +property checks at the length-limited reader boundary. + ## Required runtime integration - Bind signed setup to the actual local identity, peer, chain and channel limits. diff --git a/src/message_handler.rs b/src/message_handler.rs index fc206ec4da..db039ee894 100644 --- a/src/message_handler.rs +++ b/src/message_handler.rs @@ -5,117 +5,180 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. +//! Compose LSPS with an optional receive-only FFOR transport. The builder leaves FFOR disabled. + +pub(crate) mod ffor; +#[cfg(test)] +mod tests; + use std::ops::Deref; use std::sync::Arc; use bitcoin::secp256k1::PublicKey; +use lightning::io; +use lightning::ln::msgs::{DecodeError, Init, LightningError}; use lightning::ln::peer_handler::CustomMessageHandler; -use lightning::ln::wire::CustomMessageReader; +use lightning::ln::wire::{CustomMessageReader, Type}; use lightning::util::logger::Logger; -use lightning::util::ser::LengthLimitedRead; +use lightning::util::ser::{LengthLimitedRead, Writeable, Writer}; use lightning_liquidity::lsps0::ser::RawLSPSMessage; use lightning_types::features::{InitFeatures, NodeFeatures}; use crate::liquidity::LiquiditySource; +use crate::types::LiquidityManager; +use ffor::{FforFrame, FforReceiverTransport}; + +/// Only the LSPS variant can enter this handler's outbound queue. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum NodeCustomMessage { + Liquidity(RawLSPSMessage), + Ffor(FforFrame), +} + +impl Type for NodeCustomMessage { + fn type_id(&self) -> u16 { + match self { + Self::Liquidity(message) => message.type_id(), + Self::Ffor(message) => message.type_id(), + } + } +} + +impl Writeable for NodeCustomMessage { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + match self { + Self::Liquidity(message) => message.write(writer), + Self::Ffor(message) => message.write(writer), + } + } +} -pub(crate) enum NodeCustomMessageHandler +pub(crate) struct NodeCustomMessageHandler> where - L::Target: Logger, + H: Deref, + H::Target: CustomMessageHandler, { - Ignoring, - Liquidity { liquidity_source: Arc> }, + liquidity: Option, + ffor: Option>, +} + +impl NodeCustomMessageHandler { + pub(crate) fn new_liquidity(liquidity_source: Arc>) -> Self + where + L::Target: Logger, + { + Self::new_liquidity_handler(liquidity_source.liquidity_manager()) + } } -impl NodeCustomMessageHandler +impl NodeCustomMessageHandler where - L::Target: Logger, + H: Deref, + H::Target: CustomMessageHandler, { - pub(crate) fn new_liquidity(liquidity_source: Arc>) -> Self { - Self::Liquidity { liquidity_source } + fn new_liquidity_handler(liquidity: H) -> Self { + Self { liquidity: Some(liquidity), ffor: None } } pub(crate) fn new_ignoring() -> Self { - Self::Ignoring + Self { liquidity: None, ffor: None } + } + + // No production caller enables this seam until a native receiver owns admission and recovery. + #[allow(dead_code)] + pub(crate) fn with_ffor_receiver(mut self, receiver: Arc) -> Self { + self.ffor = Some(receiver); + self } } -impl CustomMessageReader for NodeCustomMessageHandler +impl CustomMessageReader for NodeCustomMessageHandler where - L::Target: Logger, + H: Deref, + H::Target: CustomMessageHandler, { - type CustomMessage = RawLSPSMessage; + type CustomMessage = NodeCustomMessage; fn read( &self, message_type: u16, buffer: &mut RD, - ) -> Result, lightning::ln::msgs::DecodeError> { - match self { - Self::Ignoring => Ok(None), - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().read(message_type, buffer) - }, + ) -> Result, DecodeError> { + if self.ffor.is_some() && FforFrame::handles_type(message_type) { + return FforFrame::read(message_type, buffer) + .map(|message| Some(NodeCustomMessage::Ffor(message))); + } + match self.liquidity.as_ref() { + Some(liquidity) => liquidity + .read(message_type, buffer) + .map(|message| message.map(NodeCustomMessage::Liquidity)), + None => Ok(None), } } } -impl CustomMessageHandler for NodeCustomMessageHandler +impl CustomMessageHandler for NodeCustomMessageHandler where - L::Target: Logger, + H: Deref, + H::Target: CustomMessageHandler, { fn handle_custom_message( - &self, msg: Self::CustomMessage, sender_node_id: PublicKey, - ) -> Result<(), lightning::ln::msgs::LightningError> { - match self { - Self::Ignoring => Ok(()), // Should be unreachable!() as the reader will return `None` - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().handle_custom_message(msg, sender_node_id) + &self, message: NodeCustomMessage, sender: PublicKey, + ) -> Result<(), LightningError> { + match message { + NodeCustomMessage::Liquidity(message) => match self.liquidity.as_ref() { + Some(liquidity) => liquidity.handle_custom_message(message, sender), + None => Ok(()), + }, + NodeCustomMessage::Ffor(message) => match self.ffor.as_ref() { + Some(ffor) => ffor.receive(sender, message), + None => Ok(()), }, } } - fn get_and_clear_pending_msg(&self) -> Vec<(PublicKey, Self::CustomMessage)> { - match self { - Self::Ignoring => Vec::new(), - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().get_and_clear_pending_msg() - }, - } + fn get_and_clear_pending_msg(&self) -> Vec<(PublicKey, NodeCustomMessage)> { + // FFOR has no outbound API. Native persistence must gate any future sender. + self.liquidity.as_ref().map_or_else(Vec::new, |liquidity| { + liquidity + .get_and_clear_pending_msg() + .into_iter() + .map(|(peer, message)| (peer, NodeCustomMessage::Liquidity(message))) + .collect() + }) } fn provided_node_features(&self) -> NodeFeatures { - match self { - Self::Ignoring => NodeFeatures::empty(), - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().provided_node_features() - }, - } + self.liquidity + .as_ref() + .map_or_else(NodeFeatures::empty, |liquidity| liquidity.provided_node_features()) } - fn provided_init_features(&self, their_node_id: PublicKey) -> InitFeatures { - match self { - Self::Ignoring => InitFeatures::empty(), - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().provided_init_features(their_node_id) - }, - } + fn provided_init_features(&self, peer: PublicKey) -> InitFeatures { + self.liquidity + .as_ref() + .map_or_else(InitFeatures::empty, |liquidity| liquidity.provided_init_features(peer)) } - fn peer_connected( - &self, their_node_id: PublicKey, msg: &lightning::ln::msgs::Init, inbound: bool, - ) -> Result<(), ()> { - match self { - Self::Ignoring => Ok(()), - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().peer_connected(their_node_id, msg, inbound) - }, + fn peer_connected(&self, peer: PublicKey, init: &Init, inbound: bool) -> Result<(), ()> { + if let Some(ffor) = self.ffor.as_ref() { + // A failed replacement callback must not leave the previous connection usable. + ffor.peer_disconnected(peer); } + if let Some(liquidity) = self.liquidity.as_ref() { + liquidity.peer_connected(peer, init, inbound)?; + } + if let Some(ffor) = self.ffor.as_ref() { + // Exhausting optional FFOR capacity must not disconnect an ordinary peer. + ffor.peer_connected(peer); + } + Ok(()) } - fn peer_disconnected(&self, their_node_id: PublicKey) { - match self { - Self::Ignoring => {}, - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().peer_disconnected(their_node_id) - }, + fn peer_disconnected(&self, peer: PublicKey) { + if let Some(ffor) = self.ffor.as_ref() { + ffor.peer_disconnected(peer); + } + if let Some(liquidity) = self.liquidity.as_ref() { + liquidity.peer_disconnected(peer); } } } diff --git a/src/message_handler/ffor.rs b/src/message_handler/ffor.rs new file mode 100644 index 0000000000..e89d7f37cf --- /dev/null +++ b/src/message_handler/ffor.rs @@ -0,0 +1,220 @@ +//! Bounded, receive-only transport state. Successful parsing grants no protocol authority. +//! +//! Only PeerManager's authenticated callbacks supply peer identity. A connection token identifies +//! one successful init callback, including reconnects to the same key. Consumers must recheck that +//! token under the eventual native channel authority before mutation; a queue pop is not a lock on +//! the connection. No protocol transition, signature generation or outbound queue exists here. + +use std::collections::{HashMap, VecDeque}; +use std::fmt; +use std::sync::{Arc, Mutex}; + +use bitcoin::secp256k1::PublicKey; +use lightning::io; +use lightning::ln::msgs::{DecodeError, ErrorAction, LightningError}; +use lightning::ln::wire::Type; +use lightning::util::logger::Level; +use lightning::util::ser::{LengthLimitedRead, Writeable, Writer}; +use lightning_ffor::wire::{Message, MAX_MESSAGE_LEN}; +use lightning_ffor::witness::Acknowledgement; + +const MAX_PEERS: usize = 64; +const MAX_PEER_MESSAGES: usize = 8; +const MAX_PEER_BYTES: usize = 256 * 1024; +const MAX_QUEUED_MESSAGES: usize = 128; +const MAX_QUEUED_BYTES: usize = 1024 * 1024; + +/// Exact canonical input, including the message type. Authentication remains the consumer's job. +#[derive(PartialEq, Eq)] +pub(crate) struct FforFrame(Vec); + +// PeerManager traces received custom messages through Debug. Lifecycle bodies may contain +// preimages, so even a transport-only representation must omit the entire wire payload. +impl fmt::Debug for FforFrame { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("FforFrame") + .field("message_type", &self.type_id()) + .field("length", &self.0.len()) + .finish() + } +} + +impl FforFrame { + pub(super) fn handles_type(message_type: u16) -> bool { + matches!(message_type, 55001 | 55003 | 55045 | 55047 | 55049 | 55051 | 55053 | 55057) + } + + pub(super) fn read( + message_type: u16, reader: &mut R, + ) -> Result { + let body_len = reader.remaining_bytes(); + if !Self::handles_type(message_type) || body_len > (MAX_MESSAGE_LEN - 2) as u64 { + return Err(DecodeError::InvalidValue); + } + let mut bytes = vec![0; body_len as usize + 2]; + bytes[..2].copy_from_slice(&message_type.to_be_bytes()); + reader.read_exact(&mut bytes[2..])?; + if message_type == 55057 { + Acknowledgement::decode(&bytes).map_err(|_| DecodeError::InvalidValue)?; + } else { + Message::decode(&bytes).map_err(|_| DecodeError::InvalidValue)?; + } + Ok(Self(bytes)) + } + + // The eventual receiver consumes exact bytes for native authentication and transcript binding. + #[allow(dead_code)] + pub(crate) fn wire(&self) -> &[u8] { + &self.0 + } +} + +impl Type for FforFrame { + fn type_id(&self) -> u16 { + u16::from_be_bytes([self.0[0], self.0[1]]) + } +} + +impl Writeable for FforFrame { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + writer.write_all(&self.0[2..]) + } +} + +/// Unforgeable outside this module and never numerically reused or wrapped. +/// +/// Pointer equality is intentional. Retaining an old token also keeps its allocation alive, so a +/// reconnect cannot acquire the same identity even if both connections use the same node key. +#[derive(Clone, Debug)] +pub(crate) struct ConnectionToken(Arc<()>); + +impl PartialEq for ConnectionToken { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +impl Eq for ConnectionToken {} + +/// A bounded input carrying only transport-authenticated identity, never claimed wire identity. +#[derive(Debug)] +pub(crate) struct ReceivedFforMessage { + peer: PublicKey, + connection: ConnectionToken, + frame: FforFrame, +} + +// These accessors are the private receiver seam, deliberately unused by production code for now. +#[allow(dead_code)] +impl ReceivedFforMessage { + pub(crate) fn peer(&self) -> PublicKey { + self.peer + } + pub(crate) fn connection(&self) -> &ConnectionToken { + &self.connection + } + pub(crate) fn frame(&self) -> &FforFrame { + &self.frame + } +} + +struct PeerState { + connection: ConnectionToken, + messages: usize, + bytes: usize, +} + +#[derive(Default)] +struct QueueState { + peers: HashMap, + queue: VecDeque, + bytes: usize, +} + +impl QueueState { + // Remove accounting and payloads together while holding the only state lock. + fn disconnect(&mut self, peer: PublicKey) { + if let Some(removed) = self.peers.remove(&peer) { + self.bytes -= removed.bytes; + self.queue.retain(|message| message.peer != peer); + } + } +} + +/// Optional inbound mailbox. It is never constructed by the production builder. +#[derive(Default)] +pub(crate) struct FforReceiverTransport { + state: Mutex, +} + +impl FforReceiverTransport { + pub(super) fn peer_connected(&self, peer: PublicKey) { + let mut state = self.state.lock().unwrap(); + // Defensive replacement also clears old work if callbacks are repeated without disconnect. + state.disconnect(peer); + if state.peers.len() < MAX_PEERS { + state.peers.insert( + peer, + PeerState { connection: ConnectionToken(Arc::new(())), messages: 0, bytes: 0 }, + ); + } + } + + pub(super) fn peer_disconnected(&self, peer: PublicKey) { + self.state.lock().unwrap().disconnect(peer); + } + + pub(super) fn receive(&self, peer: PublicKey, frame: FforFrame) -> Result<(), LightningError> { + let mut state = self.state.lock().unwrap(); + let peer_state = state.peers.get(&peer).ok_or_else(refused)?; + let bytes = frame.0.len(); + if peer_state.messages >= MAX_PEER_MESSAGES + || peer_state.bytes + bytes > MAX_PEER_BYTES + || state.queue.len() >= MAX_QUEUED_MESSAGES + || state.bytes + bytes > MAX_QUEUED_BYTES + { + return Err(refused()); + } + let connection = peer_state.connection.clone(); + let peer_state = state.peers.get_mut(&peer).unwrap(); + peer_state.messages += 1; + peer_state.bytes += bytes; + state.bytes += bytes; + state.queue.push_back(ReceivedFforMessage { peer, connection, frame }); + Ok(()) + } + + // Popping does not authorize any protocol action or retain the connection lock for the caller. + #[allow(dead_code)] + pub(crate) fn pop(&self) -> Option { + let mut state = self.state.lock().unwrap(); + let message = state.queue.pop_front()?; + let bytes = message.frame.0.len(); + let peer = state.peers.get_mut(&message.peer).unwrap(); + peer.messages -= 1; + peer.bytes -= bytes; + state.bytes -= bytes; + Some(message) + } + + #[allow(dead_code)] + pub(crate) fn is_current(&self, message: &ReceivedFforMessage) -> bool { + self.state + .lock() + .unwrap() + .peers + .get(&message.peer) + .is_some_and(|peer| peer.connection == message.connection) + } +} + +fn refused() -> LightningError { + LightningError { + err: "FFOR receive transport unavailable or at capacity".to_owned(), + action: ErrorAction::IgnoreAndLog(Level::Debug), + } +} + +#[cfg(test)] +mod tests; diff --git a/src/message_handler/ffor/tests.rs b/src/message_handler/ffor/tests.rs new file mode 100644 index 0000000000..1ec828fe24 --- /dev/null +++ b/src/message_handler/ffor/tests.rs @@ -0,0 +1,169 @@ +use bitcoin::secp256k1::{Secp256k1, SecretKey}; +use lightning_ffor::witness::AcknowledgementResult; +use proptest::prelude::*; + +use super::*; + +fn key(seed: u8) -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[seed; 32]).unwrap()) +} + +fn frame(bytes: usize) -> FforFrame { + let wire = Acknowledgement::new([1; 16], AcknowledgementResult::Refused(vec![0; bytes - 21])) + .unwrap() + .encode(); + assert_eq!(wire.len(), bytes); + FforFrame::read(55057, &mut &wire[2..]).unwrap() +} + +fn assert_accounting(receiver: &FforReceiverTransport) { + let state = receiver.state.lock().unwrap(); + assert!(state.peers.len() <= MAX_PEERS); + assert!(state.queue.len() <= MAX_QUEUED_MESSAGES); + assert!(state.bytes <= MAX_QUEUED_BYTES); + assert_eq!(state.bytes, state.queue.iter().map(|message| message.frame.0.len()).sum::()); + for (key, peer) in state.peers.iter() { + let queued = state.queue.iter().filter(|message| message.peer == *key).collect::>(); + assert_eq!(peer.messages, queued.len()); + assert_eq!(peer.bytes, queued.iter().map(|message| message.frame.0.len()).sum::()); + assert!(peer.messages <= MAX_PEER_MESSAGES); + assert!(peer.bytes <= MAX_PEER_BYTES); + assert!(queued.iter().all(|message| message.connection == peer.connection)); + } +} + +#[test] +fn ffor_disconnect_and_rebind_invalidate_popped_and_pending_work() { + let receiver = FforReceiverTransport::default(); + let peer = key(1); + receiver.peer_connected(peer); + receiver.receive(peer, frame(21)).unwrap(); + let old = receiver.pop().unwrap(); + assert!(receiver.is_current(&old)); + receiver.receive(peer, frame(100)).unwrap(); + receiver.peer_disconnected(peer); + assert!(!receiver.is_current(&old)); + assert!(receiver.pop().is_none()); + assert!(receiver.receive(peer, frame(21)).is_err()); + receiver.peer_connected(peer); + receiver.receive(peer, frame(21)).unwrap(); + let new = receiver.pop().unwrap(); + assert_ne!(old.connection(), new.connection()); + assert!(!receiver.is_current(&old)); + assert!(receiver.is_current(&new)); + receiver.receive(peer, frame(21)).unwrap(); + receiver.peer_connected(peer); // A repeated callback also clears stale ownership first. + assert!(!receiver.is_current(&new)); + assert!(receiver.pop().is_none()); + assert_accounting(&receiver); + + let another = FforReceiverTransport::default(); + another.peer_connected(peer); + assert!(!another.is_current(&old)); // Tokens cannot collide across handler instances. +} + +#[test] +fn ffor_peer_count_refusal_does_not_evict_and_disconnect_releases_capacity() { + let receiver = FforReceiverTransport::default(); + for seed in 1..=MAX_PEERS as u8 { + receiver.peer_connected(key(seed)); + } + receiver.receive(key(1), frame(21)).unwrap(); + receiver.peer_connected(key(65)); + assert!(receiver.receive(key(65), frame(21)).is_err()); + assert_eq!(receiver.pop().unwrap().peer(), key(1)); + receiver.peer_disconnected(key(1)); + receiver.peer_connected(key(65)); + receiver.receive(key(65), frame(21)).unwrap(); + assert_accounting(&receiver); +} + +#[test] +fn ffor_per_peer_message_and_byte_limits_preserve_existing_work() { + let receiver = FforReceiverTransport::default(); + let peer = key(1); + receiver.peer_connected(peer); + for _ in 0..MAX_PEER_MESSAGES { + receiver.receive(peer, frame(21)).unwrap(); + } + assert!(receiver.receive(peer, frame(21)).is_err()); + assert_accounting(&receiver); + for _ in 0..MAX_PEER_MESSAGES { + assert_eq!(receiver.pop().unwrap().frame().wire().len(), 21); + } + assert!(receiver.pop().is_none()); + for _ in 0..4 { + receiver.receive(peer, frame(65500)).unwrap(); + } + receiver.receive(peer, frame(144)).unwrap(); + assert_eq!(receiver.state.lock().unwrap().bytes, MAX_PEER_BYTES); + assert!(receiver.receive(peer, frame(21)).is_err()); + receiver.pop().unwrap(); + receiver.receive(peer, frame(65500)).unwrap(); + assert_accounting(&receiver); +} + +#[test] +fn ffor_global_message_and_byte_limits_release_exact_accounting() { + let receiver = FforReceiverTransport::default(); + for seed in 1..=17 { + receiver.peer_connected(key(seed)); + } + for seed in 1..=16 { + for _ in 0..MAX_PEER_MESSAGES { + receiver.receive(key(seed), frame(21)).unwrap(); + } + } + assert!(receiver.receive(key(17), frame(21)).is_err()); + assert_accounting(&receiver); + receiver.peer_disconnected(key(1)); + receiver.receive(key(17), frame(21)).unwrap(); + while receiver.pop().is_some() {} + for seed in 2..=17 { + receiver.receive(key(seed), frame(65500)).unwrap(); + } + receiver.receive(key(2), frame(576)).unwrap(); + assert_eq!(receiver.state.lock().unwrap().bytes, MAX_QUEUED_BYTES); + assert!(receiver.receive(key(3), frame(21)).is_err()); + receiver.peer_disconnected(key(2)); + receiver.receive(key(3), frame(65535)).unwrap(); + assert_accounting(&receiver); +} + +#[test] +fn ffor_disconnect_can_race_with_receive_without_retaining_stale_work() { + let receiver = Arc::new(FforReceiverTransport::default()); + let peer = key(1); + receiver.peer_connected(peer); + let barrier = Arc::new(std::sync::Barrier::new(2)); + let receiving = Arc::clone(&receiver); + let thread_barrier = Arc::clone(&barrier); + let worker = std::thread::spawn(move || { + thread_barrier.wait(); + let _ = receiving.receive(peer, frame(21)); + }); + barrier.wait(); + receiver.peer_disconnected(peer); + worker.join().unwrap(); + assert!(receiver.pop().is_none()); + assert_accounting(&receiver); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + #[test] + fn ffor_arbitrary_connection_queue_sequences_preserve_bounds(operations in prop::collection::vec((0u8..4, 0u8..4, 21usize..65536), 0..300)) { + let receiver = FforReceiverTransport::default(); + let peers = [key(1), key(2), key(3), key(4)]; + for (operation, peer, size) in operations { + let peer = peers[peer as usize]; + match operation { + 0 => receiver.peer_connected(peer), + 1 => receiver.peer_disconnected(peer), + 2 => { let _ = receiver.receive(peer, frame(size)); }, + _ => { receiver.pop(); }, + } + assert_accounting(&receiver); + } + } +} diff --git a/src/message_handler/test_data.json b/src/message_handler/test_data.json new file mode 100644 index 0000000000..087fe80d18 --- /dev/null +++ b/src/message_handler/test_data.json @@ -0,0 +1,53 @@ +{ + "spec_revision": "d719161f42d1eeb6bd6c3856d564222f03c2205e", + "reference_revision": "8aee31d18e596fe49a0d195b325a6e757d7a009b", + "provenance": "Exact Appendix D.1 and Beignet lifecycle bytes copied from lightning-ffor tests/data at native commit 25d2c0d07935f4aa3ca5f728e75327f43b85915c. No signatures are generated by Node transport tests.", + "messages": [ + { + "name": "init", + "wire": "d6d9bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff150400000000000f4240000100000000000854d0000c3110000c3500000003e80000138800000000000000000000090800000000000f4240b650a5af1658bafe768dc0d5b07af4556ee5776b010bd4da19cd64aefd40f1a0184923774f95b2d3d9e024283db263075e8ed6f793ba4553b9960e638fd90933" + }, + { + "name": "accept", + "wire": "d6dbbef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff15000000000000002a0120a0a62adc5092cd3c202aaea0900b5826c242194c5f9984070e833e58b273eda507080000000000000000090800000000000f42400b20343331edc28e500ea03cbce26983583e66fff33dc9f096a3d6924b8239f68eb898fbd933af0a181f69b015f155a48608d1295327607d85bdb03a9598b54dc58f6ac01b0421309cebbbaa596e62113de7e6fe2aafaccc281f535eda535525b3ba" + }, + { + "name": "activate", + "wire": "d705bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff15ba34be8afe89e4457543a6e7279f86054f1d6e9038234b786b740e9c905c001f8ae276dd460c7b2030287a608677bc51eb9cdef5b7cfc1f73d005e463076510a723024696ad20b4758b228284620d18fd30750b7051cc64efca23a1417cf1321000c0df003aa4c0c160338dfb3783120761fb4a6053836307e196fe3597032f28715b9a021074ec06ceb9c5f06af9854f8b21ff1b6b74ff3ae326a049de8270f2c8dc559" + }, + { + "name": "activate_ack", + "wire": "d707bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff15580a4a958e8131b32452741e51d724e0f231cb518896c743fa59de236ac6e7161a20d93b0e00046483cffe0d9b54b0747a351ccaa3971a92aaf9245eb7cde5734f20a6ed584ccd30d81e833d9e89d9d9e42878b541cce5913a647b5ffc1ca9a3" + }, + { + "name": "abort", + "message_type": 55049, + "signer": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c", + "wire": "d70901010101010101010101010101010101010101010101010101010101010101010202020202020202020202020202020202020202020202020202020202020202080808080808080808080808080808080808080808080808080808080808080800060019646973636f6e6e656374656420647572696e672073657475708e68c4763d4ebe74817028d6a02fc833da4ca71b584623466ede149efeaa17c86ed6adfcca9e8c3770b5ca620ca3b15bc4d28a033357d2dfc640ee2b81a77893" + }, + { + "name": "close", + "message_type": 55051, + "signer": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c", + "wire": "d70b010101010101010101010101010101010101010101010101010101010101010102020202020202020202020202020202020202020202020202020202020202020909090909090909090909090909090909090909090909090909090909090909b8da4a492327808e2b591f2db8be5451b07c3d54feb5d719937804e3126501e4092d425044ce969609d8583736e9693602bc6da36eebfa5bf12c99bc57a5440c" + }, + { + "name": "close_ack_paid", + "message_type": 55053, + "signer": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c", + "wire": "d70d010101010101010101010101010101010101010101010101010101010101010102020202020202020202020202020202020202020202020202020202020202020909090909090909090909090909090909090909090909090909090909090909000201012200010505050505050505050505050505050505050505050505050505050505050505b7ddea980cd1041b4dd42c200b4a1f23e84bf9c78526f25a60b46bac63d9694e27170e46aa38740eeda54b20324eafa15074324b53f43339f54375eee3c93683" + }, + { + "name": "close_ack_unpaid_absent_tlv", + "message_type": 55053, + "signer": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c", + "wire": "d70d010101010101010101010101010101010101010101010101010101010101010102020202020202020202020202020202020202020202020202020202020202020909090909090909090909090909090909090909090909090909090909090909000200b5ef97faf9628a1baf0bb5911c52cc21482c750da7107d10f12c064b2ae0e7f04bfda970e26140b436fb5a5855c0374b502b21448f92ae990d202d120670beed" + }, + { + "name": "close_ack_unpaid_empty_tlv", + "message_type": 55053, + "signer": "035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c", + "wire": "d70d010101010101010101010101010101010101010101010101010101010101010102020202020202020202020202020202020202020202020202020202020202020909090909090909090909090909090909090909090909090909090909090909000200010050a03c5bd10fffb8e15f81cdf2868320fcb86f235654c6f3ee60d56deda66a4e756c3f5f01cf83f9c15791f8f2df269167fd4a06f3477d8041ac2bf2079e0458" + } + ] +} diff --git a/src/message_handler/tests.rs b/src/message_handler/tests.rs new file mode 100644 index 0000000000..2c70c7a2aa --- /dev/null +++ b/src/message_handler/tests.rs @@ -0,0 +1,247 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; + +use bitcoin::hex::FromHex; +use bitcoin::secp256k1::{Secp256k1, SecretKey}; +use lightning::ln::msgs::ErrorAction; +use lightning::util::logger::Level; +use lightning::util::ser::LengthReadable; +use lightning_ffor::witness::{Acknowledgement, AcknowledgementResult}; +use lightning_liquidity::lsps0::ser::LSPS_MESSAGE_TYPE_ID; +use proptest::prelude::*; + +use super::*; + +#[derive(Default)] +struct LspsHandler { + received: Mutex>, + pending: Mutex>, + connected: Mutex>, + disconnected: Mutex>, + fail_connect: AtomicBool, + fail_message: AtomicBool, +} + +impl CustomMessageReader for LspsHandler { + type CustomMessage = RawLSPSMessage; + fn read( + &self, message_type: u16, reader: &mut R, + ) -> Result, DecodeError> { + if message_type == LSPS_MESSAGE_TYPE_ID { + RawLSPSMessage::read_from_fixed_length_buffer(reader).map(Some) + } else { + Ok(None) + } + } +} + +impl CustomMessageHandler for LspsHandler { + fn handle_custom_message( + &self, message: RawLSPSMessage, peer: PublicKey, + ) -> Result<(), LightningError> { + if self.fail_message.load(Ordering::Relaxed) { + return Err(LightningError { + err: "LSPS test failure".into(), + action: ErrorAction::IgnoreAndLog(Level::Trace), + }); + } + self.received.lock().unwrap().push((peer, message)); + Ok(()) + } + fn get_and_clear_pending_msg(&self) -> Vec<(PublicKey, RawLSPSMessage)> { + std::mem::take(&mut *self.pending.lock().unwrap()) + } + fn peer_connected(&self, peer: PublicKey, init: &Init, inbound: bool) -> Result<(), ()> { + if self.fail_connect.load(Ordering::Relaxed) { + return Err(()); + } + self.connected.lock().unwrap().push((peer, init.clone(), inbound)); + Ok(()) + } + fn peer_disconnected(&self, peer: PublicKey) { + self.disconnected.lock().unwrap().push(peer); + } + fn provided_node_features(&self) -> NodeFeatures { + NodeFeatures::from_le_bytes(vec![2]) + } + fn provided_init_features(&self, _peer: PublicKey) -> InitFeatures { + InitFeatures::from_le_bytes(vec![2]) + } +} + +fn key(seed: u8) -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[seed; 32]).unwrap()) +} + +fn init() -> Init { + Init { features: InitFeatures::empty(), networks: None, remote_network_address: None } +} + +fn ack_wire(claimed_peer: PublicKey) -> Vec { + Acknowledgement::new( + [7; 16], + AcknowledgementResult::Accepted { witness: claimed_peer, retention_until: 800000 }, + ) + .unwrap() + .encode() +} + +fn parse(handler: &NodeCustomMessageHandler>, wire: &[u8]) -> NodeCustomMessage { + handler.read(u16::from_be_bytes([wire[0], wire[1]]), &mut &wire[2..]).unwrap().unwrap() +} + +#[test] +fn ffor_composes_lsps_read_outbox_features_and_callbacks() { + let lsps = Arc::new(LspsHandler::default()); + let receiver = Arc::new(FforReceiverTransport::default()); + let handler = NodeCustomMessageHandler::new_liquidity_handler(Arc::clone(&lsps)) + .with_ffor_receiver(Arc::clone(&receiver)); + let peer = key(1); + handler.peer_connected(peer, &init(), true).unwrap(); + assert_eq!(*lsps.connected.lock().unwrap(), vec![(peer, init(), true)]); + assert_eq!(handler.provided_init_features(peer), lsps.provided_init_features(peer)); + assert_eq!(handler.provided_node_features(), lsps.provided_node_features()); + + let raw = RawLSPSMessage { payload: "{\"jsonrpc\":\"2.0\",\"id\":\"42\"}".into() }; + let parsed = handler.read(LSPS_MESSAGE_TYPE_ID, &mut raw.encode().as_slice()).unwrap().unwrap(); + assert_eq!(parsed.type_id(), LSPS_MESSAGE_TYPE_ID); + assert_eq!(parsed.encode(), raw.encode()); + handler.handle_custom_message(parsed, peer).unwrap(); + assert_eq!(*lsps.received.lock().unwrap(), vec![(peer, raw.clone())]); + lsps.pending.lock().unwrap().push((peer, raw.clone())); + handler.handle_custom_message(parse(&handler, &ack_wire(key(2))), peer).unwrap(); + assert_eq!( + handler.get_and_clear_pending_msg(), + vec![(peer, NodeCustomMessage::Liquidity(raw))] + ); + assert!(handler.get_and_clear_pending_msg().is_empty()); + let incoming = receiver.pop().unwrap(); + assert_eq!(incoming.peer(), peer); // Claimed witness key(2) cannot replace transport key(1). + assert!(receiver.is_current(&incoming)); + handler.peer_disconnected(peer); + assert!(!receiver.is_current(&incoming)); + assert_eq!(*lsps.disconnected.lock().unwrap(), vec![peer]); +} + +#[test] +fn ffor_disabled_has_no_read_features_queue_or_connection_effects() { + let handler = NodeCustomMessageHandler::>::new_ignoring(); + let peer = key(1); + let wire = ack_wire(peer); + let mut body = &wire[2..]; + assert!(handler.read(55057, &mut body).unwrap().is_none()); + assert_eq!(body, &wire[2..]); + assert!(handler.read(LSPS_MESSAGE_TYPE_ID, &mut &b"{}"[..]).unwrap().is_none()); + assert_eq!(handler.provided_init_features(peer), InitFeatures::empty()); + assert_eq!(handler.provided_node_features(), NodeFeatures::empty()); + handler.peer_connected(peer, &init(), false).unwrap(); + handler.peer_disconnected(peer); + assert!(handler.get_and_clear_pending_msg().is_empty()); + let lsps = Arc::new(LspsHandler::default()); + let liquidity_only = NodeCustomMessageHandler::new_liquidity_handler(lsps); + assert!(liquidity_only.read(55057, &mut &wire[2..]).unwrap().is_none()); +} + +#[test] +fn ffor_binding_waits_for_lsps_success_and_preserves_errors() { + let lsps = Arc::new(LspsHandler::default()); + let receiver = Arc::new(FforReceiverTransport::default()); + let handler = NodeCustomMessageHandler::new_liquidity_handler(Arc::clone(&lsps)) + .with_ffor_receiver(Arc::clone(&receiver)); + let peer = key(1); + lsps.fail_connect.store(true, Ordering::Relaxed); + assert_eq!(handler.peer_connected(peer, &init(), false), Err(())); + let error = handler.handle_custom_message(parse(&handler, &ack_wire(peer)), peer).unwrap_err(); + assert_eq!(error.action, ErrorAction::IgnoreAndLog(Level::Debug)); + assert!(receiver.pop().is_none()); + lsps.fail_connect.store(false, Ordering::Relaxed); + handler.peer_connected(peer, &init(), false).unwrap(); + handler.handle_custom_message(parse(&handler, &ack_wire(peer)), peer).unwrap(); + let old = receiver.pop().unwrap(); + lsps.fail_connect.store(true, Ordering::Relaxed); + assert_eq!(handler.peer_connected(peer, &init(), false), Err(())); + assert!(!receiver.is_current(&old)); + assert!(handler.handle_custom_message(parse(&handler, &ack_wire(peer)), peer).is_err()); + lsps.fail_connect.store(false, Ordering::Relaxed); + handler.peer_connected(peer, &init(), false).unwrap(); + lsps.fail_message.store(true, Ordering::Relaxed); + let error = handler + .handle_custom_message( + NodeCustomMessage::Liquidity(RawLSPSMessage { payload: "{}".into() }), + peer, + ) + .unwrap_err(); + assert_eq!(error.err, "LSPS test failure"); + assert_eq!(error.action, ErrorAction::IgnoreAndLog(Level::Trace)); +} + +#[test] +fn ffor_lifecycle_fixtures_keep_exact_type_and_signed_bytes() { + let fixtures: serde_json::Value = serde_json::from_str(include_str!("test_data.json")).unwrap(); + let receiver = Arc::new(FforReceiverTransport::default()); + let handler = NodeCustomMessageHandler::>::new_ignoring() + .with_ffor_receiver(Arc::clone(&receiver)); + let peer = key(1); + handler.peer_connected(peer, &init(), false).unwrap(); + for fixture in fixtures["messages"].as_array().unwrap() { + let wire = Vec::::from_hex(fixture["wire"].as_str().unwrap()).unwrap(); + let message = parse(&handler, &wire); + assert_eq!(message.type_id().to_be_bytes(), wire[..2]); + assert_eq!(message.encode(), wire[2..]); + handler.handle_custom_message(message, peer).unwrap(); + let received = receiver.pop().unwrap(); + assert_eq!(received.frame().wire(), wire); + assert_eq!(received.peer(), peer); + } + assert!(handler.get_and_clear_pending_msg().is_empty()); +} + +#[test] +fn ffor_debug_keeps_lifecycle_payloads_out_of_peer_trace_logs() { + let fixtures: serde_json::Value = serde_json::from_str(include_str!("test_data.json")).unwrap(); + let handler = NodeCustomMessageHandler::>::new_ignoring() + .with_ffor_receiver(Arc::new(FforReceiverTransport::default())); + for fixture in fixtures["messages"].as_array().unwrap() { + let wire = Vec::::from_hex(fixture["wire"].as_str().unwrap()).unwrap(); + let message = parse(&handler, &wire); + assert_eq!( + format!("{:?}", message), + format!( + "Ffor(FforFrame {{ message_type: {}, length: {} }})", + message.type_id(), + wire.len(), + ) + ); + } +} + +#[test] +fn ffor_reader_rejects_malformed_oversized_and_ignores_unknown_types() { + let handler = NodeCustomMessageHandler::>::new_ignoring() + .with_ffor_receiver(Arc::new(FforReceiverTransport::default())); + for typ in [55001, 55003, 55045, 55047, 55049, 55051, 55053, 55057] { + assert!(handler.read(typ, &mut &[][..]).is_err()); + let oversized = vec![0; 65534]; + let mut body = oversized.as_slice(); + assert!(handler.read(typ, &mut body).is_err()); + assert_eq!(body.len(), oversized.len()); // Refuse before allocation or reading. + } + for typ in [55055, 55059, 55061, 55101, 0] { + assert!(handler.read(typ, &mut &[][..]).unwrap().is_none()); + } + let mut wire = ack_wire(key(1)); + wire[18] = 2; + assert!(handler.read(55057, &mut &wire[2..]).is_err()); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + #[test] + fn ffor_untrusted_frames_preserve_exact_bytes_or_fail(data in prop::collection::vec(any::(), 0..66000), typ in prop::sample::select(vec![55001u16,55003,55045,55047,55049,55051,55053,55057])) { + let handler = NodeCustomMessageHandler::>::new_ignoring().with_ffor_receiver(Arc::new(FforReceiverTransport::default())); + if let Ok(Some(message)) = handler.read(typ, &mut data.as_slice()) { + prop_assert_eq!(message.type_id(), typ); + prop_assert_eq!(message.encode(), data); + } + } +} diff --git a/src/types.rs b/src/types.rs index 717aa61113..1ff23d48cc 100644 --- a/src/types.rs +++ b/src/types.rs @@ -103,7 +103,7 @@ pub(crate) type PeerManager = lightning::ln::peer_handler::PeerManager< Arc, Arc, Arc, - Arc>>, + Arc, Arc, Arc, >; From e1fd37e11c0624f5bd0bea32c82cad5e4b40d570 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Sun, 20 Sep 2026 23:03:07 -0400 Subject: [PATCH 08/23] chore(ffor): pin native activation and reconnect recovery --- Cargo.lock | 50 +++++++++++++++++++++++++------------------------- Cargo.toml | 26 +++++++++++++------------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3bc33982e4..f37e0bcdb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -982,7 +982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1616,7 +1616,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1782,7 +1782,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1994,7 +1994,7 @@ dependencies = [ [[package]] name = "lightning" version = "0.2.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" dependencies = [ "bech32", "bitcoin", @@ -2006,7 +2006,7 @@ dependencies = [ "lightning-macros 0.2.1", "lightning-types 0.3.5", "musig2", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919)", "regex", ] @@ -2026,7 +2026,7 @@ dependencies = [ [[package]] name = "lightning-background-processor" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" dependencies = [ "bitcoin", "bitcoin-io", @@ -2034,7 +2034,7 @@ dependencies = [ "lightning 0.2.5", "lightning-liquidity 0.2.3", "lightning-rapid-gossip-sync 0.2.3", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919)", ] [[package]] @@ -2053,7 +2053,7 @@ dependencies = [ [[package]] name = "lightning-block-sync" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" dependencies = [ "bitcoin", "chunked_transfer", @@ -2065,7 +2065,7 @@ dependencies = [ [[package]] name = "lightning-ffor" version = "0.1.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" dependencies = [ "bitcoin", ] @@ -2085,7 +2085,7 @@ dependencies = [ [[package]] name = "lightning-invoice" version = "0.34.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" dependencies = [ "bech32", "bitcoin", @@ -2111,7 +2111,7 @@ dependencies = [ [[package]] name = "lightning-liquidity" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" dependencies = [ "bitcoin", "chrono", @@ -2137,7 +2137,7 @@ dependencies = [ [[package]] name = "lightning-macros" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" dependencies = [ "proc-macro2", "quote", @@ -2158,7 +2158,7 @@ dependencies = [ [[package]] name = "lightning-net-tokio" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2179,7 +2179,7 @@ dependencies = [ [[package]] name = "lightning-persister" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2202,7 +2202,7 @@ dependencies = [ [[package]] name = "lightning-rapid-gossip-sync" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" dependencies = [ "bitcoin", "bitcoin-io", @@ -2227,7 +2227,7 @@ dependencies = [ [[package]] name = "lightning-transaction-sync" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" dependencies = [ "bitcoin", "electrum-client 0.24.1", @@ -2249,7 +2249,7 @@ dependencies = [ [[package]] name = "lightning-types" version = "0.3.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" dependencies = [ "bitcoin", ] @@ -2676,7 +2676,7 @@ dependencies = [ [[package]] name = "possiblyrandom" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=25d2c0d07935f4aa3ca5f728e75327f43b85915c#25d2c0d07935f4aa3ca5f728e75327f43b85915c" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" dependencies = [ "getrandom 0.2.17", ] @@ -2976,7 +2976,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.43", - "socket2 0.6.5", + "socket2 0.5.10", "thiserror 2.0.20", "tokio", "tracing", @@ -3014,9 +3014,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2 0.5.10", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3331,7 +3331,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3730,10 +3730,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4573,7 +4573,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 77b85b38fd..1b09905679 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,18 +34,18 @@ default = [] [dependencies] # Pin runtime dependencies directly so downstream consumers use the same native engine. -lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c" } -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c", features = ["std"] } -lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c" } -lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c" } -lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c" } -lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c" } -lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c", features = ["std"] } -lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c" } +lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919" } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919", features = ["std"] } +lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919" } +lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919" } +lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919" } +lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919" } +lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919", features = ["std"] } +lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919" } bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} @@ -81,7 +81,7 @@ prost = { version = "0.11.6", default-features = false} winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "25d2c0d07935f4aa3ca5f728e75327f43b85915c", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919", features = ["std", "_test_utils"] } proptest = "1.0.0" regex = "1.5.6" criterion = { version = "0.7.0", features = ["async_tokio"] } From f60691f016701e4ffaee25c203a2da50f5a5a921 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 21 Sep 2026 00:03:53 -0400 Subject: [PATCH 09/23] feat(ffor): persist encrypted witness recovery material --- Cargo.lock | 30 +- Cargo.toml | 26 +- FFOR.md | 46 +++ src/ffor/mod.rs | 9 + src/ffor/witness_store.rs | 245 ++++++++++++++ src/ffor/witness_store/binding.rs | 182 +++++++++++ src/ffor/witness_store/envelope.rs | 87 +++++ src/ffor/witness_store/record.rs | 271 +++++++++++++++ src/ffor/witness_store/tests.rs | 509 +++++++++++++++++++++++++++++ src/lib.rs | 1 + 10 files changed, 1378 insertions(+), 28 deletions(-) create mode 100644 src/ffor/mod.rs create mode 100644 src/ffor/witness_store.rs create mode 100644 src/ffor/witness_store/binding.rs create mode 100644 src/ffor/witness_store/envelope.rs create mode 100644 src/ffor/witness_store/record.rs create mode 100644 src/ffor/witness_store/tests.rs diff --git a/Cargo.lock b/Cargo.lock index f37e0bcdb0..80ed4d964a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1994,7 +1994,7 @@ dependencies = [ [[package]] name = "lightning" version = "0.2.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" dependencies = [ "bech32", "bitcoin", @@ -2006,7 +2006,7 @@ dependencies = [ "lightning-macros 0.2.1", "lightning-types 0.3.5", "musig2", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4)", "regex", ] @@ -2026,7 +2026,7 @@ dependencies = [ [[package]] name = "lightning-background-processor" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" dependencies = [ "bitcoin", "bitcoin-io", @@ -2034,7 +2034,7 @@ dependencies = [ "lightning 0.2.5", "lightning-liquidity 0.2.3", "lightning-rapid-gossip-sync 0.2.3", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4)", ] [[package]] @@ -2053,7 +2053,7 @@ dependencies = [ [[package]] name = "lightning-block-sync" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" dependencies = [ "bitcoin", "chunked_transfer", @@ -2065,7 +2065,7 @@ dependencies = [ [[package]] name = "lightning-ffor" version = "0.1.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" dependencies = [ "bitcoin", ] @@ -2085,7 +2085,7 @@ dependencies = [ [[package]] name = "lightning-invoice" version = "0.34.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" dependencies = [ "bech32", "bitcoin", @@ -2111,7 +2111,7 @@ dependencies = [ [[package]] name = "lightning-liquidity" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" dependencies = [ "bitcoin", "chrono", @@ -2137,7 +2137,7 @@ dependencies = [ [[package]] name = "lightning-macros" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" dependencies = [ "proc-macro2", "quote", @@ -2158,7 +2158,7 @@ dependencies = [ [[package]] name = "lightning-net-tokio" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2179,7 +2179,7 @@ dependencies = [ [[package]] name = "lightning-persister" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2202,7 +2202,7 @@ dependencies = [ [[package]] name = "lightning-rapid-gossip-sync" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" dependencies = [ "bitcoin", "bitcoin-io", @@ -2227,7 +2227,7 @@ dependencies = [ [[package]] name = "lightning-transaction-sync" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" dependencies = [ "bitcoin", "electrum-client 0.24.1", @@ -2249,7 +2249,7 @@ dependencies = [ [[package]] name = "lightning-types" version = "0.3.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" dependencies = [ "bitcoin", ] @@ -2676,7 +2676,7 @@ dependencies = [ [[package]] name = "possiblyrandom" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8da701be295f392195de9b935c91f071c5a30919#8da701be295f392195de9b935c91f071c5a30919" +source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" dependencies = [ "getrandom 0.2.17", ] diff --git a/Cargo.toml b/Cargo.toml index 1b09905679..43b0b8f11e 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,18 +34,18 @@ default = [] [dependencies] # Pin runtime dependencies directly so downstream consumers use the same native engine. -lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919" } -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919", features = ["std"] } -lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919" } -lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919" } -lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919" } -lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919" } -lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919", features = ["std"] } -lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919" } +lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4" } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4", features = ["std"] } +lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4" } +lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4" } +lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4" } +lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4" } +lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4", features = ["std"] } +lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4" } bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} @@ -81,7 +81,7 @@ prost = { version = "0.11.6", default-features = false} winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "8da701be295f392195de9b935c91f071c5a30919", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4", features = ["std", "_test_utils"] } proptest = "1.0.0" regex = "1.5.6" criterion = { version = "0.7.0", features = ["async_tokio"] } diff --git a/FFOR.md b/FFOR.md index f6b9272e77..d45589c409 100644 --- a/FFOR.md +++ b/FFOR.md @@ -57,6 +57,52 @@ is recorded in `src/message_handler/test_data.json`. Parser fuzzing remains in t shared `lightning-ffor` wire and witness targets; Node also runs arbitrary-byte property checks at the length-limited reader boundary. +## Private witness secret storage + +The private `ffor::witness_store` module retains an immutable encryption key per +epoch, plus a separate fetch key, mailbox and exact signed manifest per witness. +It has no builder, transport or payment-provider caller. Its non-test binding +constructor accepts only an opaque native recovery context with a retained activation +acknowledgement. It binds the native context digest, original funding output, +identities and exact signed setup and activation. Historical evidence and successful +secret storage do not establish current activation or invoice readiness. Runtime use +still requires a current native authority check. + +The store derives a dedicated wrapping key from the wallet seed using HKDF with +HMAC-SHA256. It uses the existing pinned VSS client's ChaCha20Poly1305 primitive +with a fresh operating-system nonce, even for local storage. Associated data binds +the storage key, schema and exact epoch evidence. Load authenticates the envelope, +rechecks the manifests and compares their public keys with the retained secrets. +Own secret buffers are zeroized and debug output is redacted. The dependency makes +additional plaintext allocations which it does not zeroize; complete memory erasure +is not claimed. The construction follows [HKDF](https://www.rfc-editor.org/rfc/rfc5869) +and [ChaCha20Poly1305](https://www.rfc-editor.org/rfc/rfc8439). + +One exclusive owner serializes the namespace. Admission is bounded to four witnesses +per epoch, 512 KiB per encrypted record, 64 epochs and 8 MiB overall. Records are not +evicted or replaced to admit new work. A failed write blocks that owner until the +exact retained ciphertext is written successfully. Readable bytes alone do not prove +durability: a filesystem rename can become visible before directory synchronization +succeeds. Reopening likewise requires an authenticated, byte-identical successful +rewrite before returning restored material. No retry creates replacement keys or +manifests for an existing record. + +The threat boundary includes untrusted peers, malformed or altered storage, failed +entropy, interrupted writes and concurrent callers within one owner. Store success +is the backend's durability contract. Authenticated encryption does not detect a +rollback to an older valid backup or protect against wallet-seed compromise. KVStore +has no cross-process compare-and-swap, so concurrent independent owners are not +supported. A future native registration must distinguish a new epoch from an +existing epoch whose entire sidecar is missing; `load` never regenerates secrets. +Fetch nonce allocation and witness acknowledgement persistence are separate runtime +work and are not supplied by this store. + +Run `cargo test --lib ffor_witness_store` for exact retries and restart, failed writes +with readable bytes, corruption, wrong seeds and bindings, key separation, capacity, +concurrent creation, fallible entropy, truncated records and property-based mutation +checks. These checks do not establish complete branch coverage or process-crash +interoperability. + ## Required runtime integration - Bind signed setup to the actual local identity, peer, chain and channel limits. diff --git a/src/ffor/mod.rs b/src/ffor/mod.rs new file mode 100644 index 0000000000..5e4239b65f --- /dev/null +++ b/src/ffor/mod.rs @@ -0,0 +1,9 @@ +//! Private witness recovery storage, not a native activation or invoice authority. +//! +//! No builder, transport, or payment provider constructs this module yet. Bindings come from opaque +//! native historical contexts; future registration and external work still require current native +//! authority checked under the manager's transition locks. + +// This bounded storage boundary remains disconnected until native lifecycle ownership exists. +#[allow(dead_code)] +mod witness_store; diff --git a/src/ffor/witness_store.rs b/src/ffor/witness_store.rs new file mode 100644 index 0000000000..24c84c0cb6 --- /dev/null +++ b/src/ffor/witness_store.rs @@ -0,0 +1,245 @@ +//! Immutable per-epoch witness secrets sealed before they can leave the storage boundary. +//! +//! One exclusive owner serializes all operations for this namespace. KVStore has no cross-process +//! compare-and-swap, so concurrent owners are unsupported. Store success is the durability contract; +//! authenticated encryption does not detect rollback to an older valid store. Opaque native history +//! supplies record bindings. Live native registration, fetch nonce allocation, transport, witness +//! acknowledgement handling and deletion are deliberately absent. +//! A future native registration must distinguish a new epoch from a missing sidecar after restore; +//! `load` never recreates secrets, and `create` is only for explicitly new registration material. +//! +//! Fetch retries require new mailbox-wide nonces, including after a lost reply or restart. Provision +//! retries retain the exact signed manifest and use fresh connection-scoped request identifiers. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::sync::{Arc, Mutex}; + +use bitcoin::hashes::{sha256, Hash, HashEngine}; +use lightning::util::persist::KVStoreSync; + +use crate::types::DynStore; + +mod binding; +mod envelope; +mod record; + +pub(super) use binding::WitnessStorageBinding; +#[cfg(test)] +use binding::WitnessStorageIdentity; +use envelope::WrappingKey; +pub(super) use record::{StoredWitnessEpoch, WitnessPolicy}; + +const PRIMARY_NAMESPACE: &str = "ffor_witness"; +const SECONDARY_NAMESPACE: &str = ""; +// Implementation admission caps, not protocol promises. Four maximum canonical manifests plus +// their private material fit below the per-record cap. No record is evicted to admit another. +const MAX_WITNESSES: usize = 4; +const MAX_RECORD_BYTES: usize = 512 * 1024; +const MAX_STORE_BYTES: usize = 8 * 1024 * 1024; +const MAX_EPOCHS: usize = 64; +const SCHEMA_VERSION: u16 = 1; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum WitnessStoreError { + Storage, + Missing, + Corrupt, + Binding, + Conflict, + Capacity, + Entropy, + Uncertain, +} + +struct PendingWrite { + key: String, + bytes: Vec, +} + +struct StoreState { + // Ciphertext only. This bounds inventory and detects replacement by another owner. + records: BTreeMap>, + // A read may expose a rename whose directory fsync failed. Restored entries require one + // authenticated, byte-identical successful write before this owner can return their material. + confirmed: BTreeSet, + uncertain: Option, +} + +pub(super) struct WitnessSecretStore { + storage: Arc, + wrapping_key: WrappingKey, + state: Mutex, +} + +impl fmt::Debug for WitnessSecretStore { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WitnessSecretStore").finish_non_exhaustive() + } +} + +impl WitnessSecretStore { + /// Opens an exclusive, bounded namespace. The borrowed wallet seed never leaves this call. + /// Loading this sidecar does not establish activation, funding state, or backup freshness. + pub(super) fn open(seed: &[u8; 64], storage: Arc) -> Result { + let keys = KVStoreSync::list(&*storage, PRIMARY_NAMESPACE, SECONDARY_NAMESPACE) + .map_err(|_| WitnessStoreError::Storage)?; + if keys.len() > MAX_EPOCHS { + return Err(WitnessStoreError::Capacity); + } + let mut records = BTreeMap::new(); + let mut total = 0usize; + for key in keys { + if key.len() != 64 + || !key.bytes().all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err(WitnessStoreError::Corrupt); + } + let bytes = read(&*storage, &key)?; + check_record_size(&bytes)?; + total = total.checked_add(bytes.len()).ok_or(WitnessStoreError::Capacity)?; + if total > MAX_STORE_BYTES || records.insert(key, bytes).is_some() { + return Err(WitnessStoreError::Capacity); + } + } + Ok(Self { + storage, + wrapping_key: WrappingKey::derive(seed), + state: Mutex::new(StoreState { records, confirmed: BTreeSet::new(), uncertain: None }), + }) + } + + /// Creates one immutable record or returns the identical existing witness policy and manifests. + /// Any failed write blocks this entire owner until `recover_write` resolves that exact attempt. + pub(super) fn create( + &self, binding: &WitnessStorageBinding, policies: &[WitnessPolicy], + ) -> Result { + let mut state = self.state.lock().map_err(|_| WitnessStoreError::Uncertain)?; + ensure_certain(&state)?; + let policies = record::checked_policies(policies)?; + let key = binding.storage_key(); + if state.records.contains_key(&key) { + let record = self.load_locked(&mut state, binding)?; + if record.policies() != policies { + return Err(WitnessStoreError::Conflict); + } + return Ok(record); + } + if state.records.len() == MAX_EPOCHS { + return Err(WitnessStoreError::Capacity); + } + match read(&*self.storage, &key) { + Err(WitnessStoreError::Missing) => {}, + Ok(_) => return Err(WitnessStoreError::Conflict), + Err(error) => return Err(error), + } + let record = StoredWitnessEpoch::generate(binding, &policies)?; + let bytes = self.wrapping_key.seal(binding, &record)?; + check_record_size(&bytes)?; + let total = state.records.values().map(Vec::len).sum::(); + if total.checked_add(bytes.len()).filter(|sum| *sum <= MAX_STORE_BYTES).is_none() { + return Err(WitnessStoreError::Capacity); + } + self.confirm_write(&mut state, key, bytes)?; + Ok(record) + } + + /// Reads existing material only. Missing, altered or unauthenticated records never regenerate keys. + pub(super) fn load( + &self, binding: &WitnessStorageBinding, + ) -> Result { + let mut state = self.state.lock().map_err(|_| WitnessStoreError::Uncertain)?; + ensure_certain(&state)?; + self.load_locked(&mut state, binding) + } + + fn load_locked( + &self, state: &mut StoreState, binding: &WitnessStorageBinding, + ) -> Result { + let key = binding.storage_key(); + let expected = state.records.get(&key).ok_or(WitnessStoreError::Missing)?; + let actual = read(&*self.storage, &key)?; + if &actual != expected { + return Err(WitnessStoreError::Conflict); + } + let record = self.wrapping_key.open(binding, &actual)?; + if !state.confirmed.contains(&key) { + self.confirm_write(state, key, actual)?; + } + Ok(record) + } + + fn confirm_write( + &self, state: &mut StoreState, key: String, bytes: Vec, + ) -> Result<(), WitnessStoreError> { + state.uncertain = Some(PendingWrite { key: key.clone(), bytes: bytes.clone() }); + write(&*self.storage, &key, bytes.clone())?; + state.confirmed.insert(key.clone()); + state.records.insert(key, bytes); + state.uncertain = None; + Ok(()) + } + + /// Reconciles an uncertain write, accepting only identical ciphertext or an absent key. + /// Always rewrites retained ciphertext: visibility through read does not establish durability. + /// No fresh keys, signatures or encryption nonces are allocated. + /// A read error, conflict, or another failed write leaves the owner blocked. + pub(super) fn recover_write(&self) -> Result<(), WitnessStoreError> { + let mut state = self.state.lock().map_err(|_| WitnessStoreError::Uncertain)?; + let pending = match &state.uncertain { + Some(pending) => pending, + None => return Ok(()), + }; + match read(&*self.storage, &pending.key) { + Ok(bytes) if bytes == pending.bytes => {}, + Ok(_) => return Err(WitnessStoreError::Conflict), + Err(WitnessStoreError::Missing) => {}, + Err(error) => return Err(error), + } + let key = pending.key.clone(); + let bytes = pending.bytes.clone(); + self.confirm_write(&mut state, key, bytes) + } +} + +fn ensure_certain(state: &StoreState) -> Result<(), WitnessStoreError> { + if state.uncertain.is_some() { + Err(WitnessStoreError::Uncertain) + } else { + Ok(()) + } +} + +fn check_record_size(bytes: &[u8]) -> Result<(), WitnessStoreError> { + if bytes.is_empty() || bytes.len() > MAX_RECORD_BYTES { + Err(WitnessStoreError::Capacity) + } else { + Ok(()) + } +} + +fn read(storage: &DynStore, key: &str) -> Result, WitnessStoreError> { + KVStoreSync::read(storage, PRIMARY_NAMESPACE, SECONDARY_NAMESPACE, key).map_err(|error| { + if error.kind() == lightning::io::ErrorKind::NotFound { + WitnessStoreError::Missing + } else { + WitnessStoreError::Storage + } + }) +} + +fn write(storage: &DynStore, key: &str, bytes: Vec) -> Result<(), WitnessStoreError> { + KVStoreSync::write(storage, PRIMARY_NAMESPACE, SECONDARY_NAMESPACE, key, bytes) + .map_err(|_| WitnessStoreError::Storage) +} + +fn hash(parts: &[&[u8]]) -> [u8; 32] { + let mut engine = sha256::Hash::engine(); + for part in parts { + engine.input(part); + } + sha256::Hash::from_engine(engine).to_byte_array() +} + +#[cfg(test)] +mod tests; diff --git a/src/ffor/witness_store/binding.rs b/src/ffor/witness_store/binding.rs new file mode 100644 index 0000000000..61410f5fec --- /dev/null +++ b/src/ffor/witness_store/binding.rs @@ -0,0 +1,182 @@ +use bitcoin::consensus::encode::serialize; +use bitcoin::secp256k1::PublicKey; +use bitcoin::OutPoint; +use lightning::ln::ffor::FFORReceiverRecoveryContext; +use lightning_ffor::setup::AuthenticatedSetup; +use lightning_ffor::wire::{Message, Payload}; + +use super::{hash, WitnessStoreError, SCHEMA_VERSION}; + +/// Original native storage identity, without current channel ownership or activation authority. +#[derive(Clone, Debug)] +pub(crate) struct WitnessStorageIdentity { + pub(crate) chain_hash: [u8; 32], + pub(crate) actual_node: PublicKey, + pub(crate) settlement_node: PublicKey, + pub(crate) original_funding: OutPoint, +} + +/// Immutable sidecar binding to signed historical evidence, requiring a live native authority check. +/// No durable/active flag can be supplied or inferred from this value. +#[derive(Clone, Debug)] +pub(crate) struct WitnessStorageBinding { + identity: WitnessStorageIdentity, + setup: AuthenticatedSetup, + activation_hash: [u8; 32], + commitment_hash: [u8; 32], + epoch_start_height: u32, + digest: [u8; 32], +} + +impl WitnessStorageBinding { + /// Binds to an opaque native history, including its stable digest and original funding identity. + /// A retained acknowledgement is required for witness material, but does not prove current ACTIVE + /// state, persistence, admission timing or permission to provision or expose an invoice. + pub(crate) fn from_native_context( + context: &FFORReceiverRecoveryContext, + ) -> Result { + let activate = + Message::decode(context.activation_wire()).map_err(|_| WitnessStoreError::Binding)?; + let acknowledgement = + Message::decode(context.activation_ack_wire().ok_or(WitnessStoreError::Binding)?) + .map_err(|_| WitnessStoreError::Binding)?; + let identity = WitnessStorageIdentity { + chain_hash: context.chain_hash().to_bytes(), + actual_node: context.receiver_node_id(), + settlement_node: context.settlement_node_id(), + original_funding: context.funding_txo().into_bitcoin_outpoint(), + }; + let binding = Self::from_evidence( + identity, + context.setup().clone(), + &activate, + &acknowledgement, + context.context_digest(), + )?; + if binding.activation_hash != context.activation_hash() + || binding.setup.header().channel_id != context.channel_id().0 + || binding.setup.header().epoch_id != context.epoch_id() + { + return Err(WitnessStoreError::Binding); + } + Ok(binding) + } + + #[cfg(test)] + pub(super) fn new( + identity: WitnessStorageIdentity, setup: AuthenticatedSetup, activate: &Message, + acknowledgement: &Message, + ) -> Result { + Self::from_evidence(identity, setup, activate, acknowledgement, [0; 32]) + } + + fn from_evidence( + identity: WitnessStorageIdentity, setup: AuthenticatedSetup, activate: &Message, + acknowledgement: &Message, native_context_digest: [u8; 32], + ) -> Result { + // Reauthenticate both roles against the supplied actual identities, not wire claims. + let setup = AuthenticatedSetup::new( + setup.init(), + setup.accept(), + identity.actual_node, + identity.settlement_node, + ) + .map_err(|_| WitnessStoreError::Binding)?; + let terms = match &activate.payload { + Payload::Activate(terms) => terms, + _ => return Err(WitnessStoreError::Binding), + }; + // Historical self-consistency only. A peer's signed H_commit is not a native monitor proof. + let activation_hash = setup + .validate_activation(activate, terms.commit_hash, terms.epoch_start_height) + .map_err(|_| WitnessStoreError::Binding)?; + setup + .validate_activation_ack(acknowledgement, activation_hash) + .map_err(|_| WitnessStoreError::Binding)?; + let init = setup.init().encode().map_err(|_| WitnessStoreError::Binding)?; + let accept = setup.accept().encode().map_err(|_| WitnessStoreError::Binding)?; + let activation = activate.encode().map_err(|_| WitnessStoreError::Binding)?; + let ack = acknowledgement.encode().map_err(|_| WitnessStoreError::Binding)?; + let digest = hash(&[ + b"ldk-node/ffor/witness-binding/v1", + &SCHEMA_VERSION.to_be_bytes(), + &native_context_digest, + &identity.chain_hash, + &identity.actual_node.serialize(), + &identity.settlement_node.serialize(), + &serialize(&identity.original_funding), + &hash(&[&init]), + &hash(&[&accept]), + &hash(&[&activation]), + &hash(&[&ack]), + setup.canonical_book(), + &activation_hash, + ]); + Ok(Self { + identity, + setup, + activation_hash, + commitment_hash: terms.commit_hash, + epoch_start_height: terms.epoch_start_height, + digest, + }) + } + + pub(crate) fn storage_key(&self) -> String { + let header = self.setup.header(); + let digest = hash(&[ + b"ldk-node/ffor/witness-key/v1", + &self.identity.chain_hash, + &self.identity.actual_node.serialize(), + &header.channel_id, + &header.epoch_id, + ]); + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut key = String::with_capacity(64); + for byte in digest { + key.push(HEX[usize::from(byte >> 4)] as char); + key.push(HEX[usize::from(byte & 15)] as char); + } + key + } + + pub(crate) fn digest(&self) -> [u8; 32] { + self.digest + } + pub(crate) fn setup(&self) -> &AuthenticatedSetup { + &self.setup + } + pub(crate) fn activation_hash(&self) -> [u8; 32] { + self.activation_hash + } + pub(crate) fn commitment_hash(&self) -> [u8; 32] { + self.commitment_hash + } + pub(crate) fn epoch_start_height(&self) -> u32 { + self.epoch_start_height + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ffor_witness_store_native_context_digest_binds_same_epoch() { + let (identity, setup, activate, ack) = super::super::tests::evidence(1, 2); + let first = WitnessStorageBinding::from_evidence( + identity.clone(), + setup.clone(), + &activate, + &ack, + [1; 32], + ) + .unwrap(); + let changed = + WitnessStorageBinding::from_evidence(identity, setup, &activate, &ack, [2; 32]) + .unwrap(); + // Same epoch addresses the existing record, but altered native provenance cannot open it. + assert_eq!(first.storage_key(), changed.storage_key()); + assert_ne!(first.digest(), changed.digest()); + } +} diff --git a/src/ffor/witness_store/envelope.rs b/src/ffor/witness_store/envelope.rs new file mode 100644 index 0000000000..e5a9f76bca --- /dev/null +++ b/src/ffor/witness_store/envelope.rs @@ -0,0 +1,87 @@ +use bitcoin::hashes::{sha256, Hash, HashEngine, Hmac, HmacEngine}; +use prost::Message; +use vss_client::types::Storable; +use vss_client::util::storable_builder::{EntropySource, StorableBuilder}; +use zeroize::Zeroizing; + +use super::record::{random_bytes, StoredWitnessEpoch}; +use super::{check_record_size, WitnessStorageBinding, WitnessStoreError, SCHEMA_VERSION}; + +const MAGIC: &[u8; 4] = b"FWSE"; +const HEADER_LENGTH: usize = 4 + 2 + 32; + +pub(super) struct WrappingKey(Zeroizing<[u8; 32]>); + +impl WrappingKey { + pub(super) fn derive(seed: &[u8; 64]) -> Self { + // HKDF extract/expand with a dedicated storage purpose, separate from all account/node keys. + let mut extract = HmacEngine::::new(b"ldk-node/ffor/witness-storage/v1"); + extract.input(seed); + let prk = Zeroizing::new(Hmac::from_engine(extract).to_byte_array()); + let mut expand = HmacEngine::::new(prk.as_ref()); + expand.input(b"wrapping-key"); + expand.input(&[1]); + Self(Zeroizing::new(Hmac::from_engine(expand).to_byte_array())) + } + + pub(super) fn seal( + &self, binding: &WitnessStorageBinding, record: &StoredWitnessEpoch, + ) -> Result, WitnessStoreError> { + let nonce = random_bytes::<12>()?; + let builder = StorableBuilder::new(EnvelopeNonce(nonce)); + let mut bytes = header(binding); + let aad = associated_data(binding, &bytes); + let plaintext = record.encode(); + // Our buffer is zeroized. StorableBuilder owns additional plaintext allocations during + // encryption and decryption which it does not zeroize. No total erasure is claimed. + let sealed = builder.build(plaintext.to_vec(), i64::from(SCHEMA_VERSION), &self.0, &aad); + bytes.extend_from_slice(&sealed.encode_to_vec()); + check_record_size(&bytes)?; + Ok(bytes) + } + + pub(super) fn open( + &self, binding: &WitnessStorageBinding, bytes: &[u8], + ) -> Result { + check_record_size(bytes)?; + if bytes.get(..HEADER_LENGTH) != Some(header(binding).as_slice()) { + return Err(WitnessStoreError::Binding); + } + let encoded = &bytes[HEADER_LENGTH..]; + let storable = Storable::decode(encoded).map_err(|_| WitnessStoreError::Corrupt)?; + if storable.encode_to_vec() != encoded + || storable.encryption_metadata.as_ref().map(|metadata| metadata.cipher_format.as_str()) + != Some("ChaCha20Poly1305") + { + return Err(WitnessStoreError::Corrupt); + } + let aad = associated_data(binding, &bytes[..HEADER_LENGTH]); + let (plaintext, version) = StorableBuilder::new(EnvelopeNonce([0; 12])) + .deconstruct(storable, &self.0, &aad) + .map_err(|_| WitnessStoreError::Corrupt)?; + let plaintext = Zeroizing::new(plaintext); + if version != i64::from(SCHEMA_VERSION) { + return Err(WitnessStoreError::Corrupt); + } + StoredWitnessEpoch::decode(binding, &plaintext) + } +} + +fn header(binding: &WitnessStorageBinding) -> Vec { + [MAGIC.as_slice(), &SCHEMA_VERSION.to_be_bytes(), &binding.digest()].concat() +} + +fn associated_data(binding: &WitnessStorageBinding, header: &[u8]) -> Vec { + [b"ldk-node/ffor/witness-envelope/v1".as_slice(), binding.storage_key().as_bytes(), header] + .concat() +} + +struct EnvelopeNonce([u8; 12]); + +impl EntropySource for EnvelopeNonce { + fn fill_bytes(&self, buffer: &mut [u8]) { + // StorableBuilder::build requests exactly one 12-byte AEAD nonce. Entropy was obtained + // fallibly before this infallible dependency callback, and this builder is used once. + buffer.copy_from_slice(&self.0); + } +} diff --git a/src/ffor/witness_store/record.rs b/src/ffor/witness_store/record.rs new file mode 100644 index 0000000000..f0fad5c4c9 --- /dev/null +++ b/src/ffor/witness_store/record.rs @@ -0,0 +1,271 @@ +use std::fmt; + +use bitcoin::secp256k1::{Message, PublicKey, Secp256k1, SecretKey}; +use lightning_ffor::witness::{ManifestParameters, SignedManifest, UnsignedManifest}; +use rand::rngs::OsRng; +use rand::TryRngCore; +use zeroize::Zeroizing; + +use super::{WitnessStorageBinding, WitnessStoreError, MAX_WITNESSES, SCHEMA_VERSION}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct WitnessPolicy { + pub(super) witness: PublicKey, + pub(super) retention_until: u32, + pub(super) minimum_receipts: u8, +} + +struct WitnessKeys { + policy: WitnessPolicy, + fetch_secret: Zeroizing<[u8; 32]>, + manifest: SignedManifest, +} + +/// Immutable material recovered from one successful store write, not proof of native activation. +/// There is no key-export or sending API. A future consumer must rejoin the native epoch owner. +pub(crate) struct StoredWitnessEpoch { + binding_digest: [u8; 32], + encryption_secret: Zeroizing<[u8; 32]>, + witnesses: Vec, +} + +impl fmt::Debug for StoredWitnessEpoch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("StoredWitnessEpoch") + .field("binding_digest", &self.binding_digest) + .field("witness_count", &self.witnesses.len()) + .field("secrets", &"[redacted]") + .finish() + } +} + +impl StoredWitnessEpoch { + pub(super) fn generate( + binding: &WitnessStorageBinding, policies: &[WitnessPolicy], + ) -> Result { + let encryption_secret = generate_secret()?; + let encryption_public_key = public_key(&encryption_secret)?; + let mut witnesses: Vec = Vec::with_capacity(policies.len()); + for policy in policies { + let fetch_secret = generate_secret()?; + let fetch_public_key = public_key(&fetch_secret)?; + let mailbox_id = random_bytes::<32>()?; + if fetch_public_key == encryption_public_key + || witnesses.iter().any(|entry| { + let params = entry.manifest.unsigned().parameters(); + params.mailbox_id == mailbox_id || params.fetch_public_key == fetch_public_key + }) { + return Err(WitnessStoreError::Entropy); + } + let unsigned = UnsignedManifest::new( + binding.setup(), + ManifestParameters { + mailbox_id, + commitment_hash: binding.commitment_hash(), + epoch_start_height: binding.epoch_start_height(), + fetch_public_key, + encryption_public_key, + retention_until: policy.retention_until, + minimum_receipts: policy.minimum_receipts, + }, + ) + .map_err(|_| WitnessStoreError::Binding)?; + let mut secret = TemporarySecret::new(&fetch_secret)?; + let auxiliary = Zeroizing::new(random_bytes::<32>()?); + let signature = Secp256k1::new() + .sign_ecdsa_with_noncedata( + &Message::from_digest(unsigned.signing_digest()), + &secret.0, + &auxiliary, + ) + .serialize_compact(); + secret.erase(); + let manifest = + unsigned.authenticate(signature).map_err(|_| WitnessStoreError::Corrupt)?; + witnesses.push(WitnessKeys { policy: *policy, fetch_secret, manifest }); + } + Ok(Self { binding_digest: binding.digest(), encryption_secret, witnesses }) + } + + pub(super) fn manifest(&self, witness: PublicKey) -> Option<&SignedManifest> { + self.witnesses + .iter() + .find(|entry| entry.policy.witness == witness) + .map(|entry| &entry.manifest) + } + + pub(super) fn policies(&self) -> Vec { + self.witnesses.iter().map(|entry| entry.policy).collect() + } + + pub(super) fn encode(&self) -> Zeroizing> { + let manifests: Vec<_> = + self.witnesses.iter().map(|entry| entry.manifest.encode()).collect(); + // Reserve the exact size before copying secrets, avoiding freed intermediate allocations. + let capacity = 67 + manifests.iter().map(|manifest| 69 + manifest.len()).sum::(); + let mut bytes = Zeroizing::new(Vec::with_capacity(capacity)); + bytes.extend_from_slice(&SCHEMA_VERSION.to_be_bytes()); + bytes.extend_from_slice(&self.binding_digest); + bytes.extend_from_slice(self.encryption_secret.as_ref()); + bytes.push(self.witnesses.len() as u8); + for (entry, manifest) in self.witnesses.iter().zip(manifests) { + bytes.extend_from_slice(&entry.policy.witness.serialize()); + bytes.extend_from_slice(entry.fetch_secret.as_ref()); + bytes.extend_from_slice(&(manifest.len() as u32).to_be_bytes()); + bytes.extend_from_slice(&manifest); + } + bytes + } + + pub(super) fn decode( + binding: &WitnessStorageBinding, bytes: &[u8], + ) -> Result { + let mut reader = Reader(bytes); + if u16::from_be_bytes(reader.array()?) != SCHEMA_VERSION + || reader.array::<32>()? != binding.digest() + { + return Err(WitnessStoreError::Binding); + } + let encryption_secret = Zeroizing::new(reader.array()?); + let encryption_public_key = public_key(&encryption_secret)?; + let count = usize::from(reader.array::<1>()?[0]); + if count == 0 || count > MAX_WITNESSES { + return Err(WitnessStoreError::Capacity); + } + let mut witnesses: Vec = Vec::with_capacity(count); + for _ in 0..count { + let witness = + PublicKey::from_slice(reader.take(33)?).map_err(|_| WitnessStoreError::Corrupt)?; + let fetch_secret = Zeroizing::new(reader.array()?); + let fetch_public_key = public_key(&fetch_secret)?; + let length = u32::from_be_bytes(reader.array()?) as usize; + if length > lightning_ffor::witness::MAX_MESSAGE_LEN { + return Err(WitnessStoreError::Capacity); + } + let manifest = SignedManifest::decode(reader.take(length)?, binding.setup()) + .map_err(|_| WitnessStoreError::Corrupt)?; + let params = manifest.unsigned().parameters(); + if params.fetch_public_key != fetch_public_key + || params.encryption_public_key != encryption_public_key + || manifest.unsigned().activation_hash() != binding.activation_hash() + || witnesses.iter().any(|entry| { + entry.policy.witness >= witness + || entry.manifest.unsigned().parameters().mailbox_id == params.mailbox_id + || entry.manifest.unsigned().parameters().fetch_public_key + == fetch_public_key + }) || fetch_public_key == encryption_public_key + { + return Err(WitnessStoreError::Binding); + } + let policy = WitnessPolicy { + witness, + retention_until: params.retention_until, + minimum_receipts: params.minimum_receipts, + }; + witnesses.push(WitnessKeys { policy, fetch_secret, manifest }); + } + if !reader.0.is_empty() { + return Err(WitnessStoreError::Corrupt); + } + Ok(Self { binding_digest: binding.digest(), encryption_secret, witnesses }) + } +} + +pub(super) fn checked_policies( + policies: &[WitnessPolicy], +) -> Result, WitnessStoreError> { + if policies.is_empty() || policies.len() > MAX_WITNESSES { + return Err(WitnessStoreError::Capacity); + } + let mut policies = policies.to_vec(); + policies.sort_unstable_by_key(|policy| policy.witness); + if policies.windows(2).any(|pair| pair[0].witness == pair[1].witness) { + return Err(WitnessStoreError::Conflict); + } + Ok(policies) +} + +pub(super) fn random_bytes() -> Result<[u8; N], WitnessStoreError> { + let mut bytes = Zeroizing::new([0; N]); + OsRng.try_fill_bytes(bytes.as_mut()).map_err(|_| WitnessStoreError::Entropy)?; + Ok(*bytes) +} + +fn generate_secret() -> Result, WitnessStoreError> { + generate_secret_with(random_bytes::<32>) +} + +fn generate_secret_with( + mut draw: impl FnMut() -> Result<[u8; 32], WitnessStoreError>, +) -> Result, WitnessStoreError> { + for _ in 0..16 { + let bytes = Zeroizing::new(draw()?); + if TemporarySecret::new(&bytes).is_ok() { + return Ok(bytes); + } + } + Err(WitnessStoreError::Entropy) +} + +fn public_key(bytes: &[u8; 32]) -> Result { + let secret = TemporarySecret::new(bytes)?; + Ok(PublicKey::from_secret_key(&Secp256k1::new(), &secret.0)) +} + +struct TemporarySecret(SecretKey); +impl TemporarySecret { + fn new(bytes: &[u8; 32]) -> Result { + SecretKey::from_slice(bytes).map(Self).map_err(|_| WitnessStoreError::Corrupt) + } + fn erase(&mut self) { + self.0.non_secure_erase(); + } +} +impl Drop for TemporarySecret { + fn drop(&mut self) { + self.erase(); + } +} + +struct Reader<'a>(&'a [u8]); +impl<'a> Reader<'a> { + fn take(&mut self, length: usize) -> Result<&'a [u8], WitnessStoreError> { + let value = self.0.get(..length).ok_or(WitnessStoreError::Corrupt)?; + self.0 = &self.0[length..]; + Ok(value) + } + fn array(&mut self) -> Result<[u8; N], WitnessStoreError> { + self.take(N)?.try_into().map_err(|_| WitnessStoreError::Corrupt) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ffor_witness_store_secret_generation_is_bounded_and_fallible() { + assert_eq!( + generate_secret_with(|| Err(WitnessStoreError::Entropy)).unwrap_err(), + WitnessStoreError::Entropy + ); + let mut draws = 0; + assert_eq!( + generate_secret_with(|| { + draws += 1; + Ok([0; 32]) + }) + .unwrap_err(), + WitnessStoreError::Entropy + ); + assert_eq!(draws, 16); + let mut draws = 0; + let generated = generate_secret_with(|| { + draws += 1; + Ok(if draws == 1 { [255; 32] } else { [1; 32] }) + }) + .unwrap(); + assert_eq!(draws, 2); + assert_eq!(*generated, [1; 32]); + } +} diff --git a/src/ffor/witness_store/tests.rs b/src/ffor/witness_store/tests.rs new file mode 100644 index 0000000000..1c418b8277 --- /dev/null +++ b/src/ffor/witness_store/tests.rs @@ -0,0 +1,509 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use bitcoin::secp256k1::{Message as SecpMessage, PublicKey, Secp256k1, SecretKey}; +use bitcoin::{OutPoint, Txid}; +use lightning::io; +use lightning::util::persist::{KVStore, KVStoreSync}; +use lightning_ffor::setup::AuthenticatedSetup; +use lightning_ffor::transcript; +use lightning_ffor::wire::{Accept, Activate, Header, Init, Message, Payload}; +use proptest::prelude::*; + +use crate::io::test_utils::InMemoryStore; + +use super::*; + +struct TestStore { + inner: InMemoryStore, + writes: AtomicUsize, + write_failure: AtomicUsize, + read_failure: AtomicBool, +} + +impl TestStore { + fn new() -> Arc { + Arc::new(Self { + inner: InMemoryStore::new(), + writes: AtomicUsize::new(0), + write_failure: AtomicUsize::new(0), + read_failure: AtomicBool::new(false), + }) + } + fn bytes(&self, key: &str) -> Vec { + KVStoreSync::read(&self.inner, PRIMARY_NAMESPACE, SECONDARY_NAMESPACE, key).unwrap() + } + fn replace(&self, key: &str, bytes: Vec) { + KVStoreSync::write(&self.inner, PRIMARY_NAMESPACE, SECONDARY_NAMESPACE, key, bytes) + .unwrap(); + } +} + +impl KVStoreSync for TestStore { + fn read(&self, primary: &str, secondary: &str, key: &str) -> io::Result> { + if self.read_failure.load(Ordering::SeqCst) { + return Err(io::Error::new(io::ErrorKind::Other, "injected read failure")); + } + KVStoreSync::read(&self.inner, primary, secondary, key) + } + fn write(&self, primary: &str, secondary: &str, key: &str, bytes: Vec) -> io::Result<()> { + self.writes.fetch_add(1, Ordering::SeqCst); + let failure = self.write_failure.swap(0, Ordering::SeqCst); + if failure != 1 { + KVStoreSync::write(&self.inner, primary, secondary, key, bytes)?; + } + if failure != 0 { + Err(io::Error::new(io::ErrorKind::Other, "injected write failure")) + } else { + Ok(()) + } + } + fn remove(&self, primary: &str, secondary: &str, key: &str, lazy: bool) -> io::Result<()> { + KVStoreSync::remove(&self.inner, primary, secondary, key, lazy) + } + fn list(&self, primary: &str, secondary: &str) -> io::Result> { + KVStoreSync::list(&self.inner, primary, secondary) + } +} + +impl KVStore for TestStore { + fn read( + &self, p: &str, s: &str, k: &str, + ) -> Pin>> + Send>> { + let result = KVStoreSync::read(self, p, s, k); + Box::pin(async move { result }) + } + fn write( + &self, p: &str, s: &str, k: &str, bytes: Vec, + ) -> Pin> + Send>> { + let result = KVStoreSync::write(self, p, s, k, bytes); + Box::pin(async move { result }) + } + fn remove( + &self, p: &str, s: &str, k: &str, lazy: bool, + ) -> Pin> + Send>> { + let result = KVStoreSync::remove(self, p, s, k, lazy); + Box::pin(async move { result }) + } + fn list( + &self, p: &str, s: &str, + ) -> Pin>> + Send>> { + let result = KVStoreSync::list(self, p, s); + Box::pin(async move { result }) + } +} + +fn key(byte: u8) -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[byte; 32]).unwrap()) +} + +fn signed(header: Header, payload: Payload, signer: u8) -> Message { + let mut message = Message { header, payload, extensions: vec![], signature: [0; 64] }; + message.signature = Secp256k1::new() + .sign_ecdsa_with_noncedata( + &SecpMessage::from_digest(message.signature_digest().unwrap()), + &SecretKey::from_slice(&[signer; 32]).unwrap(), + &[91; 32], + ) + .serialize_compact(); + message +} + +pub(super) fn evidence( + epoch: u8, slots: usize, +) -> (WitnessStorageIdentity, AuthenticatedSetup, Message, Message) { + let header = Header { channel_id: [1; 32], epoch_id: [epoch; 32] }; + let init = signed( + header, + Payload::Init(Init { + budget_msat: 1_000_000 * slots as u64, + min_payment_msat: 1_000_000, + settlement_deadline: 100, + voucher_expiry: 2000, + fee_base_msat: 0, + fee_proportional_millionths: 0, + amounts_msat: vec![1_000_000; slots], + witness_peers: None, + hash_chain: false, + }), + 42, + ); + let accept = signed( + header, + Payload::Accept(Accept { + s_commitment_number: 3, + payment_hashes: (0..slots) + .map(|index| hash(&[&(index as u64).to_be_bytes()])) + .collect(), + s_htlc_id_base: 9, + amounts_msat: vec![1_000_000; slots], + init_hash: transcript::init_hash(&init.encode().unwrap()), + }), + 43, + ); + let setup = AuthenticatedSetup::new(&init, &accept, key(42), key(43)).unwrap(); + let activation = signed( + header, + Payload::Activate(Activate { + setup_hash: setup.setup_hash(), + book_hash: setup.book_hash(), + commit_hash: [9; 32], + epoch_start_height: 50, + }), + 42, + ); + let h_act = setup.validate_activation(&activation, [9; 32], 50).unwrap(); + let ack = signed(header, Payload::ActivateAck(h_act), 43); + let identity = WitnessStorageIdentity { + chain_hash: [4; 32], + actual_node: key(42), + settlement_node: key(43), + original_funding: OutPoint { txid: Txid::from_byte_array([8; 32]), vout: 0 }, + }; + (identity, setup, activation, ack) +} + +fn binding(epoch: u8) -> WitnessStorageBinding { + let (identity, setup, activation, ack) = evidence(epoch, 2); + WitnessStorageBinding::new(identity, setup, &activation, &ack).unwrap() +} + +fn policies() -> Vec { + vec![ + WitnessPolicy { witness: key(2), retention_until: 2144, minimum_receipts: 0 }, + WitnessPolicy { witness: key(3), retention_until: 2200, minimum_receipts: 2 }, + ] +} + +#[test] +fn ffor_witness_store_exact_manifests_survive_retry_and_restart() { + let backend = TestStore::new(); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let binding = binding(1); + let first = store.create(&binding, &policies()).unwrap(); + let saved = backend.bytes(&binding.storage_key()); + let mut reordered = policies(); + reordered.reverse(); + let repeated = store.create(&binding, &reordered).unwrap(); + let reopened = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let restored = reopened.load(&binding).unwrap(); + assert_eq!(backend.writes.load(Ordering::SeqCst), 2); + assert_eq!(backend.bytes(&binding.storage_key()), saved); + for policy in policies() { + let manifest = first.manifest(policy.witness).unwrap(); + assert_eq!(repeated.manifest(policy.witness).unwrap().encode(), manifest.encode()); + assert_eq!(restored.manifest(policy.witness).unwrap().encode(), manifest.encode()); + assert_eq!(manifest.unsigned().activation_hash(), binding.activation_hash()); + } + let mut changed = policies(); + changed[0].retention_until += 1; + assert_eq!(store.create(&binding, &changed).unwrap_err(), WitnessStoreError::Conflict); + assert_eq!(backend.bytes(&binding.storage_key()), saved); +} + +#[test] +fn ffor_witness_store_fresh_keys_are_scoped_to_epoch_and_witness() { + let store = WitnessSecretStore::open(&[5; 64], TestStore::new()).unwrap(); + let first = store.create(&binding(1), &policies()).unwrap(); + let next = store.create(&binding(2), &policies()).unwrap(); + let first_w = first.manifest(key(2)).unwrap().unsigned().parameters(); + let other_w = first.manifest(key(3)).unwrap().unsigned().parameters(); + let next_w = next.manifest(key(2)).unwrap().unsigned().parameters(); + assert_eq!(first_w.encryption_public_key, other_w.encryption_public_key); + assert_ne!(first_w.fetch_public_key, other_w.fetch_public_key); + assert_ne!(first_w.mailbox_id, other_w.mailbox_id); + assert_ne!(first_w.encryption_public_key, next_w.encryption_public_key); + assert_ne!(first_w.fetch_public_key, next_w.fetch_public_key); + assert_ne!(first_w.mailbox_id, next_w.mailbox_id); +} + +#[test] +fn ffor_witness_store_rejects_invalid_policy_before_any_storage_write() { + let backend = TestStore::new(); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let binding = binding(1); + assert_eq!(store.create(&binding, &[]).unwrap_err(), WitnessStoreError::Capacity); + let policy = policies()[0]; + assert_eq!(store.create(&binding, &[policy, policy]).unwrap_err(), WitnessStoreError::Conflict); + let short = WitnessPolicy { retention_until: 2143, ..policy }; + assert_eq!(store.create(&binding, &[short]).unwrap_err(), WitnessStoreError::Binding); + assert_eq!(backend.writes.load(Ordering::SeqCst), 0); + assert!(store.state.lock().unwrap().records.is_empty()); +} + +#[test] +fn ffor_witness_store_envelope_hides_raw_secrets_and_uses_fresh_aead_nonces() { + let binding = binding(1); + let record = + StoredWitnessEpoch::generate(&binding, &record::checked_policies(&policies()).unwrap()) + .unwrap(); + let wrapping = WrappingKey::derive(&[5; 64]); + let first = wrapping.seal(&binding, &record).unwrap(); + let second = wrapping.seal(&binding, &record).unwrap(); + assert_ne!(first, second); + let raw = record.encode(); + for secret in [&raw[34..66], &raw[100..132]] { + assert!(!first.windows(32).any(|window| window == secret)); + assert!(!second.windows(32).any(|window| window == secret)); + } + assert_eq!(&*wrapping.open(&binding, &first).unwrap().encode(), &*raw); + assert_eq!(&*wrapping.open(&binding, &second).unwrap().encode(), &*raw); +} + +#[test] +fn ffor_witness_store_uncertain_writes_recover_only_the_exact_candidate() { + for failure in [1, 2] { + let backend = TestStore::new(); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let binding = binding(1); + backend.write_failure.store(failure, Ordering::SeqCst); + assert_eq!(store.create(&binding, &policies()).unwrap_err(), WitnessStoreError::Storage); + let candidate = store.state.lock().unwrap().uncertain.as_ref().unwrap().bytes.clone(); + assert_eq!(store.load(&binding).unwrap_err(), WitnessStoreError::Uncertain); + assert_eq!(store.create(&binding, &policies()).unwrap_err(), WitnessStoreError::Uncertain); + backend.read_failure.store(true, Ordering::SeqCst); + assert_eq!(store.recover_write(), Err(WitnessStoreError::Storage)); + backend.read_failure.store(false, Ordering::SeqCst); + store.recover_write().unwrap(); + assert_eq!(backend.bytes(&binding.storage_key()), candidate); + assert!(store.load(&binding).is_ok()); + assert_eq!(backend.writes.load(Ordering::SeqCst), 2); + let reopened = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + assert!(reopened.load(&binding).is_ok()); + } +} + +#[test] +fn ffor_witness_store_conflicting_recovery_stays_blocked() { + let backend = TestStore::new(); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + backend.write_failure.store(2, Ordering::SeqCst); + let binding = binding(1); + assert!(store.create(&binding, &policies()).is_err()); + let original = backend.bytes(&binding.storage_key()); + let mut wrong = original.clone(); + wrong[50] ^= 1; + backend.replace(&binding.storage_key(), wrong.clone()); + assert_eq!(store.recover_write(), Err(WitnessStoreError::Conflict)); + assert_eq!(store.load(&binding).unwrap_err(), WitnessStoreError::Uncertain); + assert_eq!(backend.bytes(&binding.storage_key()), wrong); + backend.replace(&binding.storage_key(), original); + store.recover_write().unwrap(); +} + +#[test] +fn ffor_witness_store_restart_recovers_a_write_whose_success_reply_was_lost() { + let backend = TestStore::new(); + let binding = binding(1); + let candidate; + { + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + backend.write_failure.store(2, Ordering::SeqCst); + assert_eq!(store.create(&binding, &policies()).unwrap_err(), WitnessStoreError::Storage); + candidate = store.state.lock().unwrap().uncertain.as_ref().unwrap().bytes.clone(); + } + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + assert!(store.load(&binding).is_ok()); + assert!(store.create(&binding, &policies()).is_ok()); + assert_eq!(backend.bytes(&binding.storage_key()), candidate); + assert_eq!(backend.writes.load(Ordering::SeqCst), 2); +} + +#[test] +fn ffor_witness_store_visible_bytes_need_a_successful_durability_retry() { + let backend = TestStore::new(); + let binding = binding(1); + let candidate; + { + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + backend.write_failure.store(2, Ordering::SeqCst); + assert!(store.create(&binding, &policies()).is_err()); + candidate = backend.bytes(&binding.storage_key()); + backend.write_failure.store(2, Ordering::SeqCst); + assert_eq!(store.recover_write(), Err(WitnessStoreError::Storage)); + assert_eq!(store.load(&binding).unwrap_err(), WitnessStoreError::Uncertain); + } + let restored = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + backend.write_failure.store(2, Ordering::SeqCst); + assert_eq!(restored.load(&binding).unwrap_err(), WitnessStoreError::Storage); + assert_eq!(restored.load(&binding).unwrap_err(), WitnessStoreError::Uncertain); + assert_eq!(backend.bytes(&binding.storage_key()), candidate); + restored.recover_write().unwrap(); + assert!(restored.load(&binding).is_ok()); + assert_eq!(backend.bytes(&binding.storage_key()), candidate); + assert_eq!(backend.writes.load(Ordering::SeqCst), 4); +} + +#[test] +fn ffor_witness_store_binding_requires_signed_roles_and_exact_historical_terms() { + let (mut identity, setup, activate, ack) = evidence(1, 2); + identity.actual_node = key(44); + assert!(WitnessStorageBinding::new(identity, setup, &activate, &ack).is_err()); + let (identity, setup, mut activate, ack) = evidence(1, 2); + activate.signature[0] ^= 1; + assert!(WitnessStorageBinding::new(identity, setup, &activate, &ack).is_err()); + let (identity, setup, activate, mut ack) = evidence(1, 2); + ack.header.epoch_id[0] ^= 1; + assert!(WitnessStorageBinding::new(identity, setup, &activate, &ack).is_err()); + let (identity, setup, activate, _) = evidence(1, 2); + let ack = signed(setup.header(), Payload::ActivateAck([0; 32]), 43); + assert!(WitnessStorageBinding::new(identity, setup, &activate, &ack).is_err()); + let backend = TestStore::new(); + let store = WitnessSecretStore::open(&[5; 64], backend).unwrap(); + store.create(&binding(1), &policies()).unwrap(); + let (mut identity, setup, activate, ack) = evidence(1, 2); + identity.original_funding.vout = 1; + let changed = WitnessStorageBinding::new(identity, setup, &activate, &ack).unwrap(); + assert_eq!(changed.storage_key(), binding(1).storage_key()); + assert_eq!(store.load(&changed).unwrap_err(), WitnessStoreError::Binding); +} + +#[test] +fn ffor_witness_store_corruption_missing_and_wrong_seed_never_regenerate() { + let backend = TestStore::new(); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let binding = binding(1); + store.create(&binding, &policies()).unwrap(); + let original = backend.bytes(&binding.storage_key()); + let wrong_seed = WitnessSecretStore::open(&[6; 64], backend.clone()).unwrap(); + assert_eq!(wrong_seed.load(&binding).unwrap_err(), WitnessStoreError::Corrupt); + for index in [0, 4, 8, 50, original.len() - 1] { + let mut corrupted = original.clone(); + corrupted[index] ^= 1; + backend.replace(&binding.storage_key(), corrupted.clone()); + assert_eq!(store.load(&binding).unwrap_err(), WitnessStoreError::Conflict); + let restored = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + assert!(restored.load(&binding).is_err()); + assert!(restored.create(&binding, &policies()).is_err()); + assert_eq!(backend.bytes(&binding.storage_key()), corrupted); + } + KVStoreSync::remove( + &*backend, + PRIMARY_NAMESPACE, + SECONDARY_NAMESPACE, + &binding.storage_key(), + false, + ) + .unwrap(); + assert_eq!(store.load(&binding).unwrap_err(), WitnessStoreError::Missing); + assert_eq!(store.create(&binding, &policies()).unwrap_err(), WitnessStoreError::Missing); + assert_eq!(backend.writes.load(Ordering::SeqCst), 1); +} + +#[test] +fn ffor_witness_store_plaintext_parser_checks_every_key_and_manifest() { + let binding = binding(1); + let record = + StoredWitnessEpoch::generate(&binding, &record::checked_policies(&policies()).unwrap()) + .unwrap(); + let encoded = record.encode(); + assert!(StoredWitnessEpoch::decode(&binding, &encoded).is_ok()); + for length in 0..encoded.len() { + assert!(StoredWitnessEpoch::decode(&binding, &encoded[..length]).is_err()); + } + for offset in [0, 2, 34, 66, 100, 132, 136, encoded.len() - 1] { + let mut damaged = encoded.clone(); + damaged[offset] ^= 1; + assert!(StoredWitnessEpoch::decode(&binding, &damaged).is_err(), "offset {offset}"); + } + let mut invalid_witness = encoded.clone(); + invalid_witness[67] = 0; + assert!(StoredWitnessEpoch::decode(&binding, &invalid_witness).is_err()); + let mut trailing = encoded.clone(); + trailing.push(0); + assert!(StoredWitnessEpoch::decode(&binding, &trailing).is_err()); + let secret_debug = format!("{:?}", &encoded[34..66]); + for debug in [format!("{record:?}"), format!("{record:#?}")] { + assert!(debug.contains("[redacted]")); + assert!(!debug.contains(&secret_debug)); + } +} + +#[test] +fn ffor_witness_store_limits_refuse_without_eviction_or_partial_write() { + let backend = TestStore::new(); + for index in 0..16 { + backend.replace(&format!("{index:064x}"), vec![9; MAX_RECORD_BYTES]); + } + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + assert_eq!(store.create(&binding(1), &policies()).unwrap_err(), WitnessStoreError::Capacity); + assert_eq!(backend.writes.load(Ordering::SeqCst), 0); + backend.replace(&format!("{:064x}", 16), vec![9]); + assert_eq!( + WitnessSecretStore::open(&[5; 64], backend).unwrap_err(), + WitnessStoreError::Capacity + ); + let backend = TestStore::new(); + for index in 0..64 { + backend.replace(&format!("{index:064x}"), vec![9]); + } + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + assert_eq!(store.create(&binding(1), &policies()).unwrap_err(), WitnessStoreError::Capacity); + backend.replace(&format!("{:064x}", 64), vec![9]); + assert_eq!( + WitnessSecretStore::open(&[5; 64], backend).unwrap_err(), + WitnessStoreError::Capacity + ); + let backend = TestStore::new(); + backend.replace(&format!("{:064x}", 0), vec![0; MAX_RECORD_BYTES + 1]); + assert_eq!( + WitnessSecretStore::open(&[5; 64], backend).unwrap_err(), + WitnessStoreError::Capacity + ); +} + +#[test] +fn ffor_witness_store_maximum_book_with_four_witnesses_fits_supported_bound() { + let (identity, setup, activate, ack) = evidence(1, 483); + let binding = WitnessStorageBinding::new(identity, setup, &activate, &ack).unwrap(); + let mut policy = policies(); + policy.push(WitnessPolicy { witness: key(4), ..policy[0] }); + policy.push(WitnessPolicy { witness: key(5), ..policy[0] }); + let backend = TestStore::new(); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + assert!(store.create(&binding, &policy).is_ok()); + assert!(backend.bytes(&binding.storage_key()).len() < MAX_RECORD_BYTES); + policy.push(WitnessPolicy { witness: key(6), ..policy[0] }); + assert_eq!(store.create(&binding, &policy).unwrap_err(), WitnessStoreError::Capacity); +} + +#[test] +fn ffor_witness_store_concurrent_create_shares_one_durable_candidate() { + let backend = TestStore::new(); + let store = Arc::new(WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap()); + let binding = binding(1); + let tasks: Vec<_> = (0..8) + .map(|_| { + let store = store.clone(); + let binding = binding.clone(); + std::thread::spawn(move || { + store.create(&binding, &policies()).unwrap().manifest(key(2)).unwrap().encode() + }) + }) + .collect(); + let manifests: Vec<_> = tasks.into_iter().map(|task| task.join().unwrap()).collect(); + assert!(manifests.iter().all(|manifest| manifest == &manifests[0])); + assert_eq!(backend.writes.load(Ordering::SeqCst), 1); +} + +proptest! { + #[test] + fn ffor_witness_store_bounded_plaintext_never_panics(bytes in prop::collection::vec(any::(), 0..2048)) { + let binding = binding(1); + if let Ok(record) = StoredWitnessEpoch::decode(&binding, &bytes) { + prop_assert_eq!(&*record.encode(), &bytes); + } + } + + #[test] + fn ffor_witness_store_ciphertext_mutations_fail_closed(offset in any::(), bit in 0u8..8) { + let binding = binding(1); + let record = StoredWitnessEpoch::generate(&binding, &record::checked_policies(&policies()).unwrap()).unwrap(); + let wrapping = WrappingKey::derive(&[5; 64]); + let mut bytes = wrapping.seal(&binding, &record).unwrap(); + let index = offset % bytes.len(); + bytes[index] ^= 1 << bit; + prop_assert!(wrapping.open(&binding, &bytes).is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 5b51005963..865f491166 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -87,6 +87,7 @@ mod error; mod event; mod fee_estimator; mod ffi; +mod ffor; mod gossip; pub mod graph; mod hex_utils; From b5fdc090f23a38ef327db3c2978d8fda86bb50cb Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 21 Sep 2026 00:21:26 -0400 Subject: [PATCH 10/23] feat(ffor): add bounded sends and protected witness operations --- FFOR.md | 39 +++- src/ffor/witness_store.rs | 4 +- src/ffor/witness_store/record.rs | 15 +- src/ffor/witness_store/record/key_use.rs | 92 ++++++++ .../witness_store/record/key_use/README.md | 13 ++ .../witness_store/record/key_use/fixtures.txt | 27 +++ .../witness_store/record/key_use/tests.rs | 210 ++++++++++++++++++ src/ffor/witness_store/tests.rs | 39 ++++ src/message_handler.rs | 17 +- src/message_handler/ffor.rs | 117 ++++++++-- src/message_handler/ffor/tests.rs | 191 +++++++++++++++- src/message_handler/tests.rs | 43 ++++ 12 files changed, 768 insertions(+), 39 deletions(-) create mode 100644 src/ffor/witness_store/record/key_use.rs create mode 100644 src/ffor/witness_store/record/key_use/README.md create mode 100644 src/ffor/witness_store/record/key_use/fixtures.txt create mode 100644 src/ffor/witness_store/record/key_use/tests.rs diff --git a/FFOR.md b/FFOR.md index d45589c409..bd58676f4e 100644 --- a/FFOR.md +++ b/FFOR.md @@ -22,13 +22,15 @@ and ordinary invoice behavior remain unchanged. The mobile draft providers expos no production capability until authenticated activation, durable channel ownership and recovery are implemented together. -## Private receive transport +## Private bounded transport Node's custom-message composition retains the existing LSPS reader, outbox, features and peer callbacks. An optional private FFOR receiver can parse the seven supported signed lifecycle types and witness acknowledgement type 55057 through the shared -canonical codecs. The production builder leaves this receiver disabled. This slice -has no FFOR sender, feature advertisement, protocol transition or public setting. +canonical codecs. Its bounded outbox accepts exact wire messages from a future +native-authorized release callback. The production builder leaves this transport +disabled. This slice has no operational sender, feature advertisement, protocol +transition or public setting. The transport accepts peer identity only from PeerManager's authenticated callback. Each successful connection gets a distinct opaque token. Disconnect or replacement @@ -38,7 +40,7 @@ consumer must recheck connection ownership under the actual native channel autho before applying a transition; a successful parse or point-in-time token check grants no such authority. Claimed wire identities cannot replace the authenticated peer. -Frames are limited to 65,535 bytes including the message type. The receive queue +Frames are limited to 65,535 bytes including the message type. The combined queues tracks at most 64 peers, eight frames and 256 KiB per peer, and 128 frames and 1 MiB globally. Those byte limits cover retained wire payloads; bounded peer and frame metadata adds fixed overhead. Queue refusals preserve existing work and do not @@ -50,9 +52,20 @@ custom reader; unknown types keep the existing ignore behavior. Witness service requests and receipt retrieval are outside this receiver slice. +An outbound enqueue checks the original connection token and shared capacity in one +critical section. Backpressure preserves all queued messages, and an exact retry +already in the outbox consumes no additional capacity. Queue acceptance is not +network delivery. Disconnect clears both queues, and old tokens cannot enqueue on a +replacement connection. The custom-message drain preserves each producer's FIFO +ordering alongside LSPS. The pinned PeerManager holds its peer-map read lock through +draining, selecting the same peer and encrypting into its socket queue; disconnect +and replacement require the write lock. Native code remains responsible for the +persistence and phase checks before enqueueing and for deciding whether any later +replay is legal. No transport mutex may span a native manager call. + Run `cargo test --lib message_handler` for exact public fixture routing, LSPS coexistence, disabled behavior, malformed frame bounds, connection replacement, -concurrent disconnect and property-based queue accounting checks. Fixture provenance +concurrent disconnect, outbound backpressure and property-based queue accounting checks. Fixture provenance is recorded in `src/message_handler/test_data.json`. Parser fuzzing remains in the shared `lightning-ffor` wire and witness targets; Node also runs arbitrary-byte property checks at the length-limited reader boundary. @@ -94,13 +107,19 @@ rollback to an older valid backup or protect against wallet-seed compromise. KVS has no cross-process compare-and-swap, so concurrent independent owners are not supported. A future native registration must distinguish a new epoch from an existing epoch whose entire sidecar is missing; `load` never regenerates secrets. -Fetch nonce allocation and witness acknowledgement persistence are separate runtime -work and are not supplied by this store. - -Run `cargo test --lib ffor_witness_store` for exact retries and restart, failed writes +Protected key operations sign fetch requests and decrypt witness records without +exporting either private key. Each fetch call draws a fresh operating-system request +identity, 256-bit nonce and signature entropy, including retries after a lost response +or restart. The witness still enforces replay refusal. Decryption authenticates the +retained witness and manifest before native AEAD and body checks. These helpers do not +correlate a response connection, persist a receipt, change a channel or credit a +payment. Witness acknowledgement persistence and runtime recovery remain separate work. + +Run `cargo test --lib ffor_witness` for exact retries and restart, failed writes with readable bytes, corruption, wrong seeds and bindings, key separation, capacity, concurrent creation, fallible entropy, truncated records and property-based mutation -checks. These checks do not establish complete branch coverage or process-crash +checks, fresh fetch authorization after restart and protected decryption of pinned +Beignet records. These checks do not establish complete branch coverage or process-crash interoperability. ## Required runtime integration diff --git a/src/ffor/witness_store.rs b/src/ffor/witness_store.rs index 24c84c0cb6..2aacc5597e 100644 --- a/src/ffor/witness_store.rs +++ b/src/ffor/witness_store.rs @@ -3,7 +3,7 @@ //! One exclusive owner serializes all operations for this namespace. KVStore has no cross-process //! compare-and-swap, so concurrent owners are unsupported. Store success is the durability contract; //! authenticated encryption does not detect rollback to an older valid store. Opaque native history -//! supplies record bindings. Live native registration, fetch nonce allocation, transport, witness +//! supplies record bindings. Live native registration, fetch response correlation, transport, witness //! acknowledgement handling and deletion are deliberately absent. //! A future native registration must distinguish a new epoch from a missing sidecar after restore; //! `load` never recreates secrets, and `create` is only for explicitly new registration material. @@ -28,6 +28,8 @@ pub(super) use binding::WitnessStorageBinding; #[cfg(test)] use binding::WitnessStorageIdentity; use envelope::WrappingKey; +#[cfg(test)] +use record::WitnessKeyUseError; pub(super) use record::{StoredWitnessEpoch, WitnessPolicy}; const PRIMARY_NAMESPACE: &str = "ffor_witness"; diff --git a/src/ffor/witness_store/record.rs b/src/ffor/witness_store/record.rs index f0fad5c4c9..d907e799c1 100644 --- a/src/ffor/witness_store/record.rs +++ b/src/ffor/witness_store/record.rs @@ -1,13 +1,26 @@ use std::fmt; use bitcoin::secp256k1::{Message, PublicKey, Secp256k1, SecretKey}; -use lightning_ffor::witness::{ManifestParameters, SignedManifest, UnsignedManifest}; +use lightning::ln::ffor::FFORWitnessDecryptionError; +use lightning_ffor::witness::{ManifestParameters, SignedManifest, UnsignedManifest, WitnessError}; use rand::rngs::OsRng; use rand::TryRngCore; use zeroize::Zeroizing; use super::{WitnessStorageBinding, WitnessStoreError, MAX_WITNESSES, SCHEMA_VERSION}; +mod key_use; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum WitnessKeyUseError { + UnknownWitness, + Entropy, + KeyMaterial, + Request(WitnessError), + Record(WitnessError), + Decryption(FFORWitnessDecryptionError), +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct WitnessPolicy { pub(super) witness: PublicKey, diff --git a/src/ffor/witness_store/record/key_use.rs b/src/ffor/witness_store/record/key_use.rs new file mode 100644 index 0000000000..e305f9cfd5 --- /dev/null +++ b/src/ffor/witness_store/record/key_use.rs @@ -0,0 +1,92 @@ +//! Operations using retained witness keys without exposing them to the runtime. + +use bitcoin::secp256k1::{Message, PublicKey, Secp256k1}; +use lightning::ln::ffor::{decrypt_ffor_witness_record, FFORWitnessReceipt}; +use lightning_ffor::witness::{ + EncryptedRecord, FetchParameters, SignedFetch, UnsignedFetch, WitnessError, +}; +use zeroize::Zeroizing; + +use super::{ + random_bytes, StoredWitnessEpoch, TemporarySecret, WitnessKeyUseError, WitnessKeys, + WitnessStoreError, +}; + +impl StoredWitnessEpoch { + /// Signs a fresh request for a retained mailbox, including after a lost reply or restart. + /// Every call draws a new 256-bit nonce and request identity from operating-system entropy. + /// The runtime must correlate responses with the authenticated connection and must never resend + /// an earlier request after ambiguous delivery. The witness still enforces nonce replay refusal. + /// This operation neither sends a request nor establishes current native activation authority. + pub(in crate::ffor::witness_store) fn prepare_fetch( + &self, witness: PublicKey, after_slot: Option, + ) -> Result { + self.prepare_fetch_with(witness, after_slot, random_bytes::<80>) + } + + fn prepare_fetch_with( + &self, witness: PublicKey, after_slot: Option, + draw: impl FnOnce() -> Result<[u8; 80], WitnessStoreError>, + ) -> Result { + let keys = self.witness_keys(witness)?; + let manifest = keys.manifest.unsigned(); + let count = (manifest.canonical_book().len() - 36) / 58; + if usize::from(after_slot.unwrap_or(0)) > count { + return Err(WitnessKeyUseError::Request(WitnessError::Pagination)); + } + let entropy = Zeroizing::new(draw().map_err(|_| WitnessKeyUseError::Entropy)?); + let mut request_id = [0; 16]; + let mut nonce = [0; 32]; + let mut auxiliary = Zeroizing::new([0; 32]); + request_id.copy_from_slice(&entropy[..16]); + nonce.copy_from_slice(&entropy[16..48]); + auxiliary.copy_from_slice(&entropy[48..]); + let parameters = manifest.parameters(); + let unsigned = UnsignedFetch::new(FetchParameters { + request_id, + mailbox_id: parameters.mailbox_id, + nonce, + after_slot, + extensions: Vec::new(), + }) + .map_err(WitnessKeyUseError::Request)?; + let key = TemporarySecret::new(&keys.fetch_secret) + .map_err(|_| WitnessKeyUseError::KeyMaterial)?; + let signature = Secp256k1::new() + .sign_ecdsa_with_noncedata( + &Message::from_digest(unsigned.signing_digest()), + &key.0, + &auxiliary, + ) + .serialize_compact(); + unsigned + .authenticate(signature, parameters.fetch_public_key) + .map_err(WitnessKeyUseError::Request) + } + + /// Authenticates and decrypts a record using this epoch's retained manifest and private key. + /// Historical recovery remains valid after admission expiry. The result proves knowledge of a + /// voucher preimage, not successful receipt storage, channel reconciliation or payment credit. + /// This does not correlate a Noise response or establish a current native phase. + pub(in crate::ffor::witness_store) fn decrypt_record( + &self, witness: PublicKey, record: EncryptedRecord, + ) -> Result { + let keys = self.witness_keys(witness)?; + let authenticated = + record.authenticate(&keys.manifest, witness).map_err(WitnessKeyUseError::Record)?; + let key = TemporarySecret::new(&self.encryption_secret) + .map_err(|_| WitnessKeyUseError::KeyMaterial)?; + decrypt_ffor_witness_record(&authenticated, &keys.manifest, &key.0) + .map_err(WitnessKeyUseError::Decryption) + } + + fn witness_keys(&self, witness: PublicKey) -> Result<&WitnessKeys, WitnessKeyUseError> { + self.witnesses + .iter() + .find(|entry| entry.policy.witness == witness) + .ok_or(WitnessKeyUseError::UnknownWitness) + } +} + +#[cfg(test)] +mod tests; diff --git a/src/ffor/witness_store/record/key_use/README.md b/src/ffor/witness_store/record/key_use/README.md new file mode 100644 index 0000000000..3043175fd1 --- /dev/null +++ b/src/ffor/witness_store/record/key_use/README.md @@ -0,0 +1,13 @@ +# Witness key-use fixtures + +`fixtures.txt` preserves the `scenario`, `init`, `accept`, `manifest`, `record` and +`body` lines from the native witness fixtures at rust-lightning commit +`8478895388b656b4fa674d1ede4c48f33a9688b4`. Its exporter is +`lightning-ffor/tests/data/generate_native_witness_fixtures.py` in +[the native draft](https://github.com/synonymdev/rust-lightning/pull/4). + +These are public deterministic Beignet vectors, pinned to Beignet revision +`8aee31d18e596fe49a0d195b325a6e757d7a009b`, covering scenarios D.1 and D.2. +Their fetch, witness and encryption test keys are respectively the repeated-byte +values 42, 43 and 44. They are not wallet data and must never be used operationally. +The store generates fresh production keys from operating-system entropy. diff --git a/src/ffor/witness_store/record/key_use/fixtures.txt b/src/ffor/witness_store/record/key_use/fixtures.txt new file mode 100644 index 0000000000..2e193a740e --- /dev/null +++ b/src/ffor/witness_store/record/key_use/fixtures.txt @@ -0,0 +1,27 @@ +scenario=D.1 +init=d6d9bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff150400000000000f4240000100000000000854d0000c3110000c3500000003e80000138800000000000000000000090800000000000f4240b650a5af1658bafe768dc0d5b07af4556ee5776b010bd4da19cd64aefd40f1a0184923774f95b2d3d9e024283db263075e8ed6f793ba4553b9960e638fd90933 +accept=d6dbbef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff15000000000000002a0120a0a62adc5092cd3c202aaea0900b5826c242194c5f9984070e833e58b273eda507080000000000000000090800000000000f42400b20343331edc28e500ea03cbce26983583e66fff33dc9f096a3d6924b8239f68eb898fbd933af0a181f69b015f155a48608d1295327607d85bdb03a9598b54dc58f6ac01b0421309cebbbaa596e62113de7e6fe2aafaccc281f535eda535525b3ba +manifest=01010b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0bba34be8afe89e4457543a6e7279f86054f1d6e9038234b786b740e9c905c001f723024696ad20b4758b228284620d18fd30750b7051cc64efca23a1417cf1321000c0df0580a4a958e8131b32452741e51d724e0f231cb518896c743fa59de236ac6e716035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c028f5304e2373e56ee7d774cb89e9f1afecf0ee7e3e3757f189908f069daa36c60000c359000005efc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff15040100010001a0a62adc5092cd3c202aaea0900b5826c242194c5f9984070e833e58b273eda500000000000f4240000c3500000c3110000000000000000068c460b699c82f1694ff36a6e872b82c566d529e13c49f63dd6db9217279044f6e5b46f06c9abf252aec4701b251aa871de7c4dfe48d0eb3b2c84e96a2fb7753 +record=01010b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0a3db013b83ab85aa5bb26de4e4043d567569b9c370faed861aba258c2e8883c0001580a4a958e8131b32452741e51d724e0f231cb518896c743fa59de236ac6e716a599edf07d16acfacd1128c82f1741bba098d480b830f04f352e822c5183da8b02bb58b5feca505c74edc000d8282fc556e51a1024fc8e7d7e56c6f887c5c8d5f2028f5304e2373e56ee7d774cb89e9f1afecf0ee7e3e3757f189908f069daa36c60000c0dfa0145fa5d649d54851f62b46e76fa8f1eef468d60403bdb470936026f5c266b4d0bde6fc54e92010c9f45d01996c45c594f6979a3dcbf26c3ee2910828726358fa965d429fd860c6658263840e4f21a18dc0171d843ab61a4f1518ad8ffb5295d8600bf034679787536ac4c5d9be764f215cbc4dcf4268533d164ea96890e0b23769268aba93176d0f31c15fab5403c4719be5750906967cce9e3d9519859a9e300e915b0543993f30d1149542bf81df6745e8575f9f853f3d1c65915c9f9096b6adce1fbcd1991567dd0247d2ff86a6b6b41847e514213187cbc99157b2f52b0d17d5f07c2253aa9426e4e6b819c85ef7a14d5dec0469d4cb34d7f13ce1f442c90677c3216bec593e948e1f798ed9edadc419e027c0b004f129d7b4edfd5640fe1ed00 +body=fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff1500018ae4a313bd01bcfcce12b797ed20603ed56b0f0b9194e7c000e5cbd6fe376517a0a62adc5092cd3c202aaea0900b5826c242194c5f9984070e833e58b273eda500000000000f4240000c3500000c311000000000000f59b000000000000f4240000c11d8000000006553f100 + +scenario=D.2 +init=d6d9bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884892436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d60400000000030e9ca2000300000000000854d0000c3110000c3500000003e80000138800000000000000000000091800000000000f2ad000000000000855ca0000000002f71c081950e84703f6ab745d5e93ed17fadd4cb4750b1b4bc04b01b18dd4051b0fc58114f5b24aa52b2a1c5c391d98621ce6a37512049c6dac1cbf048f61cb52868547 +accept=d6dbbef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884892436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d6000000000000002a016024e4e3ab2a0cbbe957d5dc41c688721d956cf7babe8f304ad24b4589787bd993a7334c46d83bba22364ed8b863d3052fe24f62b973072776b537aaf707132596934cbfbc3fdfea5fc984e1270f06ebb219beb18ee02e8441acafa05ec7d928f607080000000000000007091800000000000f2ad000000000000855ca0000000002f71c080b2019644c62830070c3d7ca623b582f274e6e94669d4938e54262a1c6e6e6ed179105ec065a191d90cbb8a6adbeb0d1fd2838be33fa511588e2a3ccb83ce7313a0a1946d43ead676cd1be4a6780b5dd6feb03dc71f0c8d98056ce61304ab243d91b +manifest=01010c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c8465b03bd1ab3001129cd91fffb6e5129460cf82fb396cad2ebbb0ede8916396e7e996cb809a52736589d78d22b1a7c2a76b2ba07a8ac1f957aabc2651b0b9c5000c0df01d561fb397d1ac599cf6876a4f390d6bd81de4cd425b30383b14a5de4b4b61d4035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c028f5304e2373e56ee7d774cb89e9f1afecf0ee7e3e3757f189908f069daa36c60000c35900000d22436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d604010003000124e4e3ab2a0cbbe957d5dc41c688721d956cf7babe8f304ad24b4589787bd99300000000000f2ad0000c3500000c311000000000000000070002a7334c46d83bba22364ed8b863d3052fe24f62b973072776b537aaf70713259600000000000855ca000c3500000c311000000000000000080003934cbfbc3fdfea5fc984e1270f06ebb219beb18ee02e8441acafa05ec7d928f60000000002f71c08000c3500000c31100000000000000009ab2800d9b36f24db717e37c55b739581b3697a6b298520240117cbc6f71130dd07dbdd77a581b1804cd3db0c1f656aa75ea6326f08fafeef1a4460f70f0a84a0 +record=01010c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0cbcd699ce25eda581b77eec605b501c6935c5cc5b80941e235708371c6d1d663a00011d561fb397d1ac599cf6876a4f390d6bd81de4cd425b30383b14a5de4b4b61d4de619aec79901ee0ab8d6ccded96ec853812587f785556a69cc0e4bcffef14a302bb58b5feca505c74edc000d8282fc556e51a1024fc8e7d7e56c6f887c5c8d5f2028f5304e2373e56ee7d774cb89e9f1afecf0ee7e3e3757f189908f069daa36c60000c0dfa010cc903269e7b9bd12266f09f0e4afbeb4bff0005ba1b8cdb6bd66ab10edaeb4a8e7800231692024b2409a747d54e7317cb6152431e89a0925599b22e60979fe77c745510f735ad04426e8bf364d9c37fde7fcb16a964163bbd69b0915603137b00bf02d7b6a5bda48673403a0fffd45ceb5bf35d5ceb92271aecf5d4fcef2c644552d7d01331daa63ebf4c6af455538301daed14c25424e9bb57c1eace0e2448e00ab055fef3f2cd9541117bbd57494d9b86efaf87af81aedd461fed72c0087ede06ff8ac7b8237c0d767bb18f53bfd80f26d4f9e2cca6bd1f3d509da2a0676233ac0f31b0a008d9552951ac103a42f0baddc292ad52460c2898dbf71ecc4ef516df35228aec3b34655168122aa3c3bc5f1aae5abaea77541f2b66cfd6e63c5a3a00 +body=2436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d600019ac2111ef6621b1e5a5e2045b1811e3fbed11db86124fe4b7d6f0589fe69765e24e4e3ab2a0cbbe957d5dc41c688721d956cf7babe8f304ad24b4589787bd99300000000000f2ad0000c3500000c311000000000000f424000000000000f2ad0000c11d8000000006553f100 + +scenario=D.2 +init=d6d9bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884892436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d60400000000030e9ca2000300000000000854d0000c3110000c3500000003e80000138800000000000000000000091800000000000f2ad000000000000855ca0000000002f71c081950e84703f6ab745d5e93ed17fadd4cb4750b1b4bc04b01b18dd4051b0fc58114f5b24aa52b2a1c5c391d98621ce6a37512049c6dac1cbf048f61cb52868547 +accept=d6dbbef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884892436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d6000000000000002a016024e4e3ab2a0cbbe957d5dc41c688721d956cf7babe8f304ad24b4589787bd993a7334c46d83bba22364ed8b863d3052fe24f62b973072776b537aaf707132596934cbfbc3fdfea5fc984e1270f06ebb219beb18ee02e8441acafa05ec7d928f607080000000000000007091800000000000f2ad000000000000855ca0000000002f71c080b2019644c62830070c3d7ca623b582f274e6e94669d4938e54262a1c6e6e6ed179105ec065a191d90cbb8a6adbeb0d1fd2838be33fa511588e2a3ccb83ce7313a0a1946d43ead676cd1be4a6780b5dd6feb03dc71f0c8d98056ce61304ab243d91b +manifest=01010c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c8465b03bd1ab3001129cd91fffb6e5129460cf82fb396cad2ebbb0ede8916396e7e996cb809a52736589d78d22b1a7c2a76b2ba07a8ac1f957aabc2651b0b9c5000c0df01d561fb397d1ac599cf6876a4f390d6bd81de4cd425b30383b14a5de4b4b61d4035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c028f5304e2373e56ee7d774cb89e9f1afecf0ee7e3e3757f189908f069daa36c60000c35900000d22436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d604010003000124e4e3ab2a0cbbe957d5dc41c688721d956cf7babe8f304ad24b4589787bd99300000000000f2ad0000c3500000c311000000000000000070002a7334c46d83bba22364ed8b863d3052fe24f62b973072776b537aaf70713259600000000000855ca000c3500000c311000000000000000080003934cbfbc3fdfea5fc984e1270f06ebb219beb18ee02e8441acafa05ec7d928f60000000002f71c08000c3500000c31100000000000000009ab2800d9b36f24db717e37c55b739581b3697a6b298520240117cbc6f71130dd07dbdd77a581b1804cd3db0c1f656aa75ea6326f08fafeef1a4460f70f0a84a0 +record=01010c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c1666deb356225ce9ad5e9b64676970a7d2a2dafb2b2cd9f669df993c081603de00021d561fb397d1ac599cf6876a4f390d6bd81de4cd425b30383b14a5de4b4b61d4b2d9c11c5bcd065716e0d8664b3f869b96dac515c6be66fc9bbe4861fe32a9a902bb58b5feca505c74edc000d8282fc556e51a1024fc8e7d7e56c6f887c5c8d5f2028f5304e2373e56ee7d774cb89e9f1afecf0ee7e3e3757f189908f069daa36c60000c0dfa00404a86662e5a6d1c7735e1250c28dc5e0103176c3a9274ac25aa0dc1a784d773ed984c97a7cca886059ca31bdd007210d2ba81525a2f9554a249f2d3d8b2e0dc306c2274601fea41a86226119b2d356d0fe06f0667a975ae86250c94210dff1a00bf021ad1aadcca777393747962322a6300c1c4269fd1032fa164f205050e387cb3d96b5799ef4fbd038072e3b84343b946da61e13ef776026d0b3c89c576f28580fd81958edf8218a61c58929e22e232ec8aa8f093efa33243ce746900ad76a23a2244699fb419c5ef6ff8b44a5a04b1282f77d4ea54ec4570734bf53fc52205aebc45fee93b4a3207dee9fcdd5894e225fea414a3a1306a0bd12775c60b678c7765893797c463eaa8ecb73cef571732a239535f2157137c4d0ed4ebd05c150802001a6f706171756520677561726469616e206174746163686d656e74000200ff +body=2436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d600024190c6e6e079c8575ea0a17d450565ec47ea99662b118c34b6608a7105d3b35ea7334c46d83bba22364ed8b863d3052fe24f62b973072776b537aaf70713259600000000000855ca000c3500000c31100000000000086d3a00000000000855ca000c11d8000000006553f100 + +scenario=D.2 +init=d6d9bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884892436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d60400000000030e9ca2000300000000000854d0000c3110000c3500000003e80000138800000000000000000000091800000000000f2ad000000000000855ca0000000002f71c081950e84703f6ab745d5e93ed17fadd4cb4750b1b4bc04b01b18dd4051b0fc58114f5b24aa52b2a1c5c391d98621ce6a37512049c6dac1cbf048f61cb52868547 +accept=d6dbbef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884892436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d6000000000000002a016024e4e3ab2a0cbbe957d5dc41c688721d956cf7babe8f304ad24b4589787bd993a7334c46d83bba22364ed8b863d3052fe24f62b973072776b537aaf707132596934cbfbc3fdfea5fc984e1270f06ebb219beb18ee02e8441acafa05ec7d928f607080000000000000007091800000000000f2ad000000000000855ca0000000002f71c080b2019644c62830070c3d7ca623b582f274e6e94669d4938e54262a1c6e6e6ed179105ec065a191d90cbb8a6adbeb0d1fd2838be33fa511588e2a3ccb83ce7313a0a1946d43ead676cd1be4a6780b5dd6feb03dc71f0c8d98056ce61304ab243d91b +manifest=01010c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c8465b03bd1ab3001129cd91fffb6e5129460cf82fb396cad2ebbb0ede8916396e7e996cb809a52736589d78d22b1a7c2a76b2ba07a8ac1f957aabc2651b0b9c5000c0df01d561fb397d1ac599cf6876a4f390d6bd81de4cd425b30383b14a5de4b4b61d4035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c028f5304e2373e56ee7d774cb89e9f1afecf0ee7e3e3757f189908f069daa36c60000c35900000d22436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d604010003000124e4e3ab2a0cbbe957d5dc41c688721d956cf7babe8f304ad24b4589787bd99300000000000f2ad0000c3500000c311000000000000000070002a7334c46d83bba22364ed8b863d3052fe24f62b973072776b537aaf70713259600000000000855ca000c3500000c311000000000000000080003934cbfbc3fdfea5fc984e1270f06ebb219beb18ee02e8441acafa05ec7d928f60000000002f71c08000c3500000c31100000000000000009ab2800d9b36f24db717e37c55b739581b3697a6b298520240117cbc6f71130dd07dbdd77a581b1804cd3db0c1f656aa75ea6326f08fafeef1a4460f70f0a84a0 +record=01010c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0cfc64314060ae5338b793a7dda00d9fd7e1c154b60fa52e324778fe63014c16fc00031d561fb397d1ac599cf6876a4f390d6bd81de4cd425b30383b14a5de4b4b61d48c946fbad478cc946802555991c23ce67a4e1356639e5d6a12212eca7abd898902bb58b5feca505c74edc000d8282fc556e51a1024fc8e7d7e56c6f887c5c8d5f2028f5304e2373e56ee7d774cb89e9f1afecf0ee7e3e3757f189908f069daa36c60000c0dfa012319822880a9ae3fee7a22c25a0107daa3d8bdd42251bddb4d4008be10a7589b289517b7742990a161b378bd1281783f9df6ae6def0d2a3c17ad42b3c835c80552470e92c37ecd8b0cb3ccdb2bbcfa67d9efb6f699d25776691c8d8518477f2400bf02ed0ef5b80e404a0bba3207635e45a99ab93ef3f43a96aca2e3837ca888274751b07a7e079f429c9ee9d98488a4f389bb9825b29b9abd871819290e5a3f33a6469b57abcccd14df422ca0172255f138c4130ca3087c21685c563be9af21b2e2633988c6e3e87e78366c001e48ab741db29abb54dd9c4eb3ad886b8467e7576dfa47b414f898908b7c9e028cc145e13f344ad7e3200a758d338fcf79d6a1e4b77cdb837bbc8f599789fd9f98e485f07a1c242269e95d7a187b05b02aa18cab02001a6f706171756520677561726469616e206174746163686d656e74000200ff +body=2436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d600038f3b529ca4bb18dc2b26ba4b0902f272f6aa9cf95b0e8c1464e54b14054b5f40934cbfbc3fdfea5fc984e1270f06ebb219beb18ee02e8441acafa05ec7d928f60000000002f71c08000c3500000c31100000000002f733780000000002f71c08000c11d8000000006553f100 diff --git a/src/ffor/witness_store/record/key_use/tests.rs b/src/ffor/witness_store/record/key_use/tests.rs new file mode 100644 index 0000000000..270ce3f069 --- /dev/null +++ b/src/ffor/witness_store/record/key_use/tests.rs @@ -0,0 +1,210 @@ +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::secp256k1::SecretKey; +use lightning::ln::ffor::FFORWitnessDecryptionError; +use lightning_ffor::setup::AuthenticatedSetup; +use lightning_ffor::wire::Message as WireMessage; +use lightning_ffor::witness::{SignedManifest, CIPHERTEXT_LEN}; + +use super::*; +use crate::ffor::witness_store::record::WitnessPolicy; + +// Public deterministic keys only. Production material is generated with operating-system entropy. +fn secret(byte: u8) -> SecretKey { + SecretKey::from_slice(&[byte; 32]).unwrap() +} + +fn public(byte: u8) -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &secret(byte)) +} + +struct Fixture<'a>(&'a str); + +impl Fixture<'_> { + fn bytes(&self, name: &str) -> Vec { + let value = self + .0 + .lines() + .find_map(|line| { + let (key, value) = line.split_once('=')?; + (key == name).then_some(value) + }) + .unwrap(); + hex(value) + } + + fn stored(&self) -> StoredWitnessEpoch { + let receiver = PublicKey::from_slice(&hex( + "039fca7f8157aa768708894ffd92550fe970edd18526a5f936583ea3b54dab3228", + )) + .unwrap(); + let settlement = PublicKey::from_slice(&hex( + "02087b7d1b4789170f6e374f0a0e58a1b7a899e34929795314ab6964e69609e9c0", + )) + .unwrap(); + let setup = AuthenticatedSetup::new( + &WireMessage::decode(&self.bytes("init")).unwrap(), + &WireMessage::decode(&self.bytes("accept")).unwrap(), + receiver, + settlement, + ) + .unwrap(); + let manifest = SignedManifest::decode(&self.bytes("manifest"), &setup).unwrap(); + let params = manifest.unsigned().parameters(); + let policy = WitnessPolicy { + witness: public(43), + retention_until: params.retention_until, + minimum_receipts: params.minimum_receipts, + }; + StoredWitnessEpoch { + binding_digest: [1; 32], + encryption_secret: Zeroizing::new([44; 32]), + witnesses: vec![WitnessKeys { + policy, + fetch_secret: Zeroizing::new([42; 32]), + manifest, + }], + } + } +} + +fn fixtures() -> impl Iterator> { + include_str!("fixtures.txt").trim().split("\n\n").map(Fixture) +} + +fn hex(value: &str) -> Vec { + (0..value.len()).step_by(2).map(|i| u8::from_str_radix(&value[i..i + 2], 16).unwrap()).collect() +} + +#[test] +fn ffor_witness_key_use_fetch_binds_retained_mailbox_key_and_cursor() { + for fixture in fixtures() { + let stored = fixture.stored(); + let manifest = stored.manifest(public(43)).unwrap(); + let count = ((manifest.unsigned().canonical_book().len() - 36) / 58) as u16; + for cursor in [None, Some(0), Some(count)] { + let request = stored.prepare_fetch(public(43), cursor).unwrap(); + let fields = request.unsigned().parameters(); + assert_eq!(fields.mailbox_id, manifest.unsigned().parameters().mailbox_id); + assert_eq!(fields.after_slot, cursor); + assert!(fields.extensions.is_empty()); + assert_eq!(request.fetch_key(), public(42)); + assert_eq!(SignedFetch::decode(&request.encode(), public(42)).unwrap(), request); + assert!(SignedFetch::decode(&request.encode(), public(41)).is_err()); + } + assert_eq!( + stored.prepare_fetch(public(43), Some(count + 1)), + Err(WitnessKeyUseError::Request(WitnessError::Pagination)) + ); + assert_eq!(stored.prepare_fetch(public(40), None), Err(WitnessKeyUseError::UnknownWitness)); + } +} + +#[test] +fn ffor_witness_key_use_fetch_entropy_failure_has_no_fallback() { + let mut stored = fixtures().next().unwrap().stored(); + assert_eq!( + stored.prepare_fetch_with(public(43), None, || Err(WitnessStoreError::Entropy)), + Err(WitnessKeyUseError::Entropy) + ); + assert_eq!( + stored.prepare_fetch_with(public(40), None, || panic!("invalid witness drew entropy")), + Err(WitnessKeyUseError::UnknownWitness) + ); + assert_eq!( + stored.prepare_fetch_with(public(43), Some(2), || panic!("invalid cursor drew entropy")), + Err(WitnessKeyUseError::Request(WitnessError::Pagination)) + ); + stored.witnesses[0].fetch_secret = Zeroizing::new([0; 32]); + assert_eq!(stored.prepare_fetch(public(43), None), Err(WitnessKeyUseError::KeyMaterial)); + stored.witnesses[0].fetch_secret = Zeroizing::new([41; 32]); + assert!(matches!(stored.prepare_fetch(public(43), None), Err(WitnessKeyUseError::Request(_)))); +} + +#[test] +fn ffor_witness_key_use_opens_pinned_beignet_records_without_key_export() { + for fixture in fixtures() { + let stored = fixture.stored(); + let record = EncryptedRecord::decode(&fixture.bytes("record")).unwrap(); + let receipt = stored.decrypt_record(public(43), record.clone()).unwrap(); + assert_eq!(receipt.header(), record.header()); + assert_eq!(receipt.body().preimage().as_slice(), &fixture.bytes("body")[34..66]); + assert_eq!(stored.decrypt_record(public(43), record).unwrap(), receipt); + assert!(!format!("{receipt:?}").contains(&format!("{:?}", &fixture.bytes("body")[34..66]))); + } +} + +#[test] +fn ffor_witness_key_use_rejects_other_witness_epoch_and_encryption_key() { + let first = fixtures().next().unwrap(); + let record = EncryptedRecord::decode(&first.bytes("record")).unwrap(); + let mut stored = first.stored(); + assert_eq!( + stored.decrypt_record(public(40), record.clone()), + Err(WitnessKeyUseError::UnknownWitness) + ); + stored.witnesses[0].policy.witness = public(40); + assert_eq!( + stored.decrypt_record(public(40), record.clone()), + Err(WitnessKeyUseError::Record(WitnessError::Witness)) + ); + let other = fixtures().nth(1).unwrap().stored(); + assert_eq!( + other.decrypt_record(public(43), record.clone()), + Err(WitnessKeyUseError::Record(WitnessError::Mailbox)) + ); + stored.witnesses[0].policy.witness = public(43); + stored.encryption_secret = Zeroizing::new([0; 32]); + assert_eq!( + stored.decrypt_record(public(43), record.clone()), + Err(WitnessKeyUseError::KeyMaterial) + ); + stored.encryption_secret = Zeroizing::new([45; 32]); + assert_eq!( + stored.decrypt_record(public(43), record), + Err(WitnessKeyUseError::Decryption(FFORWitnessDecryptionError::EncryptionKey)) + ); +} + +fn resign_record(bytes: &mut [u8]) { + let digest = sha256::Hash::hash(&[b"ffor/witness/record".as_slice(), &bytes[..235]].concat()); + let signature = Secp256k1::new().sign_ecdsa_with_noncedata( + &Message::from_digest(digest.to_byte_array()), + &secret(43), + &[91; 32], + ); + bytes[235..299].copy_from_slice(&signature.serialize_compact()); +} + +#[test] +fn ffor_witness_key_use_rejects_signed_mailbox_and_ciphertext_substitution() { + let fixture = fixtures().next().unwrap(); + let stored = fixture.stored(); + let mut changed = fixture.bytes("record"); + changed[2] ^= 1; + resign_record(&mut changed); + let record = EncryptedRecord::decode(&changed).unwrap(); + assert_eq!( + stored.decrypt_record(public(43), record), + Err(WitnessKeyUseError::Record(WitnessError::Mailbox)) + ); + let mut changed = fixture.bytes("record"); + changed[68] ^= 1; + resign_record(&mut changed); + let record = EncryptedRecord::decode(&changed).unwrap(); + assert_eq!( + stored.decrypt_record(public(43), record), + Err(WitnessKeyUseError::Record(WitnessError::Transcript)) + ); + for offset in [301 + 33, 301 + CIPHERTEXT_LEN - 1] { + let mut changed = fixture.bytes("record"); + changed[offset] ^= 1; + let ciphertext_hash = sha256::Hash::hash(&changed[301..301 + CIPHERTEXT_LEN]); + changed[203..235].copy_from_slice(ciphertext_hash.as_byte_array()); + resign_record(&mut changed); + let record = EncryptedRecord::decode(&changed).unwrap(); + assert_eq!( + stored.decrypt_record(public(43), record), + Err(WitnessKeyUseError::Decryption(FFORWitnessDecryptionError::Ciphertext)) + ); + } +} diff --git a/src/ffor/witness_store/tests.rs b/src/ffor/witness_store/tests.rs index 1c418b8277..17b77c46e8 100644 --- a/src/ffor/witness_store/tests.rs +++ b/src/ffor/witness_store/tests.rs @@ -218,6 +218,45 @@ fn ffor_witness_store_fresh_keys_are_scoped_to_epoch_and_witness() { assert_ne!(first_w.mailbox_id, next_w.mailbox_id); } +#[test] +fn ffor_witness_key_use_fetch_retries_and_reload_use_fresh_nonces() { + let backend = TestStore::new(); + let binding = binding(1); + let mut request_ids = BTreeSet::new(); + let mut nonces = BTreeSet::new(); + let original_manifest; + { + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let retained = store.create(&binding, &policies()).unwrap(); + original_manifest = retained.manifest(key(2)).unwrap().encode(); + for _ in 0..8 { + // A lost response starts a fresh authorization; it never reuses the previous wire bytes. + let request = retained.prepare_fetch(key(2), None).unwrap(); + let fields = request.unsigned().parameters(); + assert!(request_ids.insert(fields.request_id)); + assert!(nonces.insert(fields.nonce)); + } + assert_eq!(retained.prepare_fetch(key(8), None), Err(WitnessKeyUseError::UnknownWitness)); + } + let reopened = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let restored = reopened.load(&binding).unwrap(); + assert_eq!(restored.manifest(key(2)).unwrap().encode(), original_manifest); + for witness in [key(2), key(3)] { + for _ in 0..8 { + let request = restored.prepare_fetch(witness, None).unwrap(); + let fields = request.unsigned().parameters(); + assert!(request_ids.insert(fields.request_id)); + assert!(nonces.insert(fields.nonce)); + assert_eq!( + request.fetch_key(), + restored.manifest(witness).unwrap().unsigned().parameters().fetch_public_key + ); + } + } + // One initial write and one restored durability confirmation, with no mutable nonce counter. + assert_eq!(backend.writes.load(Ordering::SeqCst), 2); +} + #[test] fn ffor_witness_store_rejects_invalid_policy_before_any_storage_write() { let backend = TestStore::new(); diff --git a/src/message_handler.rs b/src/message_handler.rs index db039ee894..89e1286364 100644 --- a/src/message_handler.rs +++ b/src/message_handler.rs @@ -5,7 +5,7 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -//! Compose LSPS with an optional receive-only FFOR transport. The builder leaves FFOR disabled. +//! Compose LSPS with an optional bounded FFOR transport. The builder leaves FFOR disabled. pub(crate) mod ffor; #[cfg(test)] @@ -28,7 +28,7 @@ use crate::liquidity::LiquiditySource; use crate::types::LiquidityManager; use ffor::{FforFrame, FforReceiverTransport}; -/// Only the LSPS variant can enter this handler's outbound queue. +/// Independent LSPS and connection-scoped FFOR messages retain their own queue ordering. #[derive(Debug, PartialEq, Eq)] pub(crate) enum NodeCustomMessage { Liquidity(RawLSPSMessage), @@ -136,14 +136,21 @@ where } fn get_and_clear_pending_msg(&self) -> Vec<(PublicKey, NodeCustomMessage)> { - // FFOR has no outbound API. Native persistence must gate any future sender. - self.liquidity.as_ref().map_or_else(Vec::new, |liquidity| { + let mut messages: Vec<_> = self.liquidity.as_ref().map_or_else(Vec::new, |liquidity| { liquidity .get_and_clear_pending_msg() .into_iter() .map(|(peer, message)| (peer, NodeCustomMessage::Liquidity(message))) .collect() - }) + }); + if let Some(ffor) = self.ffor.as_ref() { + messages.extend( + ffor.drain_outbound() + .into_iter() + .map(|(peer, message)| (peer, NodeCustomMessage::Ffor(message))), + ); + } + messages } fn provided_node_features(&self) -> NodeFeatures { diff --git a/src/message_handler/ffor.rs b/src/message_handler/ffor.rs index e89d7f37cf..422e9cc012 100644 --- a/src/message_handler/ffor.rs +++ b/src/message_handler/ffor.rs @@ -1,9 +1,11 @@ -//! Bounded, receive-only transport state. Successful parsing grants no protocol authority. +//! Bounded connection-scoped transport state. Successful parsing grants no protocol authority. //! //! Only PeerManager's authenticated callbacks supply peer identity. A connection token identifies //! one successful init callback, including reconnects to the same key. Consumers must recheck that //! token under the eventual native channel authority before mutation; a queue pop is not a lock on -//! the connection. No protocol transition, signature generation or outbound queue exists here. +//! the connection. An outbound enqueue callback checks its token and queue capacity atomically; +//! the native caller must first authorize and durably retain the exact bytes. No protocol transition +//! or signature generation exists here, and the production builder leaves this transport disabled. use std::collections::{HashMap, VecDeque}; use std::fmt; @@ -55,12 +57,24 @@ impl FforFrame { let mut bytes = vec![0; body_len as usize + 2]; bytes[..2].copy_from_slice(&message_type.to_be_bytes()); reader.read_exact(&mut bytes[2..])?; + Self::validate_wire(&bytes)?; + Ok(Self(bytes)) + } + + fn validate_wire(bytes: &[u8]) -> Result<(), DecodeError> { + if bytes.len() < 2 || bytes.len() > MAX_MESSAGE_LEN { + return Err(DecodeError::InvalidValue); + } + let message_type = u16::from_be_bytes([bytes[0], bytes[1]]); + if !Self::handles_type(message_type) { + return Err(DecodeError::InvalidValue); + } if message_type == 55057 { - Acknowledgement::decode(&bytes).map_err(|_| DecodeError::InvalidValue)?; + Acknowledgement::decode(bytes).map_err(|_| DecodeError::InvalidValue)?; } else { - Message::decode(&bytes).map_err(|_| DecodeError::InvalidValue)?; + Message::decode(bytes).map_err(|_| DecodeError::InvalidValue)?; } - Ok(Self(bytes)) + Ok(()) } // The eventual receiver consumes exact bytes for native authentication and transcript binding. @@ -129,6 +143,7 @@ struct PeerState { struct QueueState { peers: HashMap, queue: VecDeque, + outbound: VecDeque, bytes: usize, } @@ -138,11 +153,28 @@ impl QueueState { if let Some(removed) = self.peers.remove(&peer) { self.bytes -= removed.bytes; self.queue.retain(|message| message.peer != peer); + self.outbound.retain(|message| message.peer != peer); } } + + fn at_capacity(&self, peer: &PeerState, bytes: usize) -> bool { + peer.messages >= MAX_PEER_MESSAGES + || peer.bytes + bytes > MAX_PEER_BYTES + || self.queue.len() + self.outbound.len() >= MAX_QUEUED_MESSAGES + || self.bytes + bytes > MAX_QUEUED_BYTES + } + + fn remove_accounting(&mut self, message: &ReceivedFforMessage) { + let bytes = message.frame.0.len(); + let peer = self.peers.get_mut(&message.peer).unwrap(); + peer.messages -= 1; + peer.bytes -= bytes; + self.bytes -= bytes; + } } -/// Optional inbound mailbox. It is never constructed by the production builder. +/// Optional inbound mailbox and outbound queue sharing one bounded capacity budget. +/// It is never constructed by the production builder. #[derive(Default)] pub(crate) struct FforReceiverTransport { state: Mutex, @@ -169,11 +201,7 @@ impl FforReceiverTransport { let mut state = self.state.lock().unwrap(); let peer_state = state.peers.get(&peer).ok_or_else(refused)?; let bytes = frame.0.len(); - if peer_state.messages >= MAX_PEER_MESSAGES - || peer_state.bytes + bytes > MAX_PEER_BYTES - || state.queue.len() >= MAX_QUEUED_MESSAGES - || state.bytes + bytes > MAX_QUEUED_BYTES - { + if state.at_capacity(peer_state, bytes) { return Err(refused()); } let connection = peer_state.connection.clone(); @@ -185,16 +213,66 @@ impl FforReceiverTransport { Ok(()) } + /// Captures only a transport generation, not native protocol or persistence authority. + #[allow(dead_code)] + pub(crate) fn connection(&self, peer: PublicKey) -> Option { + self.state.lock().unwrap().peers.get(&peer).map(|entry| entry.connection.clone()) + } + + /// Enqueues exact bytes inside a native-authorized release callback, using native -> transport + /// lock order. This method performs no I/O or manager calls. An error preserves all queued work + /// and must be returned to native so it retains retry ownership. Success means queue acceptance, + /// never network delivery. Disconnect discards accepted connection-scoped work. + #[allow(dead_code)] + pub(crate) fn enqueue( + &self, peer: PublicKey, connection: &ConnectionToken, wire: &[u8], + ) -> Result<(), OutboundError> { + FforFrame::validate_wire(wire).map_err(|_| OutboundError::InvalidMessage)?; + let mut state = self.state.lock().unwrap(); + let peer_state = state.peers.get(&peer).ok_or(OutboundError::StaleConnection)?; + if peer_state.connection != *connection { + return Err(OutboundError::StaleConnection); + } + // A retry already retained on this connection needs neither capacity nor another copy. + if state.outbound.iter().any(|message| message.peer == peer && message.frame.wire() == wire) + { + return Ok(()); + } + if state.at_capacity(peer_state, wire.len()) { + return Err(OutboundError::Capacity); + } + let frame = FforFrame(wire.to_vec()); + let peer_state = state.peers.get_mut(&peer).unwrap(); + peer_state.messages += 1; + peer_state.bytes += wire.len(); + state.bytes += wire.len(); + state.outbound.push_back(ReceivedFforMessage { + peer, + connection: connection.clone(), + frame, + }); + Ok(()) + } + + /// Called only from CustomMessageHandler's drain. The pinned PeerManager holds peers.read from + /// that callback through selection/encryption into the same peer's socket queue. Disconnect and + /// replacement require peers.write, and Noise refuses duplicate node-id mappings. + pub(super) fn drain_outbound(&self) -> Vec<(PublicKey, FforFrame)> { + let mut state = self.state.lock().unwrap(); + let mut messages = Vec::with_capacity(state.outbound.len()); + while let Some(message) = state.outbound.pop_front() { + state.remove_accounting(&message); + messages.push((message.peer, message.frame)); + } + messages + } + // Popping does not authorize any protocol action or retain the connection lock for the caller. #[allow(dead_code)] pub(crate) fn pop(&self) -> Option { let mut state = self.state.lock().unwrap(); let message = state.queue.pop_front()?; - let bytes = message.frame.0.len(); - let peer = state.peers.get_mut(&message.peer).unwrap(); - peer.messages -= 1; - peer.bytes -= bytes; - state.bytes -= bytes; + state.remove_accounting(&message); Some(message) } @@ -209,6 +287,13 @@ impl FforReceiverTransport { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum OutboundError { + InvalidMessage, + StaleConnection, + Capacity, +} + fn refused() -> LightningError { LightningError { err: "FFOR receive transport unavailable or at capacity".to_owned(), diff --git a/src/message_handler/ffor/tests.rs b/src/message_handler/ffor/tests.rs index 1ec828fe24..74430e0f1a 100644 --- a/src/message_handler/ffor/tests.rs +++ b/src/message_handler/ffor/tests.rs @@ -9,7 +9,11 @@ fn key(seed: u8) -> PublicKey { } fn frame(bytes: usize) -> FforFrame { - let wire = Acknowledgement::new([1; 16], AcknowledgementResult::Refused(vec![0; bytes - 21])) + unique_frame(bytes, 1) +} + +fn unique_frame(bytes: usize, tag: u8) -> FforFrame { + let wire = Acknowledgement::new([tag; 16], AcknowledgementResult::Refused(vec![0; bytes - 21])) .unwrap() .encode(); assert_eq!(wire.len(), bytes); @@ -18,12 +22,13 @@ fn frame(bytes: usize) -> FforFrame { fn assert_accounting(receiver: &FforReceiverTransport) { let state = receiver.state.lock().unwrap(); + let queued = state.queue.iter().chain(state.outbound.iter()).collect::>(); assert!(state.peers.len() <= MAX_PEERS); - assert!(state.queue.len() <= MAX_QUEUED_MESSAGES); + assert!(queued.len() <= MAX_QUEUED_MESSAGES); assert!(state.bytes <= MAX_QUEUED_BYTES); - assert_eq!(state.bytes, state.queue.iter().map(|message| message.frame.0.len()).sum::()); + assert_eq!(state.bytes, queued.iter().map(|message| message.frame.0.len()).sum::()); for (key, peer) in state.peers.iter() { - let queued = state.queue.iter().filter(|message| message.peer == *key).collect::>(); + let queued = queued.iter().filter(|message| message.peer == *key).collect::>(); assert_eq!(peer.messages, queued.len()); assert_eq!(peer.bytes, queued.iter().map(|message| message.frame.0.len()).sum::()); assert!(peer.messages <= MAX_PEER_MESSAGES); @@ -32,6 +37,174 @@ fn assert_accounting(receiver: &FforReceiverTransport) { } } +#[test] +fn ffor_outbound_retries_preserve_exact_fifo_and_separate_inbound_work() { + let receiver = FforReceiverTransport::default(); + let peer = key(1); + receiver.peer_connected(peer); + let connection = receiver.connection(peer).unwrap(); + let first = frame(21); + let second = frame(22); + receiver.receive(peer, frame(23)).unwrap(); + receiver.enqueue(peer, &connection, first.wire()).unwrap(); + receiver.enqueue(peer, &connection, second.wire()).unwrap(); + receiver.enqueue(peer, &connection, first.wire()).unwrap(); + assert_eq!(receiver.state.lock().unwrap().outbound.len(), 2); + assert_accounting(&receiver); + assert_eq!(receiver.drain_outbound(), vec![(peer, first), (peer, second)]); + assert_eq!(receiver.pop().unwrap().frame().wire().len(), 23); + assert!(receiver.drain_outbound().is_empty()); + // The native owner decides whether later replay is permitted after a queue drain. + receiver.enqueue(peer, &connection, frame(21).wire()).unwrap(); + assert_eq!(receiver.drain_outbound(), vec![(peer, frame(21))]); + assert_accounting(&receiver); +} + +#[test] +fn ffor_outbound_requires_exact_peer_connection_and_clears_on_disconnect() { + let receiver = FforReceiverTransport::default(); + let peer = key(1); + let other = key(2); + assert!(receiver.connection(peer).is_none()); + receiver.peer_connected(peer); + receiver.peer_connected(other); + let original = receiver.connection(peer).unwrap(); + assert_eq!( + receiver.enqueue(other, &original, frame(21).wire()), + Err(OutboundError::StaleConnection) + ); + receiver.enqueue(peer, &original, frame(21).wire()).unwrap(); + let other_connection = receiver.connection(other).unwrap(); + receiver.enqueue(other, &other_connection, frame(22).wire()).unwrap(); + receiver.peer_disconnected(peer); + assert_eq!( + receiver.enqueue(peer, &original, frame(21).wire()), + Err(OutboundError::StaleConnection) + ); + assert_eq!(receiver.drain_outbound(), vec![(other, frame(22))]); + receiver.peer_connected(peer); + let new = receiver.connection(peer).unwrap(); + assert_ne!(original, new); + assert_eq!( + receiver.enqueue(peer, &original, frame(21).wire()), + Err(OutboundError::StaleConnection) + ); + receiver.enqueue(peer, &new, frame(21).wire()).unwrap(); + receiver.peer_connected(peer); + assert!(receiver.drain_outbound().is_empty()); + let another = FforReceiverTransport::default(); + another.peer_connected(peer); + assert_eq!(another.enqueue(peer, &new, frame(21).wire()), Err(OutboundError::StaleConnection)); + assert_accounting(&receiver); +} + +#[test] +fn ffor_outbound_rejects_malformed_without_consuming_capacity_or_existing_work() { + let receiver = FforReceiverTransport::default(); + let peer = key(1); + receiver.peer_connected(peer); + let connection = receiver.connection(peer).unwrap(); + receiver.receive(peer, frame(21)).unwrap(); + receiver.enqueue(peer, &connection, frame(22).wire()).unwrap(); + let mut unknown = frame(21).wire().to_vec(); + unknown[..2].copy_from_slice(&55059u16.to_be_bytes()); + for bytes in [vec![], vec![0], vec![0, 0], unknown, vec![0; MAX_MESSAGE_LEN + 1]] { + assert_eq!(receiver.enqueue(peer, &connection, &bytes), Err(OutboundError::InvalidMessage)); + } + let wire = frame(21).wire().to_vec(); + for end in 2..wire.len() { + assert_eq!( + receiver.enqueue(peer, &connection, &wire[..end]), + Err(OutboundError::InvalidMessage) + ); + } + assert_eq!(receiver.drain_outbound(), vec![(peer, frame(22))]); + assert_eq!(receiver.pop().unwrap().frame().wire(), wire); + assert_accounting(&receiver); +} + +#[test] +fn ffor_outbound_backpressure_shares_peer_count_and_byte_budgets() { + let receiver = FforReceiverTransport::default(); + let peer = key(1); + receiver.peer_connected(peer); + let connection = receiver.connection(peer).unwrap(); + for tag in 0..MAX_PEER_MESSAGES as u8 / 2 { + receiver.receive(peer, frame(21)).unwrap(); + receiver.enqueue(peer, &connection, unique_frame(21, tag).wire()).unwrap(); + } + assert_eq!(receiver.enqueue(peer, &connection, frame(22).wire()), Err(OutboundError::Capacity)); + // Already retained bytes remain a successful retry even when the shared budget is full. + receiver.enqueue(peer, &connection, unique_frame(21, 0).wire()).unwrap(); + assert!(receiver.receive(peer, frame(21)).is_err()); + assert_accounting(&receiver); + assert_eq!(receiver.drain_outbound().len(), MAX_PEER_MESSAGES / 2); + while receiver.pop().is_some() {} + for tag in 0..4 { + receiver.enqueue(peer, &connection, unique_frame(65500, tag).wire()).unwrap(); + } + receiver.receive(peer, frame(144)).unwrap(); + assert_eq!(receiver.state.lock().unwrap().bytes, MAX_PEER_BYTES); + assert_eq!(receiver.enqueue(peer, &connection, frame(21).wire()), Err(OutboundError::Capacity)); + assert_accounting(&receiver); + receiver.pop().unwrap(); + receiver.enqueue(peer, &connection, frame(21).wire()).unwrap(); + assert_eq!(receiver.drain_outbound().len(), 5); + assert_accounting(&receiver); +} + +#[test] +fn ffor_outbound_backpressure_shares_global_count_and_byte_budgets() { + let receiver = FforReceiverTransport::default(); + for seed in 1..=17 { + receiver.peer_connected(key(seed)); + } + for seed in 1..=16 { + let peer = key(seed); + let connection = receiver.connection(peer).unwrap(); + for tag in 0..MAX_PEER_MESSAGES as u8 { + receiver.enqueue(peer, &connection, unique_frame(21, tag).wire()).unwrap(); + } + } + let peer = key(17); + let connection = receiver.connection(peer).unwrap(); + assert_eq!(receiver.enqueue(peer, &connection, frame(21).wire()), Err(OutboundError::Capacity)); + assert!(receiver.receive(peer, frame(21)).is_err()); + assert_accounting(&receiver); + assert_eq!(receiver.drain_outbound().len(), MAX_QUEUED_MESSAGES); + for seed in 1..=16 { + let peer = key(seed); + receiver.enqueue(peer, &receiver.connection(peer).unwrap(), frame(65500).wire()).unwrap(); + } + receiver.receive(peer, frame(576)).unwrap(); + assert_eq!(receiver.state.lock().unwrap().bytes, MAX_QUEUED_BYTES); + assert_eq!(receiver.enqueue(peer, &connection, frame(21).wire()), Err(OutboundError::Capacity)); + receiver.peer_disconnected(key(1)); + receiver.enqueue(peer, &connection, frame(65500).wire()).unwrap(); + assert_accounting(&receiver); +} + +#[test] +fn ffor_outbound_disconnect_race_cannot_rebind_queued_bytes() { + let receiver = Arc::new(FforReceiverTransport::default()); + let peer = key(1); + receiver.peer_connected(peer); + let old = receiver.connection(peer).unwrap(); + let barrier = Arc::new(std::sync::Barrier::new(2)); + let outbound = Arc::clone(&receiver); + let worker_barrier = Arc::clone(&barrier); + let worker = std::thread::spawn(move || { + worker_barrier.wait(); + let _ = outbound.enqueue(peer, &old, frame(21).wire()); + }); + barrier.wait(); + receiver.peer_disconnected(peer); + receiver.peer_connected(peer); + worker.join().unwrap(); + assert!(receiver.drain_outbound().is_empty()); + assert_accounting(&receiver); +} + #[test] fn ffor_disconnect_and_rebind_invalidate_popped_and_pending_work() { let receiver = FforReceiverTransport::default(); @@ -152,7 +325,7 @@ fn ffor_disconnect_can_race_with_receive_without_retaining_stale_work() { proptest! { #![proptest_config(ProptestConfig::with_cases(64))] #[test] - fn ffor_arbitrary_connection_queue_sequences_preserve_bounds(operations in prop::collection::vec((0u8..4, 0u8..4, 21usize..65536), 0..300)) { + fn ffor_arbitrary_connection_queue_sequences_preserve_bounds(operations in prop::collection::vec((0u8..6, 0u8..4, 21usize..65536), 0..300)) { let receiver = FforReceiverTransport::default(); let peers = [key(1), key(2), key(3), key(4)]; for (operation, peer, size) in operations { @@ -161,7 +334,13 @@ proptest! { 0 => receiver.peer_connected(peer), 1 => receiver.peer_disconnected(peer), 2 => { let _ = receiver.receive(peer, frame(size)); }, - _ => { receiver.pop(); }, + 3 => { receiver.pop(); }, + 4 => { + if let Some(connection) = receiver.connection(peer) { + let _ = receiver.enqueue(peer, &connection, frame(size).wire()); + } + }, + _ => { receiver.drain_outbound(); }, } assert_accounting(&receiver); } diff --git a/src/message_handler/tests.rs b/src/message_handler/tests.rs index 2c70c7a2aa..741aea0c84 100644 --- a/src/message_handler/tests.rs +++ b/src/message_handler/tests.rs @@ -123,6 +123,47 @@ fn ffor_composes_lsps_read_outbox_features_and_callbacks() { assert_eq!(*lsps.disconnected.lock().unwrap(), vec![peer]); } +#[test] +fn ffor_outbound_merges_with_lsps_without_consuming_inbound_or_reordering() { + let lsps = Arc::new(LspsHandler::default()); + let receiver = Arc::new(FforReceiverTransport::default()); + let handler = NodeCustomMessageHandler::new_liquidity_handler(Arc::clone(&lsps)) + .with_ffor_receiver(Arc::clone(&receiver)); + let peer = key(1); + handler.peer_connected(peer, &init(), false).unwrap(); + let connection = receiver.connection(peer).unwrap(); + let fixtures: serde_json::Value = serde_json::from_str(include_str!("test_data.json")).unwrap(); + let wires = fixtures["messages"] + .as_array() + .unwrap() + .iter() + .take(2) + .map(|fixture| Vec::::from_hex(fixture["wire"].as_str().unwrap()).unwrap()) + .collect::>(); + for wire in &wires { + receiver.enqueue(peer, &connection, wire).unwrap(); + } + let requests = + [RawLSPSMessage { payload: "first".into() }, RawLSPSMessage { payload: "second".into() }]; + lsps.pending.lock().unwrap().extend(requests.iter().cloned().map(|message| (peer, message))); + handler.handle_custom_message(parse(&handler, &ack_wire(peer)), peer).unwrap(); + let messages = handler.get_and_clear_pending_msg(); + assert_eq!(messages.len(), 4); + for (actual, expected) in messages[..2].iter().zip(requests) { + assert_eq!(*actual, (peer, NodeCustomMessage::Liquidity(expected))); + } + for ((actual_peer, message), wire) in messages[2..].iter().zip(wires) { + assert_eq!(*actual_peer, peer); + assert_eq!(message.type_id().to_be_bytes(), wire[..2]); + assert_eq!(message.encode(), wire[2..]); + assert!(matches!(message, NodeCustomMessage::Ffor(_))); + } + assert!(handler.get_and_clear_pending_msg().is_empty()); + assert_eq!(receiver.pop().unwrap().frame().wire(), ack_wire(peer)); + assert_eq!(handler.provided_node_features(), lsps.provided_node_features()); + assert_eq!(handler.provided_init_features(peer), lsps.provided_init_features(peer)); +} + #[test] fn ffor_disabled_has_no_read_features_queue_or_connection_effects() { let handler = NodeCustomMessageHandler::>::new_ignoring(); @@ -158,9 +199,11 @@ fn ffor_binding_waits_for_lsps_success_and_preserves_errors() { handler.peer_connected(peer, &init(), false).unwrap(); handler.handle_custom_message(parse(&handler, &ack_wire(peer)), peer).unwrap(); let old = receiver.pop().unwrap(); + receiver.enqueue(peer, old.connection(), &ack_wire(peer)).unwrap(); lsps.fail_connect.store(true, Ordering::Relaxed); assert_eq!(handler.peer_connected(peer, &init(), false), Err(())); assert!(!receiver.is_current(&old)); + assert!(handler.get_and_clear_pending_msg().is_empty()); assert!(handler.handle_custom_message(parse(&handler, &ack_wire(peer)), peer).is_err()); lsps.fail_connect.store(false, Ordering::Relaxed); handler.peer_connected(peer, &init(), false).unwrap(); From d6ad4abe2429a4ed79f9b910ad53982ba7e33449 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 21 Sep 2026 00:29:55 -0400 Subject: [PATCH 11/23] feat(ffor): transport typed witness requests and responses --- FFOR.md | 25 +++-- src/message_handler/ffor.rs | 63 +++++++++-- src/message_handler/ffor/tests.rs | 40 +++++++ src/message_handler/tests.rs | 177 +++++++++++++++++++++++++++++- 4 files changed, 284 insertions(+), 21 deletions(-) diff --git a/FFOR.md b/FFOR.md index bd58676f4e..3d1bb15c97 100644 --- a/FFOR.md +++ b/FFOR.md @@ -26,8 +26,8 @@ and recovery are implemented together. Node's custom-message composition retains the existing LSPS reader, outbox, features and peer callbacks. An optional private FFOR receiver can parse the seven supported -signed lifecycle types and witness acknowledgement type 55057 through the shared -canonical codecs. Its bounded outbox accepts exact wire messages from a future +signed lifecycle types, witness acknowledgement type 55057 and fetch response type +55061 through the shared canonical codecs. Its bounded outbox accepts exact wire messages from a future native-authorized release callback. The production builder leaves this transport disabled. This slice has no operational sender, feature advertisement, protocol transition or public setting. @@ -41,16 +41,23 @@ before applying a transition; a successful parse or point-in-time token check gr no such authority. Claimed wire identities cannot replace the authenticated peer. Frames are limited to 65,535 bytes including the message type. The combined queues -tracks at most 64 peers, eight frames and 256 KiB per peer, and 128 frames and 1 MiB +track at most 64 peers, eight frames and 256 KiB per peer, and 128 frames and 1 MiB globally. Those byte limits cover retained wire payloads; bounded peer and frame metadata adds fixed overhead. Queue refusals preserve existing work and do not disconnect ordinary peers. Consumers take one frame at a time and remain responsible for bounding any work they retain after removal from the queue. Debug formatting exposes only the FFOR type and length, so PeerManager trace logs cannot print lifecycle preimages or other wire payloads. Malformed recognized frames fail the -custom reader; unknown types keep the -existing ignore behavior. Witness service requests and receipt retrieval are outside -this receiver slice. +custom reader; unknown types keep the existing ignore behavior. Inbound witness +service requests remain unhandled. A parsed fetch response is uncorrelated encrypted +input until the recovery owner verifies the pending request, connection and manifest. + +Outbound witness provisioning (55055) and signed fetch requests (55059) accept only +the shared immutable `Provision` and `SignedFetch` types. Their constructors require +an authenticated setup or the trusted mailbox fetch key. Raw request bytes cannot +bypass those checks, and the Noise peer key is never substituted for the fetch key. +Provisioning still requires durable secrets and current native authority. Fetching +historical evidence does not establish current activation or payment authority. An outbound enqueue checks the original connection token and shared capacity in one critical section. Backpressure preserves all queued messages, and an exact retry @@ -65,8 +72,10 @@ replay is legal. No transport mutex may span a native manager call. Run `cargo test --lib message_handler` for exact public fixture routing, LSPS coexistence, disabled behavior, malformed frame bounds, connection replacement, -concurrent disconnect, outbound backpressure and property-based queue accounting checks. Fixture provenance -is recorded in `src/message_handler/test_data.json`. Parser fuzzing remains in the +concurrent disconnect, outbound backpressure, typed witness requests, bounded fetch +responses and property-based queue accounting checks. Fixture provenance is recorded +in `src/message_handler/test_data.json` and beside the witness key-operation fixtures. +Parser fuzzing remains in the shared `lightning-ffor` wire and witness targets; Node also runs arbitrary-byte property checks at the length-limited reader boundary. diff --git a/src/message_handler/ffor.rs b/src/message_handler/ffor.rs index 422e9cc012..4fea6f8b63 100644 --- a/src/message_handler/ffor.rs +++ b/src/message_handler/ffor.rs @@ -4,8 +4,11 @@ //! one successful init callback, including reconnects to the same key. Consumers must recheck that //! token under the eventual native channel authority before mutation; a queue pop is not a lock on //! the connection. An outbound enqueue callback checks its token and queue capacity atomically; -//! the native caller must first authorize and durably retain the exact bytes. No protocol transition -//! or signature generation exists here, and the production builder leaves this transport disabled. +//! native lifecycle callers must first authorize and durably retain the exact bytes. Witness callers +//! must retain their manifests and protected keys; provisioning additionally needs current native +//! authority. Fetching historical evidence establishes no current activation or payment authority. +//! No protocol transition or signature generation exists here, and the production builder leaves +//! this transport disabled. use std::collections::{HashMap, VecDeque}; use std::fmt; @@ -18,7 +21,7 @@ use lightning::ln::wire::Type; use lightning::util::logger::Level; use lightning::util::ser::{LengthLimitedRead, Writeable, Writer}; use lightning_ffor::wire::{Message, MAX_MESSAGE_LEN}; -use lightning_ffor::witness::Acknowledgement; +use lightning_ffor::witness::{Acknowledgement, FetchResponse, Provision, SignedFetch}; const MAX_PEERS: usize = 64; const MAX_PEER_MESSAGES: usize = 8; @@ -26,7 +29,7 @@ const MAX_PEER_BYTES: usize = 256 * 1024; const MAX_QUEUED_MESSAGES: usize = 128; const MAX_QUEUED_BYTES: usize = 1024 * 1024; -/// Exact canonical input, including the message type. Authentication remains the consumer's job. +/// Exact canonical bytes, including the message type. Parsing grants no protocol authority. #[derive(PartialEq, Eq)] pub(crate) struct FforFrame(Vec); @@ -44,7 +47,12 @@ impl fmt::Debug for FforFrame { impl FforFrame { pub(super) fn handles_type(message_type: u16) -> bool { - matches!(message_type, 55001 | 55003 | 55045 | 55047 | 55049 | 55051 | 55053 | 55057) + // Witness service requests are outbound-only. Unkeyed input cannot authenticate a + // Provision's setup or a Fetch's expected mailbox key. + matches!( + message_type, + 55001 | 55003 | 55045 | 55047 | 55049 | 55051 | 55053 | 55057 | 55061 + ) } pub(super) fn read( @@ -69,10 +77,16 @@ impl FforFrame { if !Self::handles_type(message_type) { return Err(DecodeError::InvalidValue); } - if message_type == 55057 { - Acknowledgement::decode(bytes).map_err(|_| DecodeError::InvalidValue)?; - } else { - Message::decode(bytes).map_err(|_| DecodeError::InvalidValue)?; + match message_type { + 55057 => { + Acknowledgement::decode(bytes).map_err(|_| DecodeError::InvalidValue)?; + }, + 55061 => { + FetchResponse::decode(bytes).map_err(|_| DecodeError::InvalidValue)?; + }, + _ => { + Message::decode(bytes).map_err(|_| DecodeError::InvalidValue)?; + }, } Ok(()) } @@ -228,6 +242,37 @@ impl FforReceiverTransport { &self, peer: PublicKey, connection: &ConnectionToken, wire: &[u8], ) -> Result<(), OutboundError> { FforFrame::validate_wire(wire).map_err(|_| OutboundError::InvalidMessage)?; + self.enqueue_validated(peer, connection, wire) + } + + /// Queues an immutable shared manifest that was authenticated against its retained setup. + /// The caller still owns durable key/manifest storage and current native provisioning authority. + /// Raw Provision bytes cannot enter through `enqueue`, which has no trusted setup to check. + #[allow(dead_code)] + pub(crate) fn enqueue_provision( + &self, peer: PublicKey, connection: &ConnectionToken, provision: &Provision, + ) -> Result<(), OutboundError> { + self.enqueue_validated(peer, connection, &provision.encode()) + } + + /// Queues an immutable fetch whose signature was checked against the trusted mailbox fetch key. + /// The Noise peer key is not a substitute for that key. After ambiguous delivery, the owner must + /// create a fresh nonce/request rather than replay an earlier fetch on a replacement connection. + #[allow(dead_code)] + pub(crate) fn enqueue_fetch( + &self, peer: PublicKey, connection: &ConnectionToken, fetch: &SignedFetch, + ) -> Result<(), OutboundError> { + self.enqueue_validated(peer, connection, &fetch.encode()) + } + + // Only a bounded canonical decoder or an immutable authenticated shared type can reach this + // insertion path. The queue does not infer signature authority from an arbitrary wire key. + fn enqueue_validated( + &self, peer: PublicKey, connection: &ConnectionToken, wire: &[u8], + ) -> Result<(), OutboundError> { + if wire.len() < 2 || wire.len() > MAX_MESSAGE_LEN { + return Err(OutboundError::InvalidMessage); + } let mut state = self.state.lock().unwrap(); let peer_state = state.peers.get(&peer).ok_or(OutboundError::StaleConnection)?; if peer_state.connection != *connection { diff --git a/src/message_handler/ffor/tests.rs b/src/message_handler/ffor/tests.rs index 74430e0f1a..114bfff286 100644 --- a/src/message_handler/ffor/tests.rs +++ b/src/message_handler/ffor/tests.rs @@ -205,6 +205,46 @@ fn ffor_outbound_disconnect_race_cannot_rebind_queued_bytes() { assert_accounting(&receiver); } +#[test] +fn ffor_witness_typed_outbound_uses_shared_budget_retry_and_disconnect_rules() { + let receiver = FforReceiverTransport::default(); + let peer = key(1); + receiver.peer_connected(peer); + let connection = receiver.connection(peer).unwrap(); + let (provision, fetch, _) = super::super::tests::witness_messages(); + let another = Provision::new([8; 16], provision.manifest().clone()); + for _ in 0..MAX_PEER_MESSAGES - 2 { + receiver.receive(peer, frame(21)).unwrap(); + } + receiver.enqueue_provision(peer, &connection, &provision).unwrap(); + receiver.enqueue_fetch(peer, &connection, &fetch).unwrap(); + receiver.enqueue_provision(peer, &connection, &provision).unwrap(); + receiver.enqueue_fetch(peer, &connection, &fetch).unwrap(); + assert_eq!( + receiver.enqueue_provision(peer, &connection, &another), + Err(OutboundError::Capacity) + ); + assert_accounting(&receiver); + let pending = receiver.drain_outbound(); + assert_eq!(pending.len(), 2); + assert_eq!(pending[0].1.wire(), provision.encode()); + assert_eq!(pending[1].1.wire(), fetch.encode()); + receiver.enqueue_provision(peer, &connection, &another).unwrap(); + receiver.peer_disconnected(peer); + receiver.peer_connected(peer); + assert!(receiver.drain_outbound().is_empty()); + assert!(receiver.pop().is_none()); + assert_eq!( + receiver.enqueue_provision(peer, &connection, &provision), + Err(OutboundError::StaleConnection) + ); + assert_eq!( + receiver.enqueue_fetch(peer, &connection, &fetch), + Err(OutboundError::StaleConnection) + ); + assert_accounting(&receiver); +} + #[test] fn ffor_disconnect_and_rebind_invalidate_popped_and_pending_work() { let receiver = FforReceiverTransport::default(); diff --git a/src/message_handler/tests.rs b/src/message_handler/tests.rs index 741aea0c84..a5ce885a15 100644 --- a/src/message_handler/tests.rs +++ b/src/message_handler/tests.rs @@ -6,7 +6,12 @@ use bitcoin::secp256k1::{Secp256k1, SecretKey}; use lightning::ln::msgs::ErrorAction; use lightning::util::logger::Level; use lightning::util::ser::LengthReadable; -use lightning_ffor::witness::{Acknowledgement, AcknowledgementResult}; +use lightning_ffor::setup::AuthenticatedSetup; +use lightning_ffor::wire::{Message as FforMessage, Tlv}; +use lightning_ffor::witness::{ + Acknowledgement, AcknowledgementResult, EncryptedRecord, FetchResponse, FetchResult, Provision, + SignedFetch, SignedManifest, +}; use lightning_liquidity::lsps0::ser::LSPS_MESSAGE_TYPE_ID; use proptest::prelude::*; @@ -90,6 +95,170 @@ fn parse(handler: &NodeCustomMessageHandler>, wire: &[u8]) -> N handler.read(u16::from_be_bytes([wire[0], wire[1]]), &mut &wire[2..]).unwrap().unwrap() } +// Public Beignet D.1 vectors from native commit 8478895. The retained manifest/record projection +// has provenance beside fixtures.txt; the odd fetch is from lightning-ffor's +// tests/data/beignet-witness-fetch.json, Beignet revision 8aee31d18e596fe49a0d195b325a6e757d7a009b. +pub(super) fn witness_messages() -> (Provision, SignedFetch, FetchResponse) { + let fixture = include_str!("../ffor/witness_store/record/key_use/fixtures.txt") + .split("\n\n") + .next() + .unwrap(); + let bytes = |name: &str| { + let value = fixture + .lines() + .find_map(|line| { + let (key, value) = line.split_once('=')?; + (key == name).then_some(value) + }) + .unwrap(); + Vec::::from_hex(value).unwrap() + }; + let receiver = PublicKey::from_slice( + &Vec::::from_hex("039fca7f8157aa768708894ffd92550fe970edd18526a5f936583ea3b54dab3228") + .unwrap(), + ) + .unwrap(); + let settlement = PublicKey::from_slice( + &Vec::::from_hex("02087b7d1b4789170f6e374f0a0e58a1b7a899e34929795314ab6964e69609e9c0") + .unwrap(), + ) + .unwrap(); + let setup = AuthenticatedSetup::new( + &FforMessage::decode(&bytes("init")).unwrap(), + &FforMessage::decode(&bytes("accept")).unwrap(), + receiver, + settlement, + ) + .unwrap(); + let manifest = SignedManifest::decode(&bytes("manifest"), &setup).unwrap(); + let provision = Provision::new([1; 16], manifest); + let fetch_wire = Vec::::from_hex(concat!( + "d7131f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", + "3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d", + "b3ec3d984d49b4f071e2f3ba5265915ff08529dab4aa61f347cc9d9439683f6e", + "3cef7f8b4483445adabfa4633635ee8a9c324a60cff27a3226c67c98bb2be36c030107", + )) + .unwrap(); + let fetch_key = provision.manifest().unsigned().parameters().fetch_public_key; + let fetch = SignedFetch::decode(&fetch_wire, fetch_key).unwrap(); + assert_eq!(fetch.encode(), fetch_wire); + let response = FetchResponse::new( + fetch.unsigned().parameters().request_id, + FetchResult::Page { + records: vec![EncryptedRecord::decode(&bytes("record")).unwrap()], + next_after_slot: None, + extensions: vec![], + }, + ) + .unwrap(); + (provision, fetch, response) +} + +#[test] +fn ffor_witness_typed_requests_keep_exact_bytes_and_do_not_open_a_service() { + let receiver = Arc::new(FforReceiverTransport::default()); + let handler = NodeCustomMessageHandler::>::new_ignoring() + .with_ffor_receiver(Arc::clone(&receiver)); + let peer = key(1); + handler.peer_connected(peer, &init(), false).unwrap(); + let connection = receiver.connection(peer).unwrap(); + let (provision, fetch, _) = witness_messages(); + // The authenticated Noise peer is deliberately different from the retained fetch key. + assert_ne!(peer, fetch.fetch_key()); + receiver.enqueue_provision(peer, &connection, &provision).unwrap(); + receiver.enqueue_fetch(peer, &connection, &fetch).unwrap(); + for wire in [provision.encode(), fetch.encode()] { + assert_eq!( + receiver.enqueue(peer, &connection, &wire), + Err(ffor::OutboundError::InvalidMessage) + ); + let mut body = &wire[2..]; + assert!(handler.read(u16::from_be_bytes([wire[0], wire[1]]), &mut body).unwrap().is_none()); + assert_eq!(body, &wire[2..]); + } + let outbound = handler.get_and_clear_pending_msg(); + for ((actual_peer, message), wire) in outbound.iter().zip([provision.encode(), fetch.encode()]) + { + assert_eq!(*actual_peer, peer); + assert_eq!(message.type_id().to_be_bytes(), wire[..2]); + assert_eq!(message.encode(), wire[2..]); + assert_eq!( + format!("{message:?}"), + format!( + "Ffor(FforFrame {{ message_type: {}, length: {} }})", + message.type_id(), + wire.len() + ) + ); + } + assert_eq!(outbound.len(), 2); + assert!(receiver.pop().is_none()); +} + +#[test] +fn ffor_witness_fetch_response_is_bounded_opaque_input_with_exact_extensions() { + let receiver = Arc::new(FforReceiverTransport::default()); + let handler = NodeCustomMessageHandler::>::new_ignoring() + .with_ffor_receiver(Arc::clone(&receiver)); + let peer = key(1); + handler.peer_connected(peer, &init(), false).unwrap(); + let (_, _, response) = witness_messages(); + let mut wire = response.encode(); + wire.extend_from_slice(&[3, 1, 7]); // Canonical unknown odd field is preserved, not interpreted. + let decoded = FetchResponse::decode(&wire).unwrap(); + assert!( + matches!(decoded.result(), FetchResult::Page { extensions, .. } if extensions == &[Tlv { kind: 3, value: vec![7] }]) + ); + let message = parse(&handler, &wire); + assert_eq!(message.type_id(), 55061); + assert_eq!(message.encode(), wire[2..]); + assert_eq!( + format!("{message:?}"), + format!("Ffor(FforFrame {{ message_type: 55061, length: {} }})", wire.len()) + ); + handler.handle_custom_message(message, peer).unwrap(); + let queued = receiver.pop().unwrap(); + assert_eq!(queued.peer(), peer); + assert_eq!(queued.frame().wire(), wire); + assert!(receiver.is_current(&queued)); + assert!(handler.get_and_clear_pending_msg().is_empty()); + let disabled = NodeCustomMessageHandler::>::new_ignoring(); + assert!(disabled.read(55061, &mut &wire[2..]).unwrap().is_none()); +} + +#[test] +fn ffor_witness_fetch_response_rejects_malformed_counts_lengths_and_tlvs() { + let handler = NodeCustomMessageHandler::>::new_ignoring() + .with_ffor_receiver(Arc::new(FforReceiverTransport::default())); + let (_, _, response) = witness_messages(); + let wire = response.encode(); + for end in 2..wire.len() { + assert!(handler.read(55061, &mut &wire[2..end]).is_err(), "truncated at {end}"); + } + let mut mutations = Vec::new(); + let mut invalid_status = wire.clone(); + invalid_status[18] = 2; + mutations.push(invalid_status); + let mut excessive_count = wire.clone(); + excessive_count[19..21].copy_from_slice(&484u16.to_be_bytes()); + mutations.push(excessive_count); + let mut invalid_length = wire.clone(); + invalid_length[21..23].copy_from_slice(&u16::MAX.to_be_bytes()); + mutations.push(invalid_length); + for suffix in [&[2, 0][..], &[3, 0, 3, 0][..], &[0xfd, 0, 3, 0][..], &[1, 1, 0][..]] { + let mut changed = wire.clone(); + changed.extend_from_slice(suffix); + mutations.push(changed); + } + for changed in mutations { + assert!(handler.read(55061, &mut &changed[2..]).is_err()); + } + assert!(handler.read(55061, &mut &vec![0; 65534][..]).is_err()); + let refused = FetchResponse::new([3; 16], FetchResult::Refused(vec![0; 65514])).unwrap(); + assert_eq!(parse(&handler, &refused.encode()).encode(), refused.encode()[2..]); +} + #[test] fn ffor_composes_lsps_read_outbox_features_and_callbacks() { let lsps = Arc::new(LspsHandler::default()); @@ -262,14 +431,14 @@ fn ffor_debug_keeps_lifecycle_payloads_out_of_peer_trace_logs() { fn ffor_reader_rejects_malformed_oversized_and_ignores_unknown_types() { let handler = NodeCustomMessageHandler::>::new_ignoring() .with_ffor_receiver(Arc::new(FforReceiverTransport::default())); - for typ in [55001, 55003, 55045, 55047, 55049, 55051, 55053, 55057] { + for typ in [55001, 55003, 55045, 55047, 55049, 55051, 55053, 55057, 55061] { assert!(handler.read(typ, &mut &[][..]).is_err()); let oversized = vec![0; 65534]; let mut body = oversized.as_slice(); assert!(handler.read(typ, &mut body).is_err()); assert_eq!(body.len(), oversized.len()); // Refuse before allocation or reading. } - for typ in [55055, 55059, 55061, 55101, 0] { + for typ in [55055, 55059, 55101, 0] { assert!(handler.read(typ, &mut &[][..]).unwrap().is_none()); } let mut wire = ack_wire(key(1)); @@ -280,7 +449,7 @@ fn ffor_reader_rejects_malformed_oversized_and_ignores_unknown_types() { proptest! { #![proptest_config(ProptestConfig::with_cases(64))] #[test] - fn ffor_untrusted_frames_preserve_exact_bytes_or_fail(data in prop::collection::vec(any::(), 0..66000), typ in prop::sample::select(vec![55001u16,55003,55045,55047,55049,55051,55053,55057])) { + fn ffor_untrusted_frames_preserve_exact_bytes_or_fail(data in prop::collection::vec(any::(), 0..66000), typ in prop::sample::select(vec![55001u16,55003,55045,55047,55049,55051,55053,55057,55061])) { let handler = NodeCustomMessageHandler::>::new_ignoring().with_ffor_receiver(Arc::new(FforReceiverTransport::default())); if let Ok(Some(message)) = handler.read(typ, &mut data.as_slice()) { prop_assert_eq!(message.type_id(), typ); From f5cd9722b700a946dd9afcd325525645ea6113a1 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 21 Sep 2026 00:50:57 -0400 Subject: [PATCH 12/23] feat(ffor): connect synchronous native setup handling --- Cargo.lock | 30 ++-- Cargo.toml | 26 ++-- src/message_handler.rs | 40 ++++- src/message_handler/ffor.rs | 32 +++- src/message_handler/ffor/setup.rs | 86 +++++++++++ src/message_handler/tests.rs | 2 + src/message_handler/tests/setup.rs | 226 +++++++++++++++++++++++++++++ 7 files changed, 406 insertions(+), 36 deletions(-) create mode 100644 src/message_handler/ffor/setup.rs create mode 100644 src/message_handler/tests/setup.rs diff --git a/Cargo.lock b/Cargo.lock index 80ed4d964a..934fdf037c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1994,7 +1994,7 @@ dependencies = [ [[package]] name = "lightning" version = "0.2.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" +source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" dependencies = [ "bech32", "bitcoin", @@ -2006,7 +2006,7 @@ dependencies = [ "lightning-macros 0.2.1", "lightning-types 0.3.5", "musig2", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89)", "regex", ] @@ -2026,7 +2026,7 @@ dependencies = [ [[package]] name = "lightning-background-processor" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" +source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" dependencies = [ "bitcoin", "bitcoin-io", @@ -2034,7 +2034,7 @@ dependencies = [ "lightning 0.2.5", "lightning-liquidity 0.2.3", "lightning-rapid-gossip-sync 0.2.3", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89)", ] [[package]] @@ -2053,7 +2053,7 @@ dependencies = [ [[package]] name = "lightning-block-sync" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" +source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" dependencies = [ "bitcoin", "chunked_transfer", @@ -2065,7 +2065,7 @@ dependencies = [ [[package]] name = "lightning-ffor" version = "0.1.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" +source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" dependencies = [ "bitcoin", ] @@ -2085,7 +2085,7 @@ dependencies = [ [[package]] name = "lightning-invoice" version = "0.34.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" +source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" dependencies = [ "bech32", "bitcoin", @@ -2111,7 +2111,7 @@ dependencies = [ [[package]] name = "lightning-liquidity" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" +source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" dependencies = [ "bitcoin", "chrono", @@ -2137,7 +2137,7 @@ dependencies = [ [[package]] name = "lightning-macros" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" +source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" dependencies = [ "proc-macro2", "quote", @@ -2158,7 +2158,7 @@ dependencies = [ [[package]] name = "lightning-net-tokio" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" +source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2179,7 +2179,7 @@ dependencies = [ [[package]] name = "lightning-persister" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" +source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2202,7 +2202,7 @@ dependencies = [ [[package]] name = "lightning-rapid-gossip-sync" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" +source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" dependencies = [ "bitcoin", "bitcoin-io", @@ -2227,7 +2227,7 @@ dependencies = [ [[package]] name = "lightning-transaction-sync" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" +source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" dependencies = [ "bitcoin", "electrum-client 0.24.1", @@ -2249,7 +2249,7 @@ dependencies = [ [[package]] name = "lightning-types" version = "0.3.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" +source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" dependencies = [ "bitcoin", ] @@ -2676,7 +2676,7 @@ dependencies = [ [[package]] name = "possiblyrandom" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=8478895388b656b4fa674d1ede4c48f33a9688b4#8478895388b656b4fa674d1ede4c48f33a9688b4" +source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" dependencies = [ "getrandom 0.2.17", ] diff --git a/Cargo.toml b/Cargo.toml index 43b0b8f11e..ca72646de4 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,18 +34,18 @@ default = [] [dependencies] # Pin runtime dependencies directly so downstream consumers use the same native engine. -lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4" } -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4", features = ["std"] } -lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4" } -lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4" } -lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4" } -lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4" } -lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4", features = ["std"] } -lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4" } +lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89" } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89", features = ["std"] } +lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89" } +lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89" } +lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89" } +lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89" } +lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89", features = ["std"] } +lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89" } bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} @@ -81,7 +81,7 @@ prost = { version = "0.11.6", default-features = false} winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "8478895388b656b4fa674d1ede4c48f33a9688b4", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89", features = ["std", "_test_utils"] } proptest = "1.0.0" regex = "1.5.6" criterion = { version = "0.7.0", features = ["async_tokio"] } diff --git a/src/message_handler.rs b/src/message_handler.rs index 89e1286364..64a2e9140a 100644 --- a/src/message_handler.rs +++ b/src/message_handler.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use bitcoin::secp256k1::PublicKey; use lightning::io; -use lightning::ln::msgs::{DecodeError, Init, LightningError}; +use lightning::ln::msgs::{DecodeError, ErrorAction, Init, LightningError}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning::ln::wire::{CustomMessageReader, Type}; use lightning::util::logger::Logger; @@ -26,6 +26,7 @@ use lightning_types::features::{InitFeatures, NodeFeatures}; use crate::liquidity::LiquiditySource; use crate::types::LiquidityManager; +use ffor::setup::FforSetupAdapter; use ffor::{FforFrame, FforReceiverTransport}; /// Independent LSPS and connection-scoped FFOR messages retain their own queue ordering. @@ -60,6 +61,7 @@ where { liquidity: Option, ffor: Option>, + ffor_setup: Option>, } impl NodeCustomMessageHandler { @@ -77,17 +79,26 @@ where H::Target: CustomMessageHandler, { fn new_liquidity_handler(liquidity: H) -> Self { - Self { liquidity: Some(liquidity), ffor: None } + Self { liquidity: Some(liquidity), ffor: None, ffor_setup: None } } pub(crate) fn new_ignoring() -> Self { - Self { liquidity: None, ffor: None } + Self { liquidity: None, ffor: None, ffor_setup: None } } // No production caller enables this seam until a native receiver owns admission and recovery. #[allow(dead_code)] pub(crate) fn with_ffor_receiver(mut self, receiver: Arc) -> Self { self.ffor = Some(receiver); + self.ffor_setup = None; + self + } + + // Explicit opt-in only. There is no builder setting, feature bit or invoice-facing API. + #[allow(dead_code)] + pub(crate) fn with_ffor_setup(mut self, setup: Arc) -> Self { + self.ffor = Some(Arc::clone(setup.transport())); + self.ffor_setup = Some(setup); self } } @@ -128,9 +139,22 @@ where Some(liquidity) => liquidity.handle_custom_message(message, sender), None => Ok(()), }, - NodeCustomMessage::Ffor(message) => match self.ffor.as_ref() { - Some(ffor) => ffor.receive(sender, message), - None => Ok(()), + NodeCustomMessage::Ffor(message) => { + if let Some(setup) = self.ffor_setup.as_ref() { + if matches!(message.type_id(), 55003 | 55049) { + return setup.handle(sender, &message).map(|_| ()).map_err(|error| { + LightningError { + err: format!("FFOR receiver setup: {}", error), + // Do not process following HTLC frames after failed native admission. + action: ErrorAction::DisconnectPeer { msg: None }, + } + }); + } + } + match self.ffor.as_ref() { + Some(ffor) => ffor.receive(sender, message), + None => Ok(()), + } }, } } @@ -173,7 +197,9 @@ where if let Some(liquidity) = self.liquidity.as_ref() { liquidity.peer_connected(peer, init, inbound)?; } - if let Some(ffor) = self.ffor.as_ref() { + if let Some(setup) = self.ffor_setup.as_ref() { + setup.peer_connected(peer); + } else if let Some(ffor) = self.ffor.as_ref() { // Exhausting optional FFOR capacity must not disconnect an ordinary peer. ffor.peer_connected(peer); } diff --git a/src/message_handler/ffor.rs b/src/message_handler/ffor.rs index 4fea6f8b63..bb44ecf477 100644 --- a/src/message_handler/ffor.rs +++ b/src/message_handler/ffor.rs @@ -10,12 +10,15 @@ //! No protocol transition or signature generation exists here, and the production builder leaves //! this transport disabled. +pub(super) mod setup; + use std::collections::{HashMap, VecDeque}; use std::fmt; use std::sync::{Arc, Mutex}; use bitcoin::secp256k1::PublicKey; use lightning::io; +use lightning::ln::ffor::FFORPeerConnection; use lightning::ln::msgs::{DecodeError, ErrorAction, LightningError}; use lightning::ln::wire::Type; use lightning::util::logger::Level; @@ -149,10 +152,19 @@ impl ReceivedFforMessage { struct PeerState { connection: ConnectionToken, + native_connection: Option, messages: usize, bytes: usize, } +/// Both observations belong to the same successful PeerManager callback. Native still checks +/// its generation under the channel lock, and enqueue separately checks the transport token. +#[derive(Clone)] +struct NativeConnection { + native: FFORPeerConnection, + transport: ConnectionToken, +} + #[derive(Default)] struct QueueState { peers: HashMap, @@ -196,17 +208,35 @@ pub(crate) struct FforReceiverTransport { impl FforReceiverTransport { pub(super) fn peer_connected(&self, peer: PublicKey) { + self.connect(peer, None); + } + + fn connect(&self, peer: PublicKey, native_connection: Option) { let mut state = self.state.lock().unwrap(); // Defensive replacement also clears old work if callbacks are repeated without disconnect. state.disconnect(peer); if state.peers.len() < MAX_PEERS { state.peers.insert( peer, - PeerState { connection: ConnectionToken(Arc::new(())), messages: 0, bytes: 0 }, + PeerState { + connection: ConnectionToken(Arc::new(())), + native_connection, + messages: 0, + bytes: 0, + }, ); } } + fn native_connection(&self, peer: PublicKey) -> Option { + let state = self.state.lock().unwrap(); + let entry = state.peers.get(&peer)?; + Some(NativeConnection { + native: entry.native_connection.clone()?, + transport: entry.connection.clone(), + }) + } + pub(super) fn peer_disconnected(&self, peer: PublicKey) { self.state.lock().unwrap().disconnect(peer); } diff --git a/src/message_handler/ffor/setup.rs b/src/message_handler/ffor/setup.rs new file mode 100644 index 0000000000..2acb03ab87 --- /dev/null +++ b/src/message_handler/ffor/setup.rs @@ -0,0 +1,86 @@ +//! Private setup bridge. The concrete native manager owns admission, signatures and persistence. +//! No production builder constructs this bridge, and setup progress grants no invoice authority. + +use std::sync::Arc; + +use bitcoin::secp256k1::PublicKey; +use lightning::ln::ffor::{ + FFORCommitmentError, FFORReceiverError, FFORReceiverId, FFORReceiverParameters, + FFORReceiverProgress, +}; +use lightning::ln::types::ChannelId; + +use super::{FforFrame, FforReceiverTransport, NativeConnection}; +use crate::types::ChannelManager; + +/// Holds no duplicate epoch or wire state. Connection pairs live in the bounded transport map. +pub(crate) struct FforSetupAdapter { + manager: Arc, + transport: Arc, +} + +// The production builder deliberately does not install this experimental adapter yet. +#[allow(dead_code)] +impl FforSetupAdapter { + pub(crate) fn new(manager: Arc, transport: Arc) -> Self { + Self { manager, transport } + } + + pub(in crate::message_handler) fn transport(&self) -> &Arc { + &self.transport + } + + pub(in crate::message_handler) fn peer_connected(&self, peer: PublicKey) { + // PeerManager calls native peer_connected first. Capture with no transport lock held. + // Failure leaves ordinary LSPS available but installs no FFOR connection or authority. + if let Ok(native) = self.manager.ffor_peer_connection(&peer) { + self.transport.connect(native.peer_node_id(), Some(native)); + } + } + + fn connection(&self, peer: PublicKey) -> Result { + self.transport + .native_connection(peer) + .ok_or_else(|| FFORCommitmentError::ChannelUnavailable.into()) + } + + pub(in crate::message_handler) fn handle( + &self, peer: PublicKey, frame: &FforFrame, + ) -> Result { + let connection = self.connection(peer)?; + // Synchronous completion precedes PeerManager's next ordinary HTLC frame. Cloning the + // pair released the transport mutex; only native can accept or reject its generation. + self.manager.handle_ffor_receiver_message(&connection.native, frame.wire()) + } + + pub(crate) fn prepare( + &self, peer: PublicKey, channel: &ChannelId, parameters: FFORReceiverParameters, + ) -> Result { + let connection = self.connection(peer)?; + self.manager.prepare_ffor_receiver(channel, &connection.native, parameters) + } + + pub(crate) fn find( + &self, local_request_id: [u8; 32], + ) -> Result, FFORReceiverError> { + self.manager.find_ffor_receiver_request(local_request_id) + } + + pub(crate) fn advance( + &self, peer: PublicKey, id: &FFORReceiverId, + ) -> Result { + let connection = self.connection(peer)?; + self.manager.advance_ffor_receiver(id, &connection.native, |wire| { + // Native -> transport is the only nested lock order. No I/O or manager reentry. + // Refusal leaves the exact Init and one-shot release permission owned natively. + self.transport.enqueue(peer, &connection.transport, wire).map_err(|_| ()) + }) + } + + pub(crate) fn cancel( + &self, peer: PublicKey, id: &FFORReceiverId, + ) -> Result { + let connection = self.connection(peer)?; + self.manager.cancel_ffor_receiver_setup(id, &connection.native) + } +} diff --git a/src/message_handler/tests.rs b/src/message_handler/tests.rs index a5ce885a15..3d41a597f9 100644 --- a/src/message_handler/tests.rs +++ b/src/message_handler/tests.rs @@ -17,6 +17,8 @@ use proptest::prelude::*; use super::*; +mod setup; + #[derive(Default)] struct LspsHandler { received: Mutex>, diff --git a/src/message_handler/tests/setup.rs b/src/message_handler/tests/setup.rs new file mode 100644 index 0000000000..9642df066c --- /dev/null +++ b/src/message_handler/tests/setup.rs @@ -0,0 +1,226 @@ +//! These tests use actual Node managers and opaque native connections. Native funded-channel +//! tests separately cover synchronous Accept ownership before the immediately following HTLC. + +use bitcoin::Network; +use lightning::ln::ffor::{FFORCommitmentError, FFORReceiverError, FFORReceiverParameters}; +use lightning::ln::msgs::BaseMessageHandler; +use lightning::ln::types::ChannelId; +use lightning_ffor::wire::{Abort, Payload}; + +use super::*; +use crate::builder::NodeBuilder; +use crate::io::test_utils::InMemoryStore; +use crate::message_handler::ffor::setup::FforSetupAdapter; +use crate::types::DynStore; +use crate::{Config, Node}; + +fn node(seed: u8) -> Node { + let mut builder = + NodeBuilder::from_config(Config { network: Network::Regtest, ..Config::default() }); + builder.set_entropy_seed_bytes([seed; 64]); + builder.set_log_facade_logger(); + let store: Arc = Arc::new(InMemoryStore::new()); + builder.build_with_store(store).unwrap() +} + +fn native_init() -> Init { + let mut init = init(); + init.features.set_static_remote_key_optional(); + init +} + +fn setup_frame(name: &str) -> FforFrame { + let fixture = include_str!("../../ffor/witness_store/record/key_use/fixtures.txt"); + let wire = Vec::::from_hex( + fixture.lines().find_map(|line| line.strip_prefix(&format!("{}=", name))).unwrap(), + ) + .unwrap(); + FforFrame::read(u16::from_be_bytes([wire[0], wire[1]]), &mut &wire[2..]).unwrap() +} + +fn signed_abort(seed: u8) -> FforFrame { + let mut message = FforMessage::decode(setup_frame("accept").wire()).unwrap(); + message.payload = + Payload::Abort(Abort { transcript_hash: [0; 32], reason: 1, data: Vec::new() }); + let digest = bitcoin::secp256k1::Message::from_digest(message.signature_digest().unwrap()); + message.signature = Secp256k1::new() + .sign_ecdsa_with_noncedata(&digest, &SecretKey::from_slice(&[seed; 32]).unwrap(), &[1; 32]) + .serialize_compact(); + message.verify_signature(&key(seed)).unwrap(); + parse_frame(&message.encode().unwrap()) +} + +fn adapter(node: &Node) -> (Arc, Arc) { + let transport = Arc::new(FforReceiverTransport::default()); + let setup = + Arc::new(FforSetupAdapter::new(Arc::clone(&node.channel_manager), Arc::clone(&transport))); + (setup, transport) +} + +fn unavailable() -> FFORReceiverError { + FFORCommitmentError::ChannelUnavailable.into() +} + +#[test] +fn ffor_setup_dispatch_is_synchronous_and_propagates_native_error() { + let node = node(71); + let peer = key(72); + let (setup, transport) = adapter(&node); + let handler = NodeCustomMessageHandler::>::new_ignoring() + .with_ffor_setup(Arc::clone(&setup)); + node.channel_manager.peer_connected(peer, &native_init(), false).unwrap(); + handler.peer_connected(peer, &native_init(), false).unwrap(); + let native = node.channel_manager.ffor_peer_connection(&peer).unwrap(); + for frame in [setup_frame("accept"), signed_abort(72)] { + let expected = + node.channel_manager.handle_ffor_receiver_message(&native, frame.wire()).unwrap_err(); + assert_eq!(expected, unavailable()); + assert_eq!(setup.handle(peer, &frame), Err(expected)); + let error = + handler.handle_custom_message(NodeCustomMessage::Ffor(frame), peer).unwrap_err(); + assert_eq!(error.err, format!("FFOR receiver setup: {}", expected)); + assert!(matches!(error.action, ErrorAction::DisconnectPeer { msg: None })); + // A deferred mailbox handler would return Ok and leave the input here. Neither is + // allowed before PeerManager considers delivering a subsequent ordinary frame. + assert!(transport.pop().is_none()); + } + assert!(handler.get_and_clear_pending_msg().is_empty()); +} + +#[test] +fn ffor_setup_rejects_foreign_and_reconnected_native_generations() { + let first = node(73); + let second = node(74); + let peer = key(75); + let (setup, transport) = adapter(&first); + let handler = NodeCustomMessageHandler::>::new_ignoring() + .with_ffor_setup(Arc::clone(&setup)); + first.channel_manager.peer_connected(peer, &native_init(), false).unwrap(); + second.channel_manager.peer_connected(peer, &native_init(), false).unwrap(); + handler.peer_connected(peer, &native_init(), false).unwrap(); + let original = first.channel_manager.ffor_peer_connection(&peer).unwrap(); + let foreign = second.channel_manager.ffor_peer_connection(&peer).unwrap(); + let frame = setup_frame("accept"); + assert_ne!(original, foreign); + assert_eq!( + first.channel_manager.handle_ffor_receiver_message(&foreign, frame.wire()), + Err(unavailable()) + ); + let old_transport = transport.connection(peer).unwrap(); + first.channel_manager.peer_disconnected(peer); + first.channel_manager.peer_connected(peer, &native_init(), false).unwrap(); + let current = first.channel_manager.ffor_peer_connection(&peer).unwrap(); + assert_ne!(original, current); + // Deliberately leave the custom callback behind the native callback. Its retained native + // generation cannot be upgraded by merely checking the peer's public key. + assert_eq!(setup.handle(peer, &frame), Err(unavailable())); + assert_eq!( + first.channel_manager.handle_ffor_receiver_message(&original, frame.wire()), + Err(unavailable()) + ); + handler.peer_connected(peer, &native_init(), false).unwrap(); + assert_ne!(old_transport, transport.connection(peer).unwrap()); + assert_eq!( + transport.enqueue(peer, &old_transport, frame.wire()), + Err(ffor::OutboundError::StaleConnection) + ); + assert!(transport.pop().is_none()); +} + +#[test] +fn ffor_setup_lsps_failure_disconnect_and_missing_native_clear_pairs() { + let node = node(76); + let peer = key(77); + let (setup, transport) = adapter(&node); + let lsps = Arc::new(LspsHandler::default()); + let handler = NodeCustomMessageHandler::new_liquidity_handler(Arc::clone(&lsps)) + .with_ffor_setup(Arc::clone(&setup)); + // A custom callback alone cannot mint a native generation. + handler.peer_connected(peer, &native_init(), false).unwrap(); + assert!(transport.connection(peer).is_none()); + node.channel_manager.peer_connected(peer, &native_init(), false).unwrap(); + handler.peer_connected(peer, &native_init(), false).unwrap(); + let old = transport.connection(peer).unwrap(); + transport.enqueue(peer, &old, &ack_wire(peer)).unwrap(); + handler.handle_custom_message(parse(&handler, &ack_wire(peer)), peer).unwrap(); + lsps.fail_connect.store(true, Ordering::Relaxed); + assert_eq!(handler.peer_connected(peer, &native_init(), false), Err(())); + assert!(transport.connection(peer).is_none()); + assert!(transport.pop().is_none()); + assert!(handler.get_and_clear_pending_msg().is_empty()); + assert_eq!(setup.handle(peer, &setup_frame("accept")), Err(unavailable())); + assert_eq!( + transport.enqueue(peer, &old, &ack_wire(peer)), + Err(ffor::OutboundError::StaleConnection) + ); + lsps.fail_connect.store(false, Ordering::Relaxed); + handler.peer_connected(peer, &native_init(), false).unwrap(); + assert!(transport.connection(peer).is_some()); + handler.peer_disconnected(peer); + assert!(transport.connection(peer).is_none()); + assert_eq!(setup.handle(peer, &setup_frame("accept")), Err(unavailable())); + assert_eq!(*lsps.disconnected.lock().unwrap(), vec![peer]); +} + +#[test] +fn ffor_setup_preserves_witness_unsupported_lifecycle_and_lsps_mailboxes() { + let node = node(78); + let peer = key(79); + let (setup, transport) = adapter(&node); + let lsps = Arc::new(LspsHandler::default()); + let handler = + NodeCustomMessageHandler::new_liquidity_handler(Arc::clone(&lsps)).with_ffor_setup(setup); + node.channel_manager.peer_connected(peer, &native_init(), false).unwrap(); + handler.peer_connected(peer, &native_init(), false).unwrap(); + let (_, _, fetch_response) = witness_messages(); + for frame in + [setup_frame("init"), parse_frame(&ack_wire(peer)), parse_frame(&fetch_response.encode())] + { + let wire = frame.wire().to_vec(); + handler.handle_custom_message(NodeCustomMessage::Ffor(frame), peer).unwrap(); + let received = transport.pop().unwrap(); + assert_eq!(received.peer(), peer); + assert_eq!(received.frame().wire(), wire); + } + let message = RawLSPSMessage { payload: "{\"id\":1}".into() }; + handler.handle_custom_message(NodeCustomMessage::Liquidity(message.clone()), peer).unwrap(); + assert_eq!(*lsps.received.lock().unwrap(), vec![(peer, message)]); + assert_eq!(handler.provided_node_features(), lsps.provided_node_features()); + assert_eq!(handler.provided_init_features(peer), lsps.provided_init_features(peer)); +} + +fn parse_frame(wire: &[u8]) -> FforFrame { + FforFrame::read(u16::from_be_bytes([wire[0], wire[1]]), &mut &wire[2..]).unwrap() +} + +#[test] +fn ffor_setup_intent_delegation_preserves_native_refusal_and_no_request() { + let node = node(80); + let peer = key(81); + let (setup, transport) = adapter(&node); + let parameters = FFORReceiverParameters { + local_request_id: [1; 32], + amounts_msat: vec![100_000], + minimum_payment_msat: 100_000, + settlement_deadline: 100, + voucher_expiry: 144, + fee_base_msat: 0, + fee_proportional_millionths: 0, + claim_margin_blocks: 20, + witness_peers: None, + hash_chain: false, + }; + assert_eq!(setup.prepare(peer, &ChannelId([2; 32]), parameters.clone()), Err(unavailable())); + node.channel_manager.peer_connected(peer, &native_init(), false).unwrap(); + setup.peer_connected(peer); + let connection = node.channel_manager.ffor_peer_connection(&peer).unwrap(); + let expected = node.channel_manager.prepare_ffor_receiver( + &ChannelId([2; 32]), + &connection, + parameters.clone(), + ); + assert_eq!(setup.prepare(peer, &ChannelId([2; 32]), parameters), expected); + assert_eq!(setup.find([1; 32]), Ok(None)); + assert!(transport.pop().is_none()); + assert!(transport.drain_outbound().is_empty()); +} From 3a4fb583e81d4cd61a95a176b7412aee56217edc Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 21 Sep 2026 00:50:57 -0400 Subject: [PATCH 13/23] feat(ffor): retain durable witness acknowledgements --- FFOR.md | 38 ++- src/ffor/witness_store.rs | 49 ++- src/ffor/witness_store/envelope.rs | 15 +- src/ffor/witness_store/record.rs | 90 +++++- .../witness_store/record/key_use/tests.rs | 1 + src/ffor/witness_store/tests.rs | 297 +++++++++++++++++- 6 files changed, 469 insertions(+), 21 deletions(-) diff --git a/FFOR.md b/FFOR.md index 3d1bb15c97..6ab38c6a67 100644 --- a/FFOR.md +++ b/FFOR.md @@ -29,8 +29,18 @@ and peer callbacks. An optional private FFOR receiver can parse the seven suppor signed lifecycle types, witness acknowledgement type 55057 and fetch response type 55061 through the shared canonical codecs. Its bounded outbox accepts exact wire messages from a future native-authorized release callback. The production builder leaves this transport -disabled. This slice has no operational sender, feature advertisement, protocol -transition or public setting. +disabled. This slice has no feature advertisement or public setting. + +The private setup adapter uses the concrete native manager and pairs its opaque +authenticated generation with the transport token in the same bounded peer map. +Accept and pre-accept Abort are processed synchronously before the custom callback +returns, so following ordinary HTLC frames cannot overtake native voucher ownership. +A native error requests disconnection and never falls back to the mailbox. Other +unsupported lifecycle inputs and witness replies remain bounded queued work. Failed +LSPS connection callbacks, disconnect and replacement clear both tokens and queues. +Preparation, exact Init release, retry lookup and cancellation delegate to native +authority. No builder constructs this adapter yet; activation and recovery orchestration +remain necessary before offering offline invoices. The transport accepts peer identity only from PeerManager's authenticated callback. Each successful connection gets a distinct opaque token. Disconnect or replacement @@ -102,13 +112,27 @@ and [ChaCha20Poly1305](https://www.rfc-editor.org/rfc/rfc8439). One exclusive owner serializes the namespace. Admission is bounded to four witnesses per epoch, 512 KiB per encrypted record, 64 epochs and 8 MiB overall. Records are not -evicted or replaced to admit new work. A failed write blocks that owner until the +evicted to admit new work. Keys and signed manifests cannot be replaced. A failed write blocks that owner until the exact retained ciphertext is written successfully. Readable bytes alone do not prove durability: a filesystem rename can become visible before directory synchronization succeeds. Reopening likewise requires an authenticated, byte-identical successful rewrite before returning restored material. No retry creates replacement keys or manifests for an existing record. +The store also retains each selected witness's first exactly correlated provisioning +acknowledgement. It rechecks the complete immutable manifest and retention promise +before sealing the update. Subsequent requests cannot replace the first promise. +Schema 2 reserves fixed acknowledgement slots when keys are created, so recording +all promises does not grow the record. Schema 1 loads with no promises; its first +update upgrades the format only if the existing global capacity permits it. + +A failed update retains the exact proposed ciphertext and its original predecessor. +Retry accepts only those bytes and always requires another successful write. A deleted +existing record or conflicting replacement blocks recovery. Restored promises likewise +require successful durability confirmation before being returned. Historical promises +from every selected witness are necessary storage evidence, not current native +provisioning authority or invoice readiness. + The threat boundary includes untrusted peers, malformed or altered storage, failed entropy, interrupted writes and concurrent callers within one owner. Store success is the backend's durability contract. Authenticated encryption does not detect a @@ -122,13 +146,15 @@ identity, 256-bit nonce and signature entropy, including retries after a lost re or restart. The witness still enforces replay refusal. Decryption authenticates the retained witness and manifest before native AEAD and body checks. These helpers do not correlate a response connection, persist a receipt, change a channel or credit a -payment. Witness acknowledgement persistence and runtime recovery remain separate work. +payment. Witness transport orchestration, durable payment-receipt retention and native +monitor reconciliation remain separate work. Run `cargo test --lib ffor_witness` for exact retries and restart, failed writes with readable bytes, corruption, wrong seeds and bindings, key separation, capacity, concurrent creation, fallible entropy, truncated records and property-based mutation -checks, fresh fetch authorization after restart and protected decryption of pinned -Beignet records. These checks do not establish complete branch coverage or process-crash +checks, fresh fetch authorization after restart, protected decryption of pinned +Beignet records, exact acknowledgement retention, concurrent acknowledgements, failed +updates and sealed legacy-record upgrades. These checks do not establish complete branch coverage or process-crash interoperability. ## Required runtime integration diff --git a/src/ffor/witness_store.rs b/src/ffor/witness_store.rs index 2aacc5597e..f223c12297 100644 --- a/src/ffor/witness_store.rs +++ b/src/ffor/witness_store.rs @@ -1,10 +1,11 @@ -//! Immutable per-epoch witness secrets sealed before they can leave the storage boundary. +//! Immutable per-epoch witness secrets and monotonic provisioning promises, sealed before use. //! //! One exclusive owner serializes all operations for this namespace. KVStore has no cross-process //! compare-and-swap, so concurrent owners are unsupported. Store success is the durability contract; //! authenticated encryption does not detect rollback to an older valid store. Opaque native history -//! supplies record bindings. Live native registration, fetch response correlation, transport, witness -//! acknowledgement handling and deletion are deliberately absent. +//! supplies record bindings. Live native registration, fetch response correlation, transport and +//! deletion are deliberately absent. Retained acknowledgements are historical promises, not current +//! provisioning authority or invoice readiness. //! A future native registration must distinguish a new epoch from a missing sidecar after restore; //! `load` never recreates secrets, and `create` is only for explicitly new registration material. //! @@ -17,6 +18,7 @@ use std::sync::{Arc, Mutex}; use bitcoin::hashes::{sha256, Hash, HashEngine}; use lightning::util::persist::KVStoreSync; +use lightning_ffor::witness::CheckedAcknowledgement; use crate::types::DynStore; @@ -57,6 +59,9 @@ pub(crate) enum WitnessStoreError { struct PendingWrite { key: String, bytes: Vec, + // Only this exact predecessor may remain after a failed update. Absence is permitted only + // for creation, never for an update to already retained recovery material. + previous: Option>, } struct StoreState { @@ -155,6 +160,29 @@ impl WitnessSecretStore { self.load_locked(&mut state, binding) } + /// Durably retain a correlated witness promise for the exact stored manifest. + /// The caller owns real transport correlation and current native provisioning authority. + /// Neither a returned record nor all historical promises authorize an offline invoice. + pub(super) fn acknowledge( + &self, binding: &WitnessStorageBinding, checked: &CheckedAcknowledgement, + ) -> Result { + let mut state = self.state.lock().map_err(|_| WitnessStoreError::Uncertain)?; + ensure_certain(&state)?; + let mut record = self.load_locked(&mut state, binding)?; + if !record.retain_acknowledgement(checked)? { + return Ok(record); + } + let key = binding.storage_key(); + let bytes = self.wrapping_key.seal(binding, &record)?; + let previous = state.records.get(&key).ok_or(WitnessStoreError::Missing)?; + let total = state.records.values().map(Vec::len).sum::() - previous.len(); + if total.checked_add(bytes.len()).filter(|sum| *sum <= MAX_STORE_BYTES).is_none() { + return Err(WitnessStoreError::Capacity); + } + self.confirm_write(&mut state, key, bytes)?; + Ok(record) + } + fn load_locked( &self, state: &mut StoreState, binding: &WitnessStorageBinding, ) -> Result { @@ -174,7 +202,14 @@ impl WitnessSecretStore { fn confirm_write( &self, state: &mut StoreState, key: String, bytes: Vec, ) -> Result<(), WitnessStoreError> { - state.uncertain = Some(PendingWrite { key: key.clone(), bytes: bytes.clone() }); + // Preserve the original predecessor through retries, including when the proposed new + // ciphertext became visible but the backend reported a failed sync. + let previous = state + .uncertain + .as_ref() + .and_then(|pending| pending.previous.clone()) + .or_else(|| state.records.get(&key).cloned()); + state.uncertain = Some(PendingWrite { key: key.clone(), bytes: bytes.clone(), previous }); write(&*self.storage, &key, bytes.clone())?; state.confirmed.insert(key.clone()); state.records.insert(key, bytes); @@ -182,7 +217,8 @@ impl WitnessSecretStore { Ok(()) } - /// Reconciles an uncertain write, accepting only identical ciphertext or an absent key. + /// Reconciles an uncertain write, accepting only its exact candidate or predecessor. + /// An absent key is acceptable only for creation, never for an existing record update. /// Always rewrites retained ciphertext: visibility through read does not establish durability. /// No fresh keys, signatures or encryption nonces are allocated. /// A read error, conflict, or another failed write leaves the owner blocked. @@ -194,8 +230,9 @@ impl WitnessSecretStore { }; match read(&*self.storage, &pending.key) { Ok(bytes) if bytes == pending.bytes => {}, + Ok(bytes) if pending.previous.as_ref() == Some(&bytes) => {}, Ok(_) => return Err(WitnessStoreError::Conflict), - Err(WitnessStoreError::Missing) => {}, + Err(WitnessStoreError::Missing) if pending.previous.is_none() => {}, Err(error) => return Err(error), } let key = pending.key.clone(); diff --git a/src/ffor/witness_store/envelope.rs b/src/ffor/witness_store/envelope.rs index e5a9f76bca..be58d03d22 100644 --- a/src/ffor/witness_store/envelope.rs +++ b/src/ffor/witness_store/envelope.rs @@ -26,12 +26,18 @@ impl WrappingKey { pub(super) fn seal( &self, binding: &WitnessStorageBinding, record: &StoredWitnessEpoch, + ) -> Result, WitnessStoreError> { + let plaintext = record.encode(); + self.seal_plaintext(binding, &plaintext) + } + + fn seal_plaintext( + &self, binding: &WitnessStorageBinding, plaintext: &[u8], ) -> Result, WitnessStoreError> { let nonce = random_bytes::<12>()?; let builder = StorableBuilder::new(EnvelopeNonce(nonce)); let mut bytes = header(binding); let aad = associated_data(binding, &bytes); - let plaintext = record.encode(); // Our buffer is zeroized. StorableBuilder owns additional plaintext allocations during // encryption and decryption which it does not zeroize. No total erasure is claimed. let sealed = builder.build(plaintext.to_vec(), i64::from(SCHEMA_VERSION), &self.0, &aad); @@ -40,6 +46,13 @@ impl WrappingKey { Ok(bytes) } + #[cfg(test)] + pub(super) fn seal_plaintext_fixture( + &self, binding: &WitnessStorageBinding, plaintext: &[u8], + ) -> Result, WitnessStoreError> { + self.seal_plaintext(binding, plaintext) + } + pub(super) fn open( &self, binding: &WitnessStorageBinding, bytes: &[u8], ) -> Result { diff --git a/src/ffor/witness_store/record.rs b/src/ffor/witness_store/record.rs index d907e799c1..8908b31e23 100644 --- a/src/ffor/witness_store/record.rs +++ b/src/ffor/witness_store/record.rs @@ -2,15 +2,22 @@ use std::fmt; use bitcoin::secp256k1::{Message, PublicKey, Secp256k1, SecretKey}; use lightning::ln::ffor::FFORWitnessDecryptionError; -use lightning_ffor::witness::{ManifestParameters, SignedManifest, UnsignedManifest, WitnessError}; +use lightning_ffor::witness::{ + CheckedAcknowledgement, ManifestParameters, SignedManifest, UnsignedManifest, WitnessError, +}; use rand::rngs::OsRng; use rand::TryRngCore; use zeroize::Zeroizing; -use super::{WitnessStorageBinding, WitnessStoreError, MAX_WITNESSES, SCHEMA_VERSION}; +use super::{WitnessStorageBinding, WitnessStoreError, MAX_WITNESSES}; mod key_use; +const RECORD_VERSION: u16 = 2; +const LEGACY_RECORD_VERSION: u16 = 1; +// Reserved even before provisioning so each acknowledgement fits without growing a new record. +const ACKNOWLEDGEMENT_BYTES: usize = 1 + 16 + 4; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum WitnessKeyUseError { UnknownWitness, @@ -32,6 +39,13 @@ struct WitnessKeys { policy: WitnessPolicy, fetch_secret: Zeroizing<[u8; 32]>, manifest: SignedManifest, + acknowledgement: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct RetainedAcknowledgement { + request_id: [u8; 16], + retention_until: u32, } /// Immutable material recovered from one successful store write, not proof of native activation. @@ -95,7 +109,12 @@ impl StoredWitnessEpoch { secret.erase(); let manifest = unsigned.authenticate(signature).map_err(|_| WitnessStoreError::Corrupt)?; - witnesses.push(WitnessKeys { policy: *policy, fetch_secret, manifest }); + witnesses.push(WitnessKeys { + policy: *policy, + fetch_secret, + manifest, + acknowledgement: None, + }); } Ok(Self { binding_digest: binding.digest(), encryption_secret, witnesses }) } @@ -111,13 +130,48 @@ impl StoredWitnessEpoch { self.witnesses.iter().map(|entry| entry.policy).collect() } + /// Historical storage promises only. The native owner still controls current invoice readiness. + pub(super) fn all_witnesses_acknowledged(&self) -> bool { + !self.witnesses.is_empty() + && self.witnesses.iter().all(|entry| entry.acknowledgement.is_some()) + } + + /// Retain the first correlated promise for this exact witness and immutable manifest. + /// A later reprovision may use a new request ID; it cannot replace the original evidence. + pub(super) fn retain_acknowledgement( + &mut self, checked: &CheckedAcknowledgement, + ) -> Result { + let entry = self + .witnesses + .iter_mut() + .find(|entry| entry.policy.witness == checked.connection().node_id) + .ok_or(WitnessStoreError::Binding)?; + if checked.provision().manifest() != &entry.manifest + || checked.retention_until() < entry.policy.retention_until + { + return Err(WitnessStoreError::Binding); + } + if entry.acknowledgement.is_some() { + return Ok(false); + } + entry.acknowledgement = Some(RetainedAcknowledgement { + request_id: checked.provision().request_id(), + retention_until: checked.retention_until(), + }); + Ok(true) + } + pub(super) fn encode(&self) -> Zeroizing> { let manifests: Vec<_> = self.witnesses.iter().map(|entry| entry.manifest.encode()).collect(); // Reserve the exact size before copying secrets, avoiding freed intermediate allocations. - let capacity = 67 + manifests.iter().map(|manifest| 69 + manifest.len()).sum::(); + let capacity = 67 + + manifests + .iter() + .map(|manifest| 69 + manifest.len() + ACKNOWLEDGEMENT_BYTES) + .sum::(); let mut bytes = Zeroizing::new(Vec::with_capacity(capacity)); - bytes.extend_from_slice(&SCHEMA_VERSION.to_be_bytes()); + bytes.extend_from_slice(&RECORD_VERSION.to_be_bytes()); bytes.extend_from_slice(&self.binding_digest); bytes.extend_from_slice(self.encryption_secret.as_ref()); bytes.push(self.witnesses.len() as u8); @@ -126,6 +180,13 @@ impl StoredWitnessEpoch { bytes.extend_from_slice(entry.fetch_secret.as_ref()); bytes.extend_from_slice(&(manifest.len() as u32).to_be_bytes()); bytes.extend_from_slice(&manifest); + if let Some(acknowledgement) = entry.acknowledgement { + bytes.push(1); + bytes.extend_from_slice(&acknowledgement.request_id); + bytes.extend_from_slice(&acknowledgement.retention_until.to_be_bytes()); + } else { + bytes.extend_from_slice(&[0; ACKNOWLEDGEMENT_BYTES]); + } } bytes } @@ -134,7 +195,8 @@ impl StoredWitnessEpoch { binding: &WitnessStorageBinding, bytes: &[u8], ) -> Result { let mut reader = Reader(bytes); - if u16::from_be_bytes(reader.array()?) != SCHEMA_VERSION + let version = u16::from_be_bytes(reader.array()?); + if !matches!(version, LEGACY_RECORD_VERSION | RECORD_VERSION) || reader.array::<32>()? != binding.digest() { return Err(WitnessStoreError::Binding); @@ -175,7 +237,21 @@ impl StoredWitnessEpoch { retention_until: params.retention_until, minimum_receipts: params.minimum_receipts, }; - witnesses.push(WitnessKeys { policy, fetch_secret, manifest }); + let acknowledgement = if version == RECORD_VERSION { + let present = reader.array::<1>()?[0]; + let request_id = reader.array()?; + let retention_until = u32::from_be_bytes(reader.array()?); + match present { + 0 if request_id == [0; 16] && retention_until == 0 => None, + 1 if retention_until >= policy.retention_until => { + Some(RetainedAcknowledgement { request_id, retention_until }) + }, + _ => return Err(WitnessStoreError::Corrupt), + } + } else { + None + }; + witnesses.push(WitnessKeys { policy, fetch_secret, manifest, acknowledgement }); } if !reader.0.is_empty() { return Err(WitnessStoreError::Corrupt); diff --git a/src/ffor/witness_store/record/key_use/tests.rs b/src/ffor/witness_store/record/key_use/tests.rs index 270ce3f069..36d78219eb 100644 --- a/src/ffor/witness_store/record/key_use/tests.rs +++ b/src/ffor/witness_store/record/key_use/tests.rs @@ -59,6 +59,7 @@ impl Fixture<'_> { binding_digest: [1; 32], encryption_secret: Zeroizing::new([44; 32]), witnesses: vec![WitnessKeys { + acknowledgement: None, policy, fetch_secret: Zeroizing::new([42; 32]), manifest, diff --git a/src/ffor/witness_store/tests.rs b/src/ffor/witness_store/tests.rs index 17b77c46e8..3d42dbd552 100644 --- a/src/ffor/witness_store/tests.rs +++ b/src/ffor/witness_store/tests.rs @@ -9,6 +9,10 @@ use lightning::util::persist::{KVStore, KVStoreSync}; use lightning_ffor::setup::AuthenticatedSetup; use lightning_ffor::transcript; use lightning_ffor::wire::{Accept, Activate, Header, Init, Message, Payload}; +use lightning_ffor::witness::{ + Acknowledgement, AcknowledgementResult, CheckedAcknowledgement, PendingProvision, Provision, + WitnessConnection, +}; use proptest::prelude::*; use crate::io::test_utils::InMemoryStore; @@ -176,6 +180,295 @@ fn policies() -> Vec { ] } +fn checked_acknowledgement( + record: &StoredWitnessEpoch, witness: PublicKey, id: u8, +) -> CheckedAcknowledgement { + checked_manifest_acknowledgement(record.manifest(witness).unwrap().clone(), witness, id) +} + +fn checked_manifest_acknowledgement( + manifest: lightning_ffor::witness::SignedManifest, witness: PublicKey, id: u8, +) -> CheckedAcknowledgement { + let retention_until = manifest.unsigned().parameters().retention_until; + let source = WitnessConnection { node_id: witness, identity: u64::from(id) }; + let pending = PendingProvision::new(Provision::new([id; 16], manifest), source.clone()); + let response = Acknowledgement::new( + [id; 16], + AcknowledgementResult::Accepted { witness, retention_until }, + ) + .unwrap(); + pending.check_acknowledgement(&response, &source).unwrap() +} + +fn legacy_plaintext(record: &StoredWitnessEpoch) -> zeroize::Zeroizing> { + let encoded = record.encode(); + let mut legacy = zeroize::Zeroizing::new(encoded[..67].to_vec()); + legacy[..2].copy_from_slice(&1u16.to_be_bytes()); + let mut offset = 67; + for _ in record.policies() { + let length = + u32::from_be_bytes(encoded[offset + 65..offset + 69].try_into().unwrap()) as usize; + legacy.extend_from_slice(&encoded[offset..offset + 69 + length]); + offset += 69 + length + 21; + } + assert_eq!(offset, encoded.len()); + legacy +} + +#[test] +fn ffor_witness_store_acknowledgements_are_durable_monotonic_and_preserve_keys() { + let backend = TestStore::new(); + let binding = binding(1); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let initial = store.create(&binding, &policies()).unwrap(); + let initial_bytes = backend.bytes(&binding.storage_key()); + assert!(!initial.all_witnesses_acknowledged()); + let first = store.acknowledge(&binding, &checked_acknowledgement(&initial, key(2), 1)).unwrap(); + assert!(!first.all_witnesses_acknowledged()); + let complete = + store.acknowledge(&binding, &checked_acknowledgement(&first, key(3), 2)).unwrap(); + assert!(complete.all_witnesses_acknowledged()); + let complete_bytes = backend.bytes(&binding.storage_key()); + assert_eq!(initial_bytes.len(), complete_bytes.len()); + assert_ne!(initial_bytes, complete_bytes); + for policy in policies() { + assert_eq!(initial.manifest(policy.witness), complete.manifest(policy.witness)); + } + // Reprovisioning uses a new connection/request, but retains the first valid promise. + let repeated = + store.acknowledge(&binding, &checked_acknowledgement(&initial, key(2), 3)).unwrap(); + assert_eq!(*repeated.encode(), *complete.encode()); + assert_eq!(backend.bytes(&binding.storage_key()), complete_bytes); + assert_eq!(backend.writes.load(Ordering::SeqCst), 3); + drop(store); + let restored = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let loaded = restored.load(&binding).unwrap(); + assert!(loaded.all_witnesses_acknowledged()); + assert_eq!(*loaded.encode(), *complete.encode()); + assert_eq!(backend.bytes(&binding.storage_key()), complete_bytes); + assert_eq!(backend.writes.load(Ordering::SeqCst), 4); +} + +#[test] +fn ffor_witness_store_acknowledgement_requires_exact_manifest_and_selected_witness() { + let backend = TestStore::new(); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let original = store.create(&binding(1), &policies()).unwrap(); + let new_keys = + StoredWitnessEpoch::generate(&binding(1), &record::checked_policies(&policies()).unwrap()) + .unwrap(); + let other_epoch = + StoredWitnessEpoch::generate(&binding(2), &record::checked_policies(&policies()).unwrap()) + .unwrap(); + let wrong_witness = + checked_manifest_acknowledgement(original.manifest(key(2)).unwrap().clone(), key(9), 1); + for ack in [ + checked_acknowledgement(&new_keys, key(2), 1), + checked_acknowledgement(&other_epoch, key(2), 1), + wrong_witness, + ] { + assert_eq!(store.acknowledge(&binding(1), &ack).unwrap_err(), WitnessStoreError::Binding); + } + assert!(!store.load(&binding(1)).unwrap().all_witnesses_acknowledged()); + assert_eq!(backend.writes.load(Ordering::SeqCst), 1); +} + +#[test] +fn ffor_witness_store_acknowledgement_uncertain_update_reuses_exact_ciphertext() { + for failure in [1, 2] { + let backend = TestStore::new(); + let binding = binding(1); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let initial = store.create(&binding, &policies()).unwrap(); + let before = backend.bytes(&binding.storage_key()); + let ack = checked_acknowledgement(&initial, key(2), 1); + backend.write_failure.store(failure, Ordering::SeqCst); + assert_eq!(store.acknowledge(&binding, &ack).unwrap_err(), WitnessStoreError::Storage); + let candidate = store.state.lock().unwrap().uncertain.as_ref().unwrap().bytes.clone(); + assert_eq!( + backend.bytes(&binding.storage_key()), + if failure == 1 { before } else { candidate.clone() } + ); + assert_eq!(store.load(&binding).unwrap_err(), WitnessStoreError::Uncertain); + assert_eq!(store.acknowledge(&binding, &ack).unwrap_err(), WitnessStoreError::Uncertain); + backend.write_failure.store(2, Ordering::SeqCst); + assert_eq!(store.recover_write(), Err(WitnessStoreError::Storage)); + assert_eq!(backend.bytes(&binding.storage_key()), candidate); + store.recover_write().unwrap(); + assert_eq!(backend.bytes(&binding.storage_key()), candidate); + let first = store.load(&binding).unwrap(); + assert_ne!(*first.encode(), *initial.encode()); + assert!(!first.all_witnesses_acknowledged()); + assert_eq!(first.manifest(key(2)), initial.manifest(key(2))); + assert_eq!(backend.writes.load(Ordering::SeqCst), 4); + } +} + +#[test] +fn ffor_witness_store_acknowledgement_recovery_refuses_deleted_or_replaced_predecessor() { + let backend = TestStore::new(); + let binding = binding(1); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let initial = store.create(&binding, &policies()).unwrap(); + backend.write_failure.store(1, Ordering::SeqCst); + assert!(store.acknowledge(&binding, &checked_acknowledgement(&initial, key(2), 1)).is_err()); + let candidate = store.state.lock().unwrap().uncertain.as_ref().unwrap().bytes.clone(); + KVStoreSync::remove( + &*backend, + PRIMARY_NAMESPACE, + SECONDARY_NAMESPACE, + &binding.storage_key(), + false, + ) + .unwrap(); + assert_eq!(store.recover_write(), Err(WitnessStoreError::Missing)); + assert_eq!(store.load(&binding).unwrap_err(), WitnessStoreError::Uncertain); + let mut changed = candidate.clone(); + changed[50] ^= 1; + backend.replace(&binding.storage_key(), changed.clone()); + assert_eq!(store.recover_write(), Err(WitnessStoreError::Conflict)); + assert_eq!(backend.bytes(&binding.storage_key()), changed); + backend.replace(&binding.storage_key(), candidate); + store.recover_write().unwrap(); + assert!(store.load(&binding).is_ok()); +} + +#[test] +fn ffor_witness_store_acknowledgement_visible_update_requires_confirmation_after_restart() { + let backend = TestStore::new(); + let binding = binding(1); + let candidate; + { + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let initial = store.create(&binding, &policies()[..1]).unwrap(); + backend.write_failure.store(2, Ordering::SeqCst); + assert!(store + .acknowledge(&binding, &checked_acknowledgement(&initial, key(2), 1)) + .is_err()); + candidate = backend.bytes(&binding.storage_key()); + } + for _ in 0..3 { + let restored = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + backend.write_failure.store(2, Ordering::SeqCst); + assert_eq!(restored.load(&binding).unwrap_err(), WitnessStoreError::Storage); + assert_eq!(restored.load(&binding).unwrap_err(), WitnessStoreError::Uncertain); + assert_eq!(backend.bytes(&binding.storage_key()), candidate); + } + let restored = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + assert!(restored.load(&binding).unwrap().all_witnesses_acknowledged()); + assert_eq!(backend.bytes(&binding.storage_key()), candidate); +} + +#[test] +fn ffor_witness_store_acknowledgement_encoding_and_legacy_upgrade_are_strict() { + let binding = binding(1); + let mut record = + StoredWitnessEpoch::generate(&binding, &record::checked_policies(&policies()).unwrap()) + .unwrap(); + let encoded = record.encode(); + // Legacy schema has no acknowledgement slots. Upgrade keeps every protected key/manifest. + let legacy = legacy_plaintext(&record); + let upgraded = StoredWitnessEpoch::decode(&binding, &legacy).unwrap(); + assert!(!upgraded.all_witnesses_acknowledged()); + assert_eq!(*upgraded.encode(), *encoded); + for offset in [encoded.len() - 21, encoded.len() - 20, encoded.len() - 1] { + let mut damaged = encoded.clone(); + damaged[offset] = 2; + assert!(StoredWitnessEpoch::decode(&binding, &damaged).is_err()); + } + let last = record.policies().last().unwrap().witness; + let ack = checked_acknowledgement(&record, last, 1); + assert!(record.retain_acknowledgement(&ack).unwrap()); + let mut too_short = record.encode(); + let length = too_short.len(); + too_short[length - 4..].copy_from_slice(&2000u32.to_be_bytes()); + assert!(StoredWitnessEpoch::decode(&binding, &too_short).is_err()); + assert_eq!(record.encode().len(), encoded.len()); +} + +#[test] +fn ffor_witness_store_sealed_legacy_ack_upgrade_handles_failure_and_capacity() { + for failure in [1, 2] { + let backend = TestStore::new(); + let binding = binding(1); + let original = StoredWitnessEpoch::generate(&binding, &policies()[..1]).unwrap(); + let legacy = WrappingKey::derive(&[5; 64]) + .seal_plaintext_fixture(&binding, &legacy_plaintext(&original)) + .unwrap(); + backend.replace(&binding.storage_key(), legacy.clone()); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let loaded = store.load(&binding).unwrap(); + assert_eq!(*loaded.encode(), *original.encode()); + assert_eq!(backend.bytes(&binding.storage_key()), legacy); + backend.write_failure.store(failure, Ordering::SeqCst); + assert_eq!( + store.acknowledge(&binding, &checked_acknowledgement(&loaded, key(2), 1)).unwrap_err(), + WitnessStoreError::Storage + ); + let candidate = store.state.lock().unwrap().uncertain.as_ref().unwrap().bytes.clone(); + assert!(candidate.len() > legacy.len()); + store.recover_write().unwrap(); + drop(store); + let restored = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let complete = restored.load(&binding).unwrap(); + assert!(complete.all_witnesses_acknowledged()); + assert_eq!(complete.manifest(key(2)), original.manifest(key(2))); + assert_eq!(backend.bytes(&binding.storage_key()), candidate); + } + let backend = TestStore::new(); + let binding = binding(1); + let original = StoredWitnessEpoch::generate(&binding, &policies()[..1]).unwrap(); + let legacy = WrappingKey::derive(&[5; 64]) + .seal_plaintext_fixture(&binding, &legacy_plaintext(&original)) + .unwrap(); + backend.replace(&binding.storage_key(), legacy.clone()); + let mut remaining = MAX_STORE_BYTES - legacy.len(); + for index in 0..MAX_EPOCHS { + if remaining == 0 { + break; + } + let key = format!("f{index:063x}"); + assert_ne!(key, binding.storage_key()); + let count = remaining.min(MAX_RECORD_BYTES); + backend.replace(&key, vec![9; count]); + remaining -= count; + } + assert_eq!(remaining, 0); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let loaded = store.load(&binding).unwrap(); + assert_eq!( + store.acknowledge(&binding, &checked_acknowledgement(&loaded, key(2), 1)).unwrap_err(), + WitnessStoreError::Capacity + ); + assert!(!store.load(&binding).unwrap().all_witnesses_acknowledged()); + assert_eq!(backend.bytes(&binding.storage_key()), legacy); + assert_eq!(backend.writes.load(Ordering::SeqCst), 1); + assert!(store.state.lock().unwrap().uncertain.is_none()); +} + +#[test] +fn ffor_witness_store_concurrent_acknowledgements_do_not_lose_a_promise() { + let backend = TestStore::new(); + let binding = binding(1); + let store = Arc::new(WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap()); + let initial = store.create(&binding, &policies()).unwrap(); + let tasks: Vec<_> = [key(2), key(3)] + .into_iter() + .enumerate() + .map(|(index, witness)| { + let ack = checked_acknowledgement(&initial, witness, index as u8); + let store = Arc::clone(&store); + let binding = binding.clone(); + std::thread::spawn(move || store.acknowledge(&binding, &ack).unwrap()) + }) + .collect(); + for task in tasks { + task.join().unwrap(); + } + assert!(store.load(&binding).unwrap().all_witnesses_acknowledged()); + assert_eq!(backend.writes.load(Ordering::SeqCst), 3); +} + #[test] fn ffor_witness_store_exact_manifests_survive_retry_and_restart() { let backend = TestStore::new(); @@ -531,7 +824,9 @@ proptest! { fn ffor_witness_store_bounded_plaintext_never_panics(bytes in prop::collection::vec(any::(), 0..2048)) { let binding = binding(1); if let Ok(record) = StoredWitnessEpoch::decode(&binding, &bytes) { - prop_assert_eq!(&*record.encode(), &bytes); + let canonical = record.encode(); + let repeated = StoredWitnessEpoch::decode(&binding, &canonical).unwrap(); + prop_assert_eq!(&*repeated.encode(), &*canonical); } } From fc5c7871fa494c6e08c4ce88f2781ef170e58d6b Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 21 Sep 2026 01:26:16 -0400 Subject: [PATCH 14/23] feat: reserve and retain encrypted witness receipts --- src/ffor/witness_store.rs | 336 ++++++++++-- src/ffor/witness_store/envelope.rs | 113 ++-- src/ffor/witness_store/receipt.rs | 244 +++++++++ src/ffor/witness_store/receipt/README.md | 32 ++ .../receipt/activation-fixtures.txt | 7 + .../witness_store/receipt/equivocation.txt | 1 + .../receipt/generate_equivocation.cjs | 23 + src/ffor/witness_store/receipt/tests.rs | 497 ++++++++++++++++++ src/ffor/witness_store/record.rs | 98 +++- .../witness_store/record/key_use/tests.rs | 1 + src/ffor/witness_store/tests.rs | 98 ++-- 11 files changed, 1321 insertions(+), 129 deletions(-) create mode 100644 src/ffor/witness_store/receipt.rs create mode 100644 src/ffor/witness_store/receipt/README.md create mode 100644 src/ffor/witness_store/receipt/activation-fixtures.txt create mode 100644 src/ffor/witness_store/receipt/equivocation.txt create mode 100644 src/ffor/witness_store/receipt/generate_equivocation.cjs create mode 100644 src/ffor/witness_store/receipt/tests.rs diff --git a/src/ffor/witness_store.rs b/src/ffor/witness_store.rs index f223c12297..85d8a4c37b 100644 --- a/src/ffor/witness_store.rs +++ b/src/ffor/witness_store.rs @@ -1,14 +1,19 @@ //! Immutable per-epoch witness secrets and monotonic provisioning promises, sealed before use. //! -//! One exclusive owner serializes all operations for this namespace. KVStore has no cross-process +//! One exclusive owner serializes the secret and encrypted-receipt namespaces. KVStore has no cross-process //! compare-and-swap, so concurrent owners are unsupported. Store success is the durability contract; //! authenticated encryption does not detect rollback to an older valid store. Opaque native history //! supplies record bindings. Live native registration, fetch response correlation, transport and -//! deletion are deliberately absent. Retained acknowledgements are historical promises, not current +//! native claim reconciliation and deletion are deliberately absent. Retained acknowledgements are historical promises, not current //! provisioning authority or invoice readiness. //! A future native registration must distinguish a new epoch from a missing sidecar after restore; //! `load` never recreates secrets, and `create` is only for explicitly new registration material. //! +//! Fresh material reserves the exact full receipt envelope before any write. Pending initialization +//! retains its quota across restart; Reserved records with missing receipts refuse recovery. Legacy +//! records remain historical-only. Evidence keeps the first authenticated encrypted core per W/slot, +//! without guardian attachments, plaintext preimages, eviction or inferred unpaid status. +//! //! Fetch retries require new mailbox-wide nonces, including after a lost reply or restart. Provision //! retries retain the exact signed manifest and use fresh connection-scoped request identifiers. @@ -17,14 +22,18 @@ use std::fmt; use std::sync::{Arc, Mutex}; use bitcoin::hashes::{sha256, Hash, HashEngine}; +use lightning::ln::ffor::FFORWitnessReceipt; use lightning::util::persist::KVStoreSync; -use lightning_ffor::witness::CheckedAcknowledgement; +use lightning_ffor::witness::{CheckedAcknowledgement, EncryptedRecord}; use crate::types::DynStore; mod binding; mod envelope; +mod receipt; mod record; +use receipt::{ReceiptBook, ReceiptRetention}; +use record::ReceiptAllocation; pub(super) use binding::WitnessStorageBinding; #[cfg(test)] @@ -54,9 +63,26 @@ pub(crate) enum WitnessStoreError { Capacity, Entropy, Uncertain, + Unreserved, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Namespace { + Secrets, + Receipts, +} + +impl Namespace { + fn name(self) -> &'static str { + match self { + Self::Secrets => PRIMARY_NAMESPACE, + Self::Receipts => receipt::NAMESPACE, + } + } } struct PendingWrite { + namespace: Namespace, key: String, bytes: Vec, // Only this exact predecessor may remain after a failed update. Absence is permitted only @@ -71,11 +97,15 @@ struct StoreState { // authenticated, byte-identical successful write before this owner can return their material. confirmed: BTreeSet, uncertain: Option, + receipt_records: BTreeMap>, + receipt_confirmed: BTreeSet, + receipt_reservations: BTreeMap, } pub(super) struct WitnessSecretStore { storage: Arc, wrapping_key: WrappingKey, + receipt_key: WrappingKey, state: Mutex, } @@ -89,30 +119,60 @@ impl WitnessSecretStore { /// Opens an exclusive, bounded namespace. The borrowed wallet seed never leaves this call. /// Loading this sidecar does not establish activation, funding state, or backup freshness. pub(super) fn open(seed: &[u8; 64], storage: Arc) -> Result { - let keys = KVStoreSync::list(&*storage, PRIMARY_NAMESPACE, SECONDARY_NAMESPACE) - .map_err(|_| WitnessStoreError::Storage)?; - if keys.len() > MAX_EPOCHS { - return Err(WitnessStoreError::Capacity); - } - let mut records = BTreeMap::new(); + let records = inventory(&*storage, Namespace::Secrets, MAX_RECORD_BYTES, MAX_STORE_BYTES)?; + let receipt_records = inventory( + &*storage, + Namespace::Receipts, + receipt::MAX_RECEIPT_RECORD_BYTES, + receipt::MAX_RECEIPT_STORE_BYTES, + )?; + let wrapping_key = WrappingKey::derive(seed); + let receipt_key = WrappingKey::derive_receipts(seed); + let mut receipt_reservations = BTreeMap::new(); let mut total = 0usize; - for key in keys { - if key.len() != 64 - || !key.bytes().all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) - { - return Err(WitnessStoreError::Corrupt); + for (key, bytes) in &records { + let (digest, plaintext) = wrapping_key.open_inventory(key, bytes)?; + let allocation = StoredWitnessEpoch::inventory_allocation(&plaintext, digest)?; + let reserved = allocation.bytes(); + if reserved != 0 { + total = total.checked_add(reserved).ok_or(WitnessStoreError::Capacity)?; + if total > receipt::MAX_RECEIPT_STORE_BYTES { + return Err(WitnessStoreError::Capacity); + } + receipt_reservations.insert(key.clone(), reserved); } - let bytes = read(&*storage, &key)?; - check_record_size(&bytes)?; - total = total.checked_add(bytes.len()).ok_or(WitnessStoreError::Capacity)?; - if total > MAX_STORE_BYTES || records.insert(key, bytes).is_some() { - return Err(WitnessStoreError::Capacity); + match receipt_records.get(key) { + Some(receipt) => { + if receipt.len() != reserved { + return Err(WitnessStoreError::Corrupt); + } + let (actual, plaintext) = receipt_key.open_inventory(key, receipt)?; + if actual != digest { + return Err(WitnessStoreError::Binding); + } + ReceiptBook::validate_inventory(&plaintext, digest)?; + }, + None if matches!(allocation, ReceiptAllocation::Reserved(_)) => { + return Err(WitnessStoreError::Missing) + }, + None => {}, } } + if receipt_records.keys().any(|key| !records.contains_key(key)) { + return Err(WitnessStoreError::Missing); + } Ok(Self { storage, - wrapping_key: WrappingKey::derive(seed), - state: Mutex::new(StoreState { records, confirmed: BTreeSet::new(), uncertain: None }), + wrapping_key, + receipt_key, + state: Mutex::new(StoreState { + records, + confirmed: BTreeSet::new(), + uncertain: None, + receipt_records, + receipt_confirmed: BTreeSet::new(), + receipt_reservations, + }), }) } @@ -135,19 +195,40 @@ impl WitnessSecretStore { if state.records.len() == MAX_EPOCHS { return Err(WitnessStoreError::Capacity); } - match read(&*self.storage, &key) { + match read(&*self.storage, Namespace::Secrets, &key) { + Err(WitnessStoreError::Missing) => {}, + Ok(_) => return Err(WitnessStoreError::Conflict), + Err(error) => return Err(error), + } + match read(&*self.storage, Namespace::Receipts, &key) { Err(WitnessStoreError::Missing) => {}, Ok(_) => return Err(WitnessStoreError::Conflict), Err(error) => return Err(error), } - let record = StoredWitnessEpoch::generate(binding, &policies)?; + let mut record = StoredWitnessEpoch::generate(binding, &policies)?; + let receipt = self + .receipt_key + .seal_plaintext(binding, &ReceiptBook::empty(binding, &record).encode())?; + record.receipt_allocation = ReceiptAllocation::Pending( + receipt.len().try_into().map_err(|_| WitnessStoreError::Capacity)?, + ); let bytes = self.wrapping_key.seal(binding, &record)?; - check_record_size(&bytes)?; - let total = state.records.values().map(Vec::len).sum::(); - if total.checked_add(bytes.len()).filter(|sum| *sum <= MAX_STORE_BYTES).is_none() { + // Both complete encrypted envelopes, including metadata overhead, are charged before + // the first write. Pending and Reserved plaintext differ in exactly one fixed byte. + let secret_total = state.records.values().map(Vec::len).sum::(); + let receipt_total = state.receipt_reservations.values().sum::(); + if secret_total.checked_add(bytes.len()).filter(|n| *n <= MAX_STORE_BYTES).is_none() + || receipt_total + .checked_add(receipt.len()) + .filter(|n| *n <= receipt::MAX_RECEIPT_STORE_BYTES) + .is_none() + { return Err(WitnessStoreError::Capacity); } - self.confirm_write(&mut state, key, bytes)?; + state.receipt_reservations.insert(key.clone(), receipt.len()); + self.confirm_write(&mut state, Namespace::Secrets, key.clone(), bytes)?; + self.confirm_write(&mut state, Namespace::Receipts, key, receipt)?; + self.finish_initialization(&mut state, binding, &mut record)?; Ok(record) } @@ -179,7 +260,7 @@ impl WitnessSecretStore { if total.checked_add(bytes.len()).filter(|sum| *sum <= MAX_STORE_BYTES).is_none() { return Err(WitnessStoreError::Capacity); } - self.confirm_write(&mut state, key, bytes)?; + self.confirm_write(&mut state, Namespace::Secrets, key, bytes)?; Ok(record) } @@ -188,19 +269,127 @@ impl WitnessSecretStore { ) -> Result { let key = binding.storage_key(); let expected = state.records.get(&key).ok_or(WitnessStoreError::Missing)?; - let actual = read(&*self.storage, &key)?; + let actual = read(&*self.storage, Namespace::Secrets, &key)?; if &actual != expected { return Err(WitnessStoreError::Conflict); } - let record = self.wrapping_key.open(binding, &actual)?; + let mut record = self.wrapping_key.open(binding, &actual)?; if !state.confirmed.contains(&key) { - self.confirm_write(state, key, actual)?; + self.confirm_write(state, Namespace::Secrets, key.clone(), actual)?; + } + match record.receipt_allocation { + ReceiptAllocation::Pending(_) => { + self.finish_initialization(state, binding, &mut record)? + }, + ReceiptAllocation::Reserved(_) => { + self.load_receipt_locked(state, binding, &record)?; + }, + ReceiptAllocation::Legacy => {}, } Ok(record) } + fn finish_initialization( + &self, state: &mut StoreState, binding: &WitnessStorageBinding, + record: &mut StoredWitnessEpoch, + ) -> Result<(), WitnessStoreError> { + let reserved = match record.receipt_allocation { + ReceiptAllocation::Pending(size) => size, + _ => return Err(WitnessStoreError::Unreserved), + }; + let key = binding.storage_key(); + if state.receipt_reservations.get(&key) != Some(&(reserved as usize)) { + return Err(WitnessStoreError::Corrupt); + } + if !state.receipt_records.contains_key(&key) { + match read(&*self.storage, Namespace::Receipts, &key) { + Err(WitnessStoreError::Missing) => {}, + Ok(_) => return Err(WitnessStoreError::Conflict), + Err(error) => return Err(error), + } + let bytes = self + .receipt_key + .seal_plaintext(binding, &ReceiptBook::empty(binding, record).encode())?; + if bytes.len() != reserved as usize { + return Err(WitnessStoreError::Corrupt); + } + self.confirm_write(state, Namespace::Receipts, key.clone(), bytes)?; + } + let receipts = self.load_receipt_locked(state, binding, record)?; + if !receipts.is_empty() { + return Err(WitnessStoreError::Conflict); + } + record.receipt_allocation = ReceiptAllocation::Reserved(reserved); + let bytes = self.wrapping_key.seal(binding, record)?; + if bytes.len() != state.records.get(&key).ok_or(WitnessStoreError::Missing)?.len() { + return Err(WitnessStoreError::Corrupt); + } + self.confirm_write(state, Namespace::Secrets, key, bytes) + } + + fn load_receipt_locked( + &self, state: &mut StoreState, binding: &WitnessStorageBinding, record: &StoredWitnessEpoch, + ) -> Result { + if record.receipt_allocation == ReceiptAllocation::Legacy { + return Err(WitnessStoreError::Unreserved); + } + let key = binding.storage_key(); + let expected = state.receipt_records.get(&key).ok_or(WitnessStoreError::Missing)?; + let actual = read(&*self.storage, Namespace::Receipts, &key)?; + if &actual != expected { + return Err(WitnessStoreError::Conflict); + } + if actual.len() != record.receipt_allocation.bytes() { + return Err(WitnessStoreError::Corrupt); + } + let plaintext = self.receipt_key.open_bound(binding, &actual)?; + let receipts = ReceiptBook::decode(binding, record, &plaintext)?; + if !state.receipt_confirmed.contains(&key) { + self.confirm_write(state, Namespace::Receipts, key, actual)?; + } + Ok(receipts) + } + + /// Retains the first valid signed encrypted core. No native claim or payment event occurs. + pub(super) fn retain_receipt( + &self, binding: &WitnessStorageBinding, witness: bitcoin::secp256k1::PublicKey, + receipt: &EncryptedRecord, + ) -> Result { + let mut state = self.state.lock().map_err(|_| WitnessStoreError::Uncertain)?; + ensure_certain(&state)?; + let record = self.load_locked(&mut state, binding)?; + let mut receipts = self.load_receipt_locked(&mut state, binding, &record)?; + record.decrypt_record(witness, receipt.clone()).map_err(|_| WitnessStoreError::Corrupt)?; + let result = receipts.insert(witness, receipt)?; + if result == ReceiptRetention::Stored { + let bytes = self.receipt_key.seal_plaintext(binding, &receipts.encode())?; + if bytes.len() != record.receipt_allocation.bytes() { + return Err(WitnessStoreError::Corrupt); + } + self.confirm_write(&mut state, Namespace::Receipts, binding.storage_key(), bytes)?; + } + Ok(result) + } + + /// Re-decrypts durable retained evidence for a future native reconciliation boundary. + /// Absence is no evidence of an unpaid voucher. This does not claim channel settlement. + pub(super) fn load_receipt( + &self, binding: &WitnessStorageBinding, witness: bitcoin::secp256k1::PublicKey, slot: u16, + ) -> Result, WitnessStoreError> { + let mut state = self.state.lock().map_err(|_| WitnessStoreError::Uncertain)?; + ensure_certain(&state)?; + let record = self.load_locked(&mut state, binding)?; + let receipts = self.load_receipt_locked(&mut state, binding, &record)?; + receipts + .get(witness, slot)? + .map(|receipt| { + record.decrypt_record(witness, receipt).map_err(|_| WitnessStoreError::Corrupt) + }) + .transpose() + } + fn confirm_write( - &self, state: &mut StoreState, key: String, bytes: Vec, + &self, state: &mut StoreState, namespace: Namespace, key: String, bytes: Vec, ) -> Result<(), WitnessStoreError> { // Preserve the original predecessor through retries, including when the proposed new // ciphertext became visible but the backend reported a failed sync. @@ -208,11 +397,12 @@ impl WitnessSecretStore { .uncertain .as_ref() .and_then(|pending| pending.previous.clone()) - .or_else(|| state.records.get(&key).cloned()); - state.uncertain = Some(PendingWrite { key: key.clone(), bytes: bytes.clone(), previous }); - write(&*self.storage, &key, bytes.clone())?; - state.confirmed.insert(key.clone()); - state.records.insert(key, bytes); + .or_else(|| state.records(namespace).get(&key).cloned()); + state.uncertain = + Some(PendingWrite { namespace, key: key.clone(), bytes: bytes.clone(), previous }); + write(&*self.storage, namespace, &key, bytes.clone())?; + state.confirmed_mut(namespace).insert(key.clone()); + state.records_mut(namespace).insert(key, bytes); state.uncertain = None; Ok(()) } @@ -228,7 +418,7 @@ impl WitnessSecretStore { Some(pending) => pending, None => return Ok(()), }; - match read(&*self.storage, &pending.key) { + match read(&*self.storage, pending.namespace, &pending.key) { Ok(bytes) if bytes == pending.bytes => {}, Ok(bytes) if pending.previous.as_ref() == Some(&bytes) => {}, Ok(_) => return Err(WitnessStoreError::Conflict), @@ -237,28 +427,70 @@ impl WitnessSecretStore { } let key = pending.key.clone(); let bytes = pending.bytes.clone(); - self.confirm_write(&mut state, key, bytes) + let namespace = pending.namespace; + self.confirm_write(&mut state, namespace, key, bytes) } } -fn ensure_certain(state: &StoreState) -> Result<(), WitnessStoreError> { - if state.uncertain.is_some() { - Err(WitnessStoreError::Uncertain) - } else { - Ok(()) +impl StoreState { + fn records(&self, namespace: Namespace) -> &BTreeMap> { + match namespace { + Namespace::Secrets => &self.records, + Namespace::Receipts => &self.receipt_records, + } + } + fn records_mut(&mut self, namespace: Namespace) -> &mut BTreeMap> { + match namespace { + Namespace::Secrets => &mut self.records, + Namespace::Receipts => &mut self.receipt_records, + } + } + fn confirmed_mut(&mut self, namespace: Namespace) -> &mut BTreeSet { + match namespace { + Namespace::Secrets => &mut self.confirmed, + Namespace::Receipts => &mut self.receipt_confirmed, + } + } +} + +fn inventory( + storage: &DynStore, namespace: Namespace, maximum_record: usize, maximum_total: usize, +) -> Result>, WitnessStoreError> { + let keys = KVStoreSync::list(storage, namespace.name(), SECONDARY_NAMESPACE) + .map_err(|_| WitnessStoreError::Storage)?; + if keys.len() > MAX_EPOCHS { + return Err(WitnessStoreError::Capacity); } + let mut records = BTreeMap::new(); + let mut total = 0usize; + for key in keys { + if key.len() != 64 + || !key.bytes().all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err(WitnessStoreError::Corrupt); + } + let bytes = read(storage, namespace, &key)?; + if bytes.is_empty() || bytes.len() > maximum_record { + return Err(WitnessStoreError::Capacity); + } + total = total.checked_add(bytes.len()).ok_or(WitnessStoreError::Capacity)?; + if total > maximum_total || records.insert(key, bytes).is_some() { + return Err(WitnessStoreError::Capacity); + } + } + Ok(records) } -fn check_record_size(bytes: &[u8]) -> Result<(), WitnessStoreError> { - if bytes.is_empty() || bytes.len() > MAX_RECORD_BYTES { - Err(WitnessStoreError::Capacity) +fn ensure_certain(state: &StoreState) -> Result<(), WitnessStoreError> { + if state.uncertain.is_some() { + Err(WitnessStoreError::Uncertain) } else { Ok(()) } } -fn read(storage: &DynStore, key: &str) -> Result, WitnessStoreError> { - KVStoreSync::read(storage, PRIMARY_NAMESPACE, SECONDARY_NAMESPACE, key).map_err(|error| { +fn read(storage: &DynStore, namespace: Namespace, key: &str) -> Result, WitnessStoreError> { + KVStoreSync::read(storage, namespace.name(), SECONDARY_NAMESPACE, key).map_err(|error| { if error.kind() == lightning::io::ErrorKind::NotFound { WitnessStoreError::Missing } else { @@ -267,8 +499,10 @@ fn read(storage: &DynStore, key: &str) -> Result, WitnessStoreError> { }) } -fn write(storage: &DynStore, key: &str, bytes: Vec) -> Result<(), WitnessStoreError> { - KVStoreSync::write(storage, PRIMARY_NAMESPACE, SECONDARY_NAMESPACE, key, bytes) +fn write( + storage: &DynStore, namespace: Namespace, key: &str, bytes: Vec, +) -> Result<(), WitnessStoreError> { + KVStoreSync::write(storage, namespace.name(), SECONDARY_NAMESPACE, key, bytes) .map_err(|_| WitnessStoreError::Storage) } diff --git a/src/ffor/witness_store/envelope.rs b/src/ffor/witness_store/envelope.rs index be58d03d22..1468f9bf99 100644 --- a/src/ffor/witness_store/envelope.rs +++ b/src/ffor/witness_store/envelope.rs @@ -5,44 +5,56 @@ use vss_client::util::storable_builder::{EntropySource, StorableBuilder}; use zeroize::Zeroizing; use super::record::{random_bytes, StoredWitnessEpoch}; -use super::{check_record_size, WitnessStorageBinding, WitnessStoreError, SCHEMA_VERSION}; +use super::{WitnessStorageBinding, WitnessStoreError, MAX_RECORD_BYTES, SCHEMA_VERSION}; -const MAGIC: &[u8; 4] = b"FWSE"; const HEADER_LENGTH: usize = 4 + 2 + 32; -pub(super) struct WrappingKey(Zeroizing<[u8; 32]>); +pub(super) struct WrappingKey { + key: Zeroizing<[u8; 32]>, + receipts: bool, +} impl WrappingKey { pub(super) fn derive(seed: &[u8; 64]) -> Self { - // HKDF extract/expand with a dedicated storage purpose, separate from all account/node keys. - let mut extract = HmacEngine::::new(b"ldk-node/ffor/witness-storage/v1"); + Self::derive_domain(seed, false) + } + pub(super) fn derive_receipts(seed: &[u8; 64]) -> Self { + Self::derive_domain(seed, true) + } + + fn derive_domain(seed: &[u8; 64], receipts: bool) -> Self { + // Independent HKDF purposes separate both storage keys from account and node keys. + let purpose: &[u8] = if receipts { + b"ldk-node/ffor/witness-receipt-storage/v1" + } else { + b"ldk-node/ffor/witness-storage/v1" + }; + let mut extract = HmacEngine::::new(purpose); extract.input(seed); let prk = Zeroizing::new(Hmac::from_engine(extract).to_byte_array()); let mut expand = HmacEngine::::new(prk.as_ref()); expand.input(b"wrapping-key"); expand.input(&[1]); - Self(Zeroizing::new(Hmac::from_engine(expand).to_byte_array())) + Self { key: Zeroizing::new(Hmac::from_engine(expand).to_byte_array()), receipts } } pub(super) fn seal( &self, binding: &WitnessStorageBinding, record: &StoredWitnessEpoch, ) -> Result, WitnessStoreError> { - let plaintext = record.encode(); - self.seal_plaintext(binding, &plaintext) + self.seal_plaintext(binding, &record.encode()) } - fn seal_plaintext( + pub(super) fn seal_plaintext( &self, binding: &WitnessStorageBinding, plaintext: &[u8], ) -> Result, WitnessStoreError> { - let nonce = random_bytes::<12>()?; - let builder = StorableBuilder::new(EnvelopeNonce(nonce)); - let mut bytes = header(binding); - let aad = associated_data(binding, &bytes); - // Our buffer is zeroized. StorableBuilder owns additional plaintext allocations during - // encryption and decryption which it does not zeroize. No total erasure is claimed. - let sealed = builder.build(plaintext.to_vec(), i64::from(SCHEMA_VERSION), &self.0, &aad); + let builder = StorableBuilder::new(EnvelopeNonce(random_bytes::<12>()?)); + let mut bytes = self.header(binding.digest()); + let aad = self.associated_data(&binding.storage_key(), &bytes); + // Our buffers are zeroized. The dependency owns additional plaintext allocations which + // it does not zeroize; this does not claim complete memory erasure. + let sealed = builder.build(plaintext.to_vec(), i64::from(SCHEMA_VERSION), &self.key, &aad); bytes.extend_from_slice(&sealed.encode_to_vec()); - check_record_size(&bytes)?; + self.check_size(&bytes)?; Ok(bytes) } @@ -56,45 +68,78 @@ impl WrappingKey { pub(super) fn open( &self, binding: &WitnessStorageBinding, bytes: &[u8], ) -> Result { - check_record_size(bytes)?; - if bytes.get(..HEADER_LENGTH) != Some(header(binding).as_slice()) { + let plaintext = self.open_bound(binding, bytes)?; + StoredWitnessEpoch::decode(binding, &plaintext) + } + + pub(super) fn open_bound( + &self, binding: &WitnessStorageBinding, bytes: &[u8], + ) -> Result>, WitnessStoreError> { + let (digest, plaintext) = self.open_inventory(&binding.storage_key(), bytes)?; + if digest != binding.digest() { + return Err(WitnessStoreError::Binding); + } + Ok(plaintext) + } + + // AEAD binds the actual namespace key and native binding digest. Inventory uses this only to + // authenticate fixed allocation metadata; use still requires full native-context validation. + pub(super) fn open_inventory( + &self, key: &str, bytes: &[u8], + ) -> Result<([u8; 32], Zeroizing>), WitnessStoreError> { + self.check_size(bytes)?; + let header = bytes.get(..HEADER_LENGTH).ok_or(WitnessStoreError::Corrupt)?; + let digest: [u8; 32] = header[6..].try_into().map_err(|_| WitnessStoreError::Corrupt)?; + if header != self.header(digest) { return Err(WitnessStoreError::Binding); } let encoded = &bytes[HEADER_LENGTH..]; let storable = Storable::decode(encoded).map_err(|_| WitnessStoreError::Corrupt)?; if storable.encode_to_vec() != encoded - || storable.encryption_metadata.as_ref().map(|metadata| metadata.cipher_format.as_str()) + || storable.encryption_metadata.as_ref().map(|m| m.cipher_format.as_str()) != Some("ChaCha20Poly1305") { return Err(WitnessStoreError::Corrupt); } - let aad = associated_data(binding, &bytes[..HEADER_LENGTH]); + let aad = self.associated_data(key, header); let (plaintext, version) = StorableBuilder::new(EnvelopeNonce([0; 12])) - .deconstruct(storable, &self.0, &aad) + .deconstruct(storable, &self.key, &aad) .map_err(|_| WitnessStoreError::Corrupt)?; let plaintext = Zeroizing::new(plaintext); if version != i64::from(SCHEMA_VERSION) { return Err(WitnessStoreError::Corrupt); } - StoredWitnessEpoch::decode(binding, &plaintext) + Ok((digest, plaintext)) } -} - -fn header(binding: &WitnessStorageBinding) -> Vec { - [MAGIC.as_slice(), &SCHEMA_VERSION.to_be_bytes(), &binding.digest()].concat() -} -fn associated_data(binding: &WitnessStorageBinding, header: &[u8]) -> Vec { - [b"ldk-node/ffor/witness-envelope/v1".as_slice(), binding.storage_key().as_bytes(), header] - .concat() + fn header(&self, digest: [u8; 32]) -> Vec { + let magic: &[u8] = if self.receipts { b"FWRE" } else { b"FWSE" }; + [magic, &SCHEMA_VERSION.to_be_bytes(), &digest].concat() + } + fn associated_data(&self, key: &str, header: &[u8]) -> Vec { + let purpose: &[u8] = if self.receipts { + b"ldk-node/ffor/witness-receipt-envelope/v1" + } else { + b"ldk-node/ffor/witness-envelope/v1" + }; + [purpose, key.as_bytes(), header].concat() + } + fn check_size(&self, bytes: &[u8]) -> Result<(), WitnessStoreError> { + let maximum = + if self.receipts { super::receipt::MAX_RECEIPT_RECORD_BYTES } else { MAX_RECORD_BYTES }; + if bytes.is_empty() || bytes.len() > maximum { + Err(WitnessStoreError::Capacity) + } else { + Ok(()) + } + } } +// The pinned builder requests exactly one 12-byte AEAD nonce per build. Its infallible entropy +// trait is fed only after the fallible operating-system draw succeeds. struct EnvelopeNonce([u8; 12]); - impl EntropySource for EnvelopeNonce { fn fill_bytes(&self, buffer: &mut [u8]) { - // StorableBuilder::build requests exactly one 12-byte AEAD nonce. Entropy was obtained - // fallibly before this infallible dependency callback, and this builder is used once. buffer.copy_from_slice(&self.0); } } diff --git a/src/ffor/witness_store/receipt.rs b/src/ffor/witness_store/receipt.rs new file mode 100644 index 0000000000..fba22477b0 --- /dev/null +++ b/src/ffor/witness_store/receipt.rs @@ -0,0 +1,244 @@ +//! Fixed encrypted evidence reservations. These contain no plaintext preimages or payment status. + +use bitcoin::secp256k1::PublicKey; +use lightning_ffor::witness::{EncryptedRecord, CIPHERTEXT_LEN, RECORD_HEADER_LEN}; +use zeroize::Zeroizing; + +use super::{hash, StoredWitnessEpoch, WitnessStorageBinding, WitnessStoreError, MAX_WITNESSES}; + +pub(super) const NAMESPACE: &str = "ffor_witness_receipts"; +pub(super) const MAX_RECEIPT_RECORD_BYTES: usize = 1024 * 1024; +pub(super) const MAX_RECEIPT_STORE_BYTES: usize = 8 * 1024 * 1024; +const VERSION: u16 = 1; +// The decoder below verifies this canonical prefix against the actual shared encoder. Guardian +// attachments follow this prefix and are excluded: they are unsigned, non-authoritative bytes. +const CORE_BYTES: usize = RECORD_HEADER_LEN + 64 + 2 + CIPHERTEXT_LEN; +const SLOT_BYTES: usize = 1 + CORE_BYTES; + +type Core = [u8; CORE_BYTES]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ReceiptRetention { + Stored, + AlreadyStored, +} + +struct WitnessEntries { + witness: PublicKey, + manifest_digest: [u8; 32], + slots: Vec>, +} + +pub(super) struct ReceiptBook { + binding_digest: [u8; 32], + count: usize, + witnesses: Vec, +} + +impl ReceiptBook { + pub(super) fn empty(binding: &WitnessStorageBinding, record: &StoredWitnessEpoch) -> Self { + let count = binding.setup().vouchers().len(); + let witnesses = record + .policies() + .iter() + .map(|policy| WitnessEntries { + witness: policy.witness, + manifest_digest: hash(&[ + b"ldk-node/ffor/retained-manifest/v1", + &record.manifest(policy.witness).unwrap().encode(), + ]), + slots: vec![None; count], + }) + .collect(); + Self { binding_digest: binding.digest(), count, witnesses } + } + + pub(super) fn encode(&self) -> Zeroizing> { + let length = 37 + self.witnesses.len() * (65 + self.count * SLOT_BYTES); + let mut bytes = Zeroizing::new(Vec::with_capacity(length)); + bytes.extend_from_slice(&VERSION.to_be_bytes()); + bytes.extend_from_slice(&self.binding_digest); + bytes.push(self.witnesses.len() as u8); + bytes.extend_from_slice(&(self.count as u16).to_be_bytes()); + for witness in &self.witnesses { + bytes.extend_from_slice(&witness.witness.serialize()); + bytes.extend_from_slice(&witness.manifest_digest); + for slot in &witness.slots { + match slot { + Some(core) => { + bytes.push(1); + bytes.extend_from_slice(core); + }, + None => { + let length = bytes.len() + SLOT_BYTES; + bytes.resize(length, 0); + }, + } + } + } + bytes + } + + pub(super) fn decode( + binding: &WitnessStorageBinding, secrets: &StoredWitnessEpoch, bytes: &[u8], + ) -> Result { + let book = Self::read(bytes, binding.digest())?; + let expected = Self::empty(binding, secrets); + if book.count != expected.count || book.witnesses.len() != expected.witnesses.len() { + return Err(WitnessStoreError::Binding); + } + for (actual, expected) in book.witnesses.iter().zip(&expected.witnesses) { + if actual.witness != expected.witness + || actual.manifest_digest != expected.manifest_digest + { + return Err(WitnessStoreError::Binding); + } + for core in actual.slots.iter().flatten() { + secrets + .decrypt_record(actual.witness, decode_core(core)?) + .map_err(|_| WitnessStoreError::Corrupt)?; + } + } + Ok(book) + } + + pub(super) fn validate_inventory( + bytes: &[u8], digest: [u8; 32], + ) -> Result<(), WitnessStoreError> { + Self::read(bytes, digest).map(|_| ()) + } + + fn read(bytes: &[u8], digest: [u8; 32]) -> Result { + let mut reader = Reader(bytes); + if u16::from_be_bytes(reader.array()?) != VERSION || reader.array::<32>()? != digest { + return Err(WitnessStoreError::Binding); + } + let witness_count = reader.array::<1>()?[0] as usize; + let count = u16::from_be_bytes(reader.array()?) as usize; + if witness_count == 0 || witness_count > MAX_WITNESSES || count == 0 || count > 483 { + return Err(WitnessStoreError::Capacity); + } + if reader.0.len() != witness_count * (65 + count * SLOT_BYTES) { + return Err(WitnessStoreError::Corrupt); + } + let mut witnesses: Vec = Vec::with_capacity(witness_count); + for _ in 0..witness_count { + let witness = + PublicKey::from_slice(reader.take(33)?).map_err(|_| WitnessStoreError::Corrupt)?; + if witnesses.last().is_some_and(|previous| previous.witness >= witness) { + return Err(WitnessStoreError::Corrupt); + } + let manifest_digest = reader.array()?; + let mut slots = Vec::with_capacity(count); + for index in 0..count { + let present = reader.array::<1>()?[0]; + let core = reader.array()?; + slots.push(match present { + 0 if core == [0; CORE_BYTES] => None, + 1 => { + let record = decode_core(&core)?; + if record.header().witness != witness + || record.header().slot as usize != index + 1 + { + return Err(WitnessStoreError::Binding); + } + Some(core) + }, + _ => return Err(WitnessStoreError::Corrupt), + }); + } + witnesses.push(WitnessEntries { witness, manifest_digest, slots }); + } + Ok(Self { binding_digest: digest, count, witnesses }) + } + + pub(super) fn is_empty(&self) -> bool { + self.witnesses.iter().all(|witness| witness.slots.iter().all(Option::is_none)) + } + + pub(super) fn insert( + &mut self, witness: PublicKey, record: &EncryptedRecord, + ) -> Result { + let entry = self + .witnesses + .iter_mut() + .find(|entry| entry.witness == witness) + .ok_or(WitnessStoreError::Binding)?; + if record.header().witness != witness { + return Err(WitnessStoreError::Binding); + } + let index = + usize::from(record.header().slot).checked_sub(1).ok_or(WitnessStoreError::Binding)?; + let slot = entry.slots.get_mut(index).ok_or(WitnessStoreError::Binding)?; + let core = canonical_core(record)?; + match slot { + Some(existing) if existing == &core => Ok(ReceiptRetention::AlreadyStored), + Some(_) => Err(WitnessStoreError::Conflict), + None => { + *slot = Some(core); + Ok(ReceiptRetention::Stored) + }, + } + } + + pub(super) fn get( + &self, witness: PublicKey, slot: u16, + ) -> Result, WitnessStoreError> { + let entry = self + .witnesses + .iter() + .find(|entry| entry.witness == witness) + .ok_or(WitnessStoreError::Binding)?; + let index = usize::from(slot).checked_sub(1).ok_or(WitnessStoreError::Binding)?; + entry + .slots + .get(index) + .ok_or(WitnessStoreError::Binding)? + .as_ref() + .map(decode_core) + .transpose() + } +} + +fn canonical_core(record: &EncryptedRecord) -> Result { + let wire = record.encode(); + let core: Core = wire + .get(..CORE_BYTES) + .ok_or(WitnessStoreError::Corrupt)? + .try_into() + .map_err(|_| WitnessStoreError::Corrupt)?; + // Confirm shared canonical framing rather than truncating a differently encoded record. + let decoded = decode_core(&core)?; + if decoded.header() != record.header() + || decoded.ciphertext() != record.ciphertext() + || wire.get(CORE_BYTES).copied() != Some(record.receipts().len() as u8) + { + return Err(WitnessStoreError::Corrupt); + } + Ok(core) +} + +fn decode_core(core: &Core) -> Result { + let mut bytes = core.to_vec(); + bytes.push(0); + let record = EncryptedRecord::decode(&bytes).map_err(|_| WitnessStoreError::Corrupt)?; + if record.encode() != bytes { + return Err(WitnessStoreError::Corrupt); + } + Ok(record) +} + +struct Reader<'a>(&'a [u8]); +impl Reader<'_> { + fn take(&mut self, length: usize) -> Result<&[u8], WitnessStoreError> { + let bytes = self.0.get(..length).ok_or(WitnessStoreError::Corrupt)?; + self.0 = &self.0[length..]; + Ok(bytes) + } + fn array(&mut self) -> Result<[u8; N], WitnessStoreError> { + self.take(N)?.try_into().map_err(|_| WitnessStoreError::Corrupt) + } +} + +#[cfg(test)] +mod tests; diff --git a/src/ffor/witness_store/receipt/README.md b/src/ffor/witness_store/receipt/README.md new file mode 100644 index 0000000000..6448c90b8e --- /dev/null +++ b/src/ffor/witness_store/receipt/README.md @@ -0,0 +1,32 @@ +# Receipt retention fixtures + +These tests reuse the public deterministic D.1 and D.2 encrypted records documented +in [the key-use fixtures](../record/key_use/README.md). They contain public test keys +and preimages, never wallet material. + +`activation-fixtures.txt` is an exact projection of the first two records in +`lightning-ffor/tests/data/appendix-d.json` at native revision +`a20b34f9822e7131d229a526249c917971c25d14`: `scenario` is unchanged, `activate_wire` +becomes `activate`, and `ack_wire` becomes `ack`. These signatures let the tests use +the normal historical storage binding without bypassing activation authentication. + +`equivocation.txt` contains a second valid signed and encrypted D.1 record for the +same witness and voucher. The generator changes the public record ID to repeated +byte 99 and uses repeated byte 46 as its deterministic ephemeral encryption key. +It retains the original valid payment body and signs with the public witness key +43. Beignet verifies both signature and decryption before exporting it. The test +proves a later valid conflicting record cannot replace the first retained evidence. + +To regenerate, use Beignet revision `8aee31d18e596fe49a0d195b325a6e757d7a009b` +with its dependencies installed. From this directory: + +```sh +TS_NODE_PROJECT=/path/to/beignet/tsconfig.json \ +node -r /path/to/beignet/node_modules/ts-node/register \ + generate_equivocation.cjs /path/to/beignet \ + ../record/key_use/fixtures.txt /tmp/ffor-equivocation.txt +cmp equivocation.txt /tmp/ffor-equivocation.txt +``` + +The generator refuses another reference revision. Production storage generates +fresh keys and nonces from operating-system entropy. diff --git a/src/ffor/witness_store/receipt/activation-fixtures.txt b/src/ffor/witness_store/receipt/activation-fixtures.txt new file mode 100644 index 0000000000..06d1addbbc --- /dev/null +++ b/src/ffor/witness_store/receipt/activation-fixtures.txt @@ -0,0 +1,7 @@ +scenario=D.1 +activate=d705bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff15ba34be8afe89e4457543a6e7279f86054f1d6e9038234b786b740e9c905c001f8ae276dd460c7b2030287a608677bc51eb9cdef5b7cfc1f73d005e463076510a723024696ad20b4758b228284620d18fd30750b7051cc64efca23a1417cf1321000c0df003aa4c0c160338dfb3783120761fb4a6053836307e196fe3597032f28715b9a021074ec06ceb9c5f06af9854f8b21ff1b6b74ff3ae326a049de8270f2c8dc559 +ack=d707bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489fc4bc36f402d396fe452db96030f1563fd554e9c9f9313ef4cdd6299d21dff15580a4a958e8131b32452741e51d724e0f231cb518896c743fa59de236ac6e7161a20d93b0e00046483cffe0d9b54b0747a351ccaa3971a92aaf9245eb7cde5734f20a6ed584ccd30d81e833d9e89d9d9e42878b541cce5913a647b5ffc1ca9a3 + +scenario=D.2 +activate=d705bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884892436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d68465b03bd1ab3001129cd91fffb6e5129460cf82fb396cad2ebbb0ede891639609e4e3f8cef88225f7401bdcf539cf557932eeb035a265d602afba2083125eece7e996cb809a52736589d78d22b1a7c2a76b2ba07a8ac1f957aabc2651b0b9c5000c0df01b5ca88c942d833703ab7702576abcbe1694524b37ef6b762f61358c9e2c31710cd2d600c69ef3a810d29a4b25705759de647e7b666909f0baec2a7af766eca4 +ack=d707bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a4884892436d73f6fdf469b68bedbbd9c90aa51871d491fadf862d815853b27f13f62d61d561fb397d1ac599cf6876a4f390d6bd81de4cd425b30383b14a5de4b4b61d40e976c39f969145764a509691cd84aafedaddd873be2e3b35fbb42b55d43087b79aadcd67eb097ea539931314d7b8aee65758147094f78cd43736c0e496d2e66 diff --git a/src/ffor/witness_store/receipt/equivocation.txt b/src/ffor/witness_store/receipt/equivocation.txt new file mode 100644 index 0000000000..de58069ae0 --- /dev/null +++ b/src/ffor/witness_store/receipt/equivocation.txt @@ -0,0 +1 @@ +01010b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b63636363636363636363636363636363636363636363636363636363636363630001580a4a958e8131b32452741e51d724e0f231cb518896c743fa59de236ac6e716a599edf07d16acfacd1128c82f1741bba098d480b830f04f352e822c5183da8b02bb58b5feca505c74edc000d8282fc556e51a1024fc8e7d7e56c6f887c5c8d5f2028f5304e2373e56ee7d774cb89e9f1afecf0ee7e3e3757f189908f069daa36c60000c0dfa01513b1fd4e74eeb2534c6b2795431facb93759172253d915c7afcd9666a1b612e5a5e5b16c0abdd1d336af9dea269c4dc3c199f7bfdc15f0a2ec771349e793ea150524ab0d72e928c7a03dd9477d6415ba502e115f6c17b3d56b9ab2119cda1c400bf03d98a7def67e8370118f636620d0be08ed63344df68082b4f2f1844e7ce601483c44c1f9642a4461ee9a39387fb690075eb91827a538fc9ab851c4ab4dcce9a21ced9085bc2bd7798ab13087860a5ef031c24898424707e76acf77f6e6e7938b89e671df5d622c663a0fe6fb8c98bba5518dbd403a7884e6f98adb8590d0ac5a86cb9d6d8fc30515159529705b8bda7b9d3fbcb5d0a233fce63d74d7226f48c10472bca143b53b443d19e60e4a3ef4955263cfa04e578e5141f3ab43dba9c00 diff --git a/src/ffor/witness_store/receipt/generate_equivocation.cjs b/src/ffor/witness_store/receipt/generate_equivocation.cjs new file mode 100644 index 0000000000..03e39cc727 --- /dev/null +++ b/src/ffor/witness_store/receipt/generate_equivocation.cjs @@ -0,0 +1,23 @@ +// Public deterministic D.1 alternate evidence, never production key material. +// TS_NODE_PROJECT=/path/beignet/tsconfig.json node -r /path/beignet/node_modules/ts-node/register \ +// generate_equivocation.cjs /path/beignet ../record/key_use/fixtures.txt equivocation.txt +const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto'); +const { execFileSync } = require('node:child_process'); +const reference = path.resolve(process.argv[2]); +if (execFileSync('git', ['rev-parse', 'HEAD'], {cwd: reference, encoding: 'utf8'}).trim() !== '8aee31d18e596fe49a0d195b325a6e757d7a009b') throw Error('Wrong reference'); +const codecs = require(path.join(reference, 'src/lightning/ffor/witness-messages.ts')); +const encryption = require(path.join(reference, 'src/lightning/ffor/witness-crypto.ts')); +const curve = require(path.join(reference, 'src/lightning/crypto/ecdh.ts')); +const fixture = Object.fromEntries(fs.readFileSync(process.argv[3], 'utf8').trim().split('\n\n')[0].split('\n').map(line => line.split('='))); +const record = codecs.decodeRecord(Buffer.from(fixture.record, 'hex')); +record.header.recordId = Buffer.alloc(32, 99); +const savedRandom = crypto.randomBytes; +try { + crypto.randomBytes = length => { if (length !== 32) throw Error('Unexpected entropy'); return Buffer.alloc(32, 46); }; + record.ciphertext = encryption.sealRecordBody(record.header.encPubkey, codecs.recordAad(record.header), Buffer.from(fixture.body, 'hex')); +} finally { crypto.randomBytes = savedRandom; } +record.header.ciphertextHash = crypto.createHash('sha256').update(record.ciphertext).digest(); +record.witnessSig = curve.sign(codecs.recordDigest(codecs.encodeRecordHeader(record.header)), Buffer.alloc(32, 43)); +if (!codecs.verifyRecordSignature(record)) throw Error('Signature'); +if (!encryption.openRecordBody(Buffer.alloc(32, 44), codecs.recordAad(record.header), record.ciphertext).equals(Buffer.from(fixture.body, 'hex'))) throw Error('AEAD'); +fs.writeFileSync(process.argv[4], codecs.encodeRecord(record).toString('hex')+'\n'); diff --git a/src/ffor/witness_store/receipt/tests.rs b/src/ffor/witness_store/receipt/tests.rs new file mode 100644 index 0000000000..32ff3d383e --- /dev/null +++ b/src/ffor/witness_store/receipt/tests.rs @@ -0,0 +1,497 @@ +use std::sync::atomic::Ordering; +use std::sync::Arc; + +use bitcoin::hashes::Hash; +use bitcoin::{OutPoint, Txid}; +use lightning::util::persist::KVStoreSync; +use lightning_ffor::setup::AuthenticatedSetup; +use lightning_ffor::wire::Message; +use lightning_ffor::witness::SignedManifest; +use proptest::prelude::*; + +use super::*; +use crate::ffor::witness_store::tests::{ + binding, checked_acknowledgement, key, policies, TestStore, +}; +use crate::ffor::witness_store::{ + record::ReceiptAllocation, Namespace, WitnessSecretStore, WitnessStorageIdentity, MAX_EPOCHS, + SECONDARY_NAMESPACE, +}; + +fn hex(value: &str) -> Vec { + (0..value.len()).step_by(2).map(|i| u8::from_str_radix(&value[i..i + 2], 16).unwrap()).collect() +} + +fn field(text: &str, name: &str) -> Vec { + hex(text + .lines() + .find_map(|line| { + let (key, value) = line.split_once('=')?; + (key == name).then_some(value) + }) + .unwrap()) +} + +// Exact public Beignet D.1/D.2 evidence from the existing key-use fixtures and Appendix D activation +// vectors at lightning-ffor/tests/data/appendix-d.json. Keys 42/44 are public fixture constants. +fn fixture(index: usize) -> (WitnessStorageBinding, StoredWitnessEpoch, EncryptedRecord) { + let data = + include_str!("../record/key_use/fixtures.txt").trim().split("\n\n").nth(index).unwrap(); + let activation = include_str!("activation-fixtures.txt") + .trim() + .split("\n\n") + .nth(if index == 0 { 0 } else { 1 }) + .unwrap(); + let identity = WitnessStorageIdentity { + chain_hash: [4; 32], + actual_node: PublicKey::from_slice(&hex( + "039fca7f8157aa768708894ffd92550fe970edd18526a5f936583ea3b54dab3228", + )) + .unwrap(), + settlement_node: PublicKey::from_slice(&hex( + "02087b7d1b4789170f6e374f0a0e58a1b7a899e34929795314ab6964e69609e9c0", + )) + .unwrap(), + original_funding: OutPoint { txid: Txid::from_byte_array([8; 32]), vout: 0 }, + }; + let setup = AuthenticatedSetup::new( + &Message::decode(&field(data, "init")).unwrap(), + &Message::decode(&field(data, "accept")).unwrap(), + identity.actual_node, + identity.settlement_node, + ) + .unwrap(); + let binding = WitnessStorageBinding::new( + identity, + setup, + &Message::decode(&field(activation, "activate")).unwrap(), + &Message::decode(&field(activation, "ack")).unwrap(), + ) + .unwrap(); + let manifest = SignedManifest::decode(&field(data, "manifest"), binding.setup()).unwrap(); + let mut plaintext = Zeroizing::new(Vec::new()); + plaintext.extend_from_slice(&2u16.to_be_bytes()); + plaintext.extend_from_slice(&binding.digest()); + plaintext.extend_from_slice(&[44; 32]); + plaintext.push(1); + plaintext.extend_from_slice(&key(43).serialize()); + plaintext.extend_from_slice(&[42; 32]); + plaintext.extend_from_slice(&(manifest.encode().len() as u32).to_be_bytes()); + plaintext.extend_from_slice(&manifest.encode()); + plaintext.extend_from_slice(&[0; 21]); + let stored = StoredWitnessEpoch::decode(&binding, &plaintext).unwrap(); + (binding, stored, EncryptedRecord::decode(&field(data, "record")).unwrap()) +} + +fn seeded( + index: usize, +) -> (Arc, WitnessSecretStore, WitnessStorageBinding, EncryptedRecord) { + let (binding, mut record, encrypted) = fixture(index); + let backend = TestStore::new(); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let receipt = store + .receipt_key + .seal_plaintext(&binding, &ReceiptBook::empty(&binding, &record).encode()) + .unwrap(); + record.receipt_allocation = ReceiptAllocation::Pending(receipt.len() as u32); + { + let mut state = store.state.lock().unwrap(); + state.receipt_reservations.insert(binding.storage_key(), receipt.len()); + store + .confirm_write( + &mut state, + Namespace::Secrets, + binding.storage_key(), + store.wrapping_key.seal(&binding, &record).unwrap(), + ) + .unwrap(); + store + .confirm_write(&mut state, Namespace::Receipts, binding.storage_key(), receipt) + .unwrap(); + store.finish_initialization(&mut state, &binding, &mut record).unwrap(); + } + (backend, store, binding, encrypted) +} + +fn receipt_bytes(backend: &TestStore, binding: &WitnessStorageBinding) -> Vec { + KVStoreSync::read(backend, NAMESPACE, SECONDARY_NAMESPACE, &binding.storage_key()).unwrap() +} + +#[test] +fn ffor_receipt_retains_exact_core_deduplicates_and_reopens_all_fixture_slots() { + for index in 0..4 { + let (backend, store, binding, record) = seeded(index); + let length = receipt_bytes(&backend, &binding).len(); + assert!(store.load_receipt(&binding, key(43), record.header().slot).unwrap().is_none()); + assert_eq!(store.retain_receipt(&binding, key(43), &record), Ok(ReceiptRetention::Stored)); + let retained = receipt_bytes(&backend, &binding); + assert_eq!(retained.len(), length); + let writes = backend.writes.load(Ordering::SeqCst); + assert_eq!( + store.retain_receipt(&binding, key(43), &record), + Ok(ReceiptRetention::AlreadyStored) + ); + // Guardian attachments are deliberately outside retained, authoritative signed evidence. + let stripped = decode_core(&canonical_core(&record).unwrap()).unwrap(); + assert_eq!(stripped.encode().len(), CORE_BYTES + 1); + assert!(stripped.receipts().is_empty()); + assert_eq!( + store.retain_receipt(&binding, key(43), &stripped), + Ok(ReceiptRetention::AlreadyStored) + ); + assert_eq!(backend.writes.load(Ordering::SeqCst), writes); + let expected = + store.load_receipt(&binding, key(43), record.header().slot).unwrap().unwrap(); + drop(store); + let restored = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + assert_eq!( + restored.load_receipt(&binding, key(43), record.header().slot).unwrap(), + Some(expected) + ); + assert_eq!(receipt_bytes(&backend, &binding), retained); + assert_eq!(backend.writes.load(Ordering::SeqCst), writes + 2); + } +} + +#[test] +fn ffor_receipt_first_valid_evidence_survives_equivocation_and_later_errors() { + let (backend, store, binding, first) = seeded(0); + store.retain_receipt(&binding, key(43), &first).unwrap(); + let retained = receipt_bytes(&backend, &binding); + let writes = backend.writes.load(Ordering::SeqCst); + let different = EncryptedRecord::decode(&hex(include_str!("equivocation.txt").trim())).unwrap(); + let secrets = store.load(&binding).unwrap(); + assert!(secrets.decrypt_record(key(43), different.clone()).is_ok()); + assert_eq!( + store.retain_receipt(&binding, key(43), &different), + Err(WitnessStoreError::Conflict) + ); + assert_eq!(store.retain_receipt(&binding, key(40), &first), Err(WitnessStoreError::Corrupt)); + let (_, _, other_epoch) = fixture(1); + assert_eq!( + store.retain_receipt(&binding, key(43), &other_epoch), + Err(WitnessStoreError::Corrupt) + ); + assert!(store.load_receipt(&binding, key(43), 0).is_err()); + assert!(store.load_receipt(&binding, key(43), 2).is_err()); + assert_eq!(receipt_bytes(&backend, &binding), retained); + assert_eq!(backend.writes.load(Ordering::SeqCst), writes); + assert_eq!(store.load_receipt(&binding, key(43), 1).unwrap().unwrap().header(), first.header()); +} + +#[test] +fn ffor_receipt_initialization_each_write_failure_recovers_without_new_material() { + for failure in [1, 2] { + for target in 1..=3 { + let backend = TestStore::new(); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let binding = binding(1); + backend.fail_at.store(target, Ordering::SeqCst); + backend.write_failure.store(failure, Ordering::SeqCst); + assert_eq!( + store.create(&binding, &policies()).unwrap_err(), + WitnessStoreError::Storage + ); + assert_eq!(store.load(&binding).unwrap_err(), WitnessStoreError::Uncertain); + let before = { + let state = store.state.lock().unwrap(); + let pending = state.uncertain.as_ref().unwrap(); + if pending.namespace == Namespace::Secrets { + pending.bytes.clone() + } else { + state.records.get(&binding.storage_key()).unwrap().clone() + } + }; + let expected = store.wrapping_key.open(&binding, &before).unwrap(); + backend.fail_at.store(0, Ordering::SeqCst); + backend.write_failure.store(2, Ordering::SeqCst); + assert_eq!(store.recover_write(), Err(WitnessStoreError::Storage)); + assert_eq!(store.load(&binding).unwrap_err(), WitnessStoreError::Uncertain); + store.recover_write().unwrap(); + drop(store); + let restored = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let actual = restored.load(&binding).unwrap(); + assert!(matches!(actual.receipt_allocation, ReceiptAllocation::Reserved(_))); + for policy in policies() { + assert_eq!(actual.manifest(policy.witness), expected.manifest(policy.witness)); + } + assert!(restored.load_receipt(&binding, key(2), 1).unwrap().is_none()); + } + } +} + +#[test] +fn ffor_receipt_pending_restart_retains_full_quota_and_finishes_all_visible_phases() { + for target in 1..=3 { + let backend = TestStore::new(); + let binding = binding(1); + let reservation; + { + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + backend.fail_at.store(target, Ordering::SeqCst); + backend.write_failure.store(2, Ordering::SeqCst); + assert!(store.create(&binding, &policies()).is_err()); + reservation = store.state.lock().unwrap().receipt_reservations[&binding.storage_key()]; + } + let restored = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + assert_eq!( + restored.state.lock().unwrap().receipt_reservations[&binding.storage_key()], + reservation + ); + backend.fail_at.store(0, Ordering::SeqCst); + backend.write_failure.store(2, Ordering::SeqCst); + assert_eq!(restored.load(&binding).unwrap_err(), WitnessStoreError::Storage); + assert_eq!(restored.load(&binding).unwrap_err(), WitnessStoreError::Uncertain); + restored.recover_write().unwrap(); + assert!(matches!( + restored.load(&binding).unwrap().receipt_allocation, + ReceiptAllocation::Reserved(_) + )); + assert_eq!(receipt_bytes(&backend, &binding).len(), reservation); + } +} + +#[test] +fn ffor_receipt_uncertain_update_requires_successful_exact_rewrite_even_after_restart() { + for failure in [1, 2] { + let (backend, store, binding, receipt) = seeded(0); + let before = receipt_bytes(&backend, &binding); + backend.write_failure.store(failure, Ordering::SeqCst); + assert_eq!( + store.retain_receipt(&binding, key(43), &receipt), + Err(WitnessStoreError::Storage) + ); + let candidate = store.state.lock().unwrap().uncertain.as_ref().unwrap().bytes.clone(); + assert_ne!(candidate, before); + assert_eq!(store.load_receipt(&binding, key(43), 1), Err(WitnessStoreError::Uncertain)); + backend.write_failure.store(2, Ordering::SeqCst); + assert_eq!(store.recover_write(), Err(WitnessStoreError::Storage)); + assert_eq!(receipt_bytes(&backend, &binding), candidate); + drop(store); + let restored = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + // Confirm secret ciphertext first, then fail the receipt's durability confirmation. + backend.fail_at.store(backend.writes.load(Ordering::SeqCst) + 2, Ordering::SeqCst); + backend.write_failure.store(2, Ordering::SeqCst); + assert_eq!(restored.load_receipt(&binding, key(43), 1), Err(WitnessStoreError::Storage)); + assert_eq!(restored.load_receipt(&binding, key(43), 1), Err(WitnessStoreError::Uncertain)); + restored.recover_write().unwrap(); + assert!(restored.load_receipt(&binding, key(43), 1).unwrap().is_some()); + assert_eq!(receipt_bytes(&backend, &binding), candidate); + } +} + +#[test] +fn ffor_receipt_missing_corrupt_or_other_epoch_reservation_is_never_replaced() { + let (backend, store, binding, receipt) = seeded(0); + let initial = receipt_bytes(&backend, &binding); + let writes = backend.writes.load(Ordering::SeqCst); + for bytes in [vec![0; initial.len()], { + let mut bytes = initial.clone(); + bytes[50] ^= 1; + bytes + }] { + KVStoreSync::write(&backend.inner, NAMESPACE, "", &binding.storage_key(), bytes.clone()) + .unwrap(); + assert_eq!( + store.retain_receipt(&binding, key(43), &receipt), + Err(WitnessStoreError::Conflict) + ); + assert!(WitnessSecretStore::open(&[5; 64], backend.clone()).is_err()); + assert_eq!(receipt_bytes(&backend, &binding), bytes); + } + KVStoreSync::remove(&backend.inner, NAMESPACE, "", &binding.storage_key(), false).unwrap(); + assert_eq!(store.load(&binding).unwrap_err(), WitnessStoreError::Missing); + assert_eq!( + WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap_err(), + WitnessStoreError::Missing + ); + assert_eq!(backend.writes.load(Ordering::SeqCst), writes); +} + +#[test] +fn ffor_receipt_late_orphan_refuses_before_either_namespace_write() { + let backend = TestStore::new(); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let binding = binding(1); + KVStoreSync::write(&backend.inner, NAMESPACE, "", &binding.storage_key(), vec![91; 300]) + .unwrap(); + assert_eq!(store.create(&binding, &policies()).unwrap_err(), WitnessStoreError::Conflict); + assert_eq!(receipt_bytes(&backend, &binding), vec![91; 300]); + assert_eq!(backend.writes.load(Ordering::SeqCst), 0); + assert!(store.state.lock().unwrap().records.is_empty()); + assert!(store.state.lock().unwrap().receipt_reservations.is_empty()); +} + +#[test] +fn ffor_receipt_quota_is_reserved_before_write_and_retained_for_pending_records() { + let backend = TestStore::new(); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + store + .state + .lock() + .unwrap() + .receipt_reservations + .insert("unrelated".into(), MAX_RECEIPT_STORE_BYTES); + assert_eq!(store.create(&binding(1), &policies()).unwrap_err(), WitnessStoreError::Capacity); + assert_eq!(backend.writes.load(Ordering::SeqCst), 0); + assert!(store.state.lock().unwrap().records.is_empty()); + // Every authenticated Pending allocation is charged even with no receipt blob at all. + let backend = TestStore::new(); + let wrapping = super::super::WrappingKey::derive(&[5; 64]); + for epoch in 1..=9 { + let binding = binding(epoch); + let mut record = StoredWitnessEpoch::generate(&binding, &policies()).unwrap(); + record.receipt_allocation = ReceiptAllocation::Pending(MAX_RECEIPT_RECORD_BYTES as u32); + backend.replace(&binding.storage_key(), wrapping.seal(&binding, &record).unwrap()); + if epoch == 8 { + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + assert_eq!( + store.state.lock().unwrap().receipt_reservations.values().sum::(), + MAX_RECEIPT_STORE_BYTES + ); + // Full binding validation recomputes the actual shape, refusing forged allocation size. + assert_eq!(store.load(&binding).unwrap_err(), WitnessStoreError::Corrupt); + } + } + assert_eq!( + WitnessSecretStore::open(&[5; 64], backend).unwrap_err(), + WitnessStoreError::Capacity + ); + assert_eq!(MAX_EPOCHS, 64); +} + +#[test] +fn ffor_receipt_concurrent_retention_and_acknowledgement_preserve_both() { + let (backend, store, binding, receipt) = seeded(0); + let store = Arc::new(store); + let original = store.load(&binding).unwrap(); + let checked = checked_acknowledgement(&original, key(43), 1); + let tasks: Vec<_> = (0..8) + .map(|index| { + let store = store.clone(); + let binding = binding.clone(); + let receipt = receipt.clone(); + let checked = checked.clone(); + std::thread::spawn(move || { + if index % 2 == 0 { + store.retain_receipt(&binding, key(43), &receipt).unwrap(); + } else { + store.acknowledge(&binding, &checked).unwrap(); + } + }) + }) + .collect(); + for task in tasks { + task.join().unwrap(); + } + assert!(store.load(&binding).unwrap().all_witnesses_acknowledged()); + assert!(store.load_receipt(&binding, key(43), 1).unwrap().is_some()); + assert_eq!(backend.writes.load(Ordering::SeqCst), 5); +} + +proptest! { + #[test] + fn ffor_receipt_bounded_parser_refuses_malformed_reservations(bytes in prop::collection::vec(any::(), 0..2048)) { + let result = ReceiptBook::validate_inventory(&bytes, [9; 32]); + prop_assert!(result.is_err()); + } +} + +#[test] +fn ffor_receipt_recovery_refuses_deleted_or_valid_substituted_predecessor() { + let (backend, store, binding, receipt) = seeded(0); + let previous = receipt_bytes(&backend, &binding); + let (other_backend, _, other_binding, _) = seeded(1); + let unrelated = receipt_bytes(&other_backend, &other_binding); + backend.write_failure.store(1, Ordering::SeqCst); + assert_eq!(store.retain_receipt(&binding, key(43), &receipt), Err(WitnessStoreError::Storage)); + KVStoreSync::remove(&backend.inner, NAMESPACE, "", &binding.storage_key(), false).unwrap(); + assert_eq!(store.recover_write(), Err(WitnessStoreError::Missing)); + KVStoreSync::write(&backend.inner, NAMESPACE, "", &binding.storage_key(), unrelated.clone()) + .unwrap(); + assert_eq!(store.recover_write(), Err(WitnessStoreError::Conflict)); + assert!(WitnessSecretStore::open(&[5; 64], backend.clone()).is_err()); + assert_eq!(receipt_bytes(&backend, &binding), unrelated); + KVStoreSync::write(&backend.inner, NAMESPACE, "", &binding.storage_key(), previous).unwrap(); + store.recover_write().unwrap(); + assert!(store.load_receipt(&binding, key(43), 1).unwrap().is_some()); +} + +#[test] +fn ffor_receipt_legacy_records_remain_historical_without_allocating_receipts() { + let (binding, record, receipt) = fixture(0); + let backend = TestStore::new(); + let wrapping = super::super::WrappingKey::derive(&[5; 64]); + backend.replace(&binding.storage_key(), wrapping.seal(&binding, &record).unwrap()); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let loaded = store.load(&binding).unwrap(); + assert_eq!(loaded.receipt_allocation, ReceiptAllocation::Legacy); + assert_eq!( + store.retain_receipt(&binding, key(43), &receipt), + Err(WitnessStoreError::Unreserved) + ); + assert_eq!(store.load_receipt(&binding, key(43), 1), Err(WitnessStoreError::Unreserved)); + assert_eq!( + store.create(&binding, &record.policies()).unwrap().receipt_allocation, + ReceiptAllocation::Legacy + ); + assert!(KVStoreSync::list(&*backend, NAMESPACE, "").unwrap().is_empty()); + store.acknowledge(&binding, &checked_acknowledgement(&loaded, key(43), 1)).unwrap(); + assert!(store.load(&binding).unwrap().all_witnesses_acknowledged()); + assert!(store.state.lock().unwrap().receipt_reservations.is_empty()); +} + +#[test] +fn ffor_receipt_inventory_authenticates_and_bounds_allocation_metadata() { + let binding = binding(1); + let mut record = StoredWitnessEpoch::generate(&binding, &policies()).unwrap(); + record.receipt_allocation = ReceiptAllocation::Pending(5000); + let original = record.encode(); + let wrapping = super::super::WrappingKey::derive(&[5; 64]); + let mut cases = Vec::new(); + for (offset, value) in [(0, 9), (66, 0), (66, 5), (67, 0), (67, 3)] { + let mut bytes = original.clone(); + bytes[offset] = value; + cases.push(bytes); + } + for size in [0, MAX_RECEIPT_RECORD_BYTES as u32 + 1, u32::MAX] { + let mut bytes = original.clone(); + bytes[68..72].copy_from_slice(&size.to_be_bytes()); + cases.push(bytes); + } + for length in 0..72 { + cases.push(Zeroizing::new(original[..length].to_vec())); + } + for plaintext in cases { + let backend = TestStore::new(); + let sealed = wrapping.seal_plaintext(&binding, &plaintext).unwrap(); + backend.replace(&binding.storage_key(), sealed.clone()); + assert!(WitnessSecretStore::open(&[5; 64], backend.clone()).is_err()); + assert_eq!(backend.bytes(&binding.storage_key()), sealed); + assert_eq!(backend.writes.load(Ordering::SeqCst), 0); + } +} + +#[test] +fn ffor_receipt_parser_rejects_truncation_trailing_slots_and_manifest_substitution() { + let (binding, record, encrypted) = fixture(0); + let mut book = ReceiptBook::empty(&binding, &record); + book.insert(key(43), &encrypted).unwrap(); + let encoded = book.encode(); + for length in 0..encoded.len() { + assert!(ReceiptBook::decode(&binding, &record, &encoded[..length]).is_err()); + } + let mut trailing = encoded.clone(); + trailing.push(0); + assert!(ReceiptBook::decode(&binding, &record, &trailing).is_err()); + for offset in [0, 2, 34, 35, 36, 37, 70, 102, 103, encoded.len() - 1] { + let mut changed = encoded.clone(); + changed[offset] ^= 1; + assert!(ReceiptBook::decode(&binding, &record, &changed).is_err(), "offset {offset}"); + } + // A fixed empty slot cannot smuggle nonzero bytes without its presence flag. + let mut empty = ReceiptBook::empty(&binding, &record).encode(); + *empty.last_mut().unwrap() = 1; + assert!(ReceiptBook::decode(&binding, &record, &empty).is_err()); + assert_eq!(CORE_BYTES, 492); +} diff --git a/src/ffor/witness_store/record.rs b/src/ffor/witness_store/record.rs index 8908b31e23..182dbbdb2e 100644 --- a/src/ffor/witness_store/record.rs +++ b/src/ffor/witness_store/record.rs @@ -14,6 +14,7 @@ use super::{WitnessStorageBinding, WitnessStoreError, MAX_WITNESSES}; mod key_use; const RECORD_VERSION: u16 = 2; +const RESERVED_RECORD_VERSION: u16 = 3; const LEGACY_RECORD_VERSION: u16 = 1; // Reserved even before provisioning so each acknowledgement fits without growing a new record. const ACKNOWLEDGEMENT_BYTES: usize = 1 + 16 + 4; @@ -48,12 +49,30 @@ struct RetainedAcknowledgement { retention_until: u32, } -/// Immutable material recovered from one successful store write, not proof of native activation. +/// Immutable secrets and monotonic promises from confirmed storage, not native activation authority. /// There is no key-export or sending API. A future consumer must rejoin the native epoch owner. pub(crate) struct StoredWitnessEpoch { binding_digest: [u8; 32], encryption_secret: Zeroizing<[u8; 32]>, witnesses: Vec, + pub(super) receipt_allocation: ReceiptAllocation, +} + +/// Storage initialization only. No variant conveys protocol or invoice authority. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ReceiptAllocation { + Legacy, + Pending(u32), + Reserved(u32), +} + +impl ReceiptAllocation { + pub(super) fn bytes(self) -> usize { + match self { + Self::Legacy => 0, + Self::Pending(bytes) | Self::Reserved(bytes) => bytes as usize, + } + } } impl fmt::Debug for StoredWitnessEpoch { @@ -116,7 +135,12 @@ impl StoredWitnessEpoch { acknowledgement: None, }); } - Ok(Self { binding_digest: binding.digest(), encryption_secret, witnesses }) + Ok(Self { + binding_digest: binding.digest(), + encryption_secret, + witnesses, + receipt_allocation: ReceiptAllocation::Legacy, + }) } pub(super) fn manifest(&self, witness: PublicKey) -> Option<&SignedManifest> { @@ -166,15 +190,32 @@ impl StoredWitnessEpoch { self.witnesses.iter().map(|entry| entry.manifest.encode()).collect(); // Reserve the exact size before copying secrets, avoiding freed intermediate allocations. let capacity = 67 + + if self.receipt_allocation == ReceiptAllocation::Legacy { 0 } else { 5 } + manifests .iter() .map(|manifest| 69 + manifest.len() + ACKNOWLEDGEMENT_BYTES) .sum::(); let mut bytes = Zeroizing::new(Vec::with_capacity(capacity)); - bytes.extend_from_slice(&RECORD_VERSION.to_be_bytes()); + let version = if self.receipt_allocation == ReceiptAllocation::Legacy { + RECORD_VERSION + } else { + RESERVED_RECORD_VERSION + }; + bytes.extend_from_slice(&version.to_be_bytes()); bytes.extend_from_slice(&self.binding_digest); bytes.extend_from_slice(self.encryption_secret.as_ref()); bytes.push(self.witnesses.len() as u8); + match self.receipt_allocation { + ReceiptAllocation::Legacy => {}, + ReceiptAllocation::Pending(size) | ReceiptAllocation::Reserved(size) => { + bytes.push(if matches!(self.receipt_allocation, ReceiptAllocation::Pending(_)) { + 1 + } else { + 2 + }); + bytes.extend_from_slice(&size.to_be_bytes()); + }, + } for (entry, manifest) in self.witnesses.iter().zip(manifests) { bytes.extend_from_slice(&entry.policy.witness.serialize()); bytes.extend_from_slice(entry.fetch_secret.as_ref()); @@ -196,7 +237,7 @@ impl StoredWitnessEpoch { ) -> Result { let mut reader = Reader(bytes); let version = u16::from_be_bytes(reader.array()?); - if !matches!(version, LEGACY_RECORD_VERSION | RECORD_VERSION) + if !matches!(version, LEGACY_RECORD_VERSION | RECORD_VERSION | RESERVED_RECORD_VERSION) || reader.array::<32>()? != binding.digest() { return Err(WitnessStoreError::Binding); @@ -207,6 +248,7 @@ impl StoredWitnessEpoch { if count == 0 || count > MAX_WITNESSES { return Err(WitnessStoreError::Capacity); } + let receipt_allocation = read_allocation(version, &mut reader)?; let mut witnesses: Vec = Vec::with_capacity(count); for _ in 0..count { let witness = @@ -237,7 +279,7 @@ impl StoredWitnessEpoch { retention_until: params.retention_until, minimum_receipts: params.minimum_receipts, }; - let acknowledgement = if version == RECORD_VERSION { + let acknowledgement = if version != LEGACY_RECORD_VERSION { let present = reader.array::<1>()?[0]; let request_id = reader.array()?; let retention_until = u32::from_be_bytes(reader.array()?); @@ -256,7 +298,51 @@ impl StoredWitnessEpoch { if !reader.0.is_empty() { return Err(WitnessStoreError::Corrupt); } - Ok(Self { binding_digest: binding.digest(), encryption_secret, witnesses }) + Ok(Self { + binding_digest: binding.digest(), + encryption_secret, + witnesses, + receipt_allocation, + }) + } + + // The envelope is authenticated before inventory calls this. Read only the fixed allocation + // prefix so reopen can charge Pending quotas without possessing a native context for every epoch. + pub(super) fn inventory_allocation( + bytes: &[u8], digest: [u8; 32], + ) -> Result { + let mut reader = Reader(bytes); + let version = u16::from_be_bytes(reader.array()?); + if reader.array::<32>()? != digest { + return Err(WitnessStoreError::Binding); + } + reader.take(32)?; + let count = reader.array::<1>()?[0] as usize; + if count == 0 || count > MAX_WITNESSES { + return Err(WitnessStoreError::Corrupt); + } + read_allocation(version, &mut reader) + } +} + +fn read_allocation( + version: u16, reader: &mut Reader<'_>, +) -> Result { + match version { + LEGACY_RECORD_VERSION | RECORD_VERSION => Ok(ReceiptAllocation::Legacy), + RESERVED_RECORD_VERSION => { + let state = reader.array::<1>()?[0]; + let size = u32::from_be_bytes(reader.array()?); + if size == 0 || size as usize > super::receipt::MAX_RECEIPT_RECORD_BYTES { + return Err(WitnessStoreError::Capacity); + } + match state { + 1 => Ok(ReceiptAllocation::Pending(size)), + 2 => Ok(ReceiptAllocation::Reserved(size)), + _ => Err(WitnessStoreError::Corrupt), + } + }, + _ => Err(WitnessStoreError::Corrupt), } } diff --git a/src/ffor/witness_store/record/key_use/tests.rs b/src/ffor/witness_store/record/key_use/tests.rs index 36d78219eb..f6a4749587 100644 --- a/src/ffor/witness_store/record/key_use/tests.rs +++ b/src/ffor/witness_store/record/key_use/tests.rs @@ -57,6 +57,7 @@ impl Fixture<'_> { }; StoredWitnessEpoch { binding_digest: [1; 32], + receipt_allocation: super::super::ReceiptAllocation::Legacy, encryption_secret: Zeroizing::new([44; 32]), witnesses: vec![WitnessKeys { acknowledgement: None, diff --git a/src/ffor/witness_store/tests.rs b/src/ffor/witness_store/tests.rs index 3d42dbd552..9fce1f8959 100644 --- a/src/ffor/witness_store/tests.rs +++ b/src/ffor/witness_store/tests.rs @@ -19,26 +19,28 @@ use crate::io::test_utils::InMemoryStore; use super::*; -struct TestStore { - inner: InMemoryStore, - writes: AtomicUsize, - write_failure: AtomicUsize, - read_failure: AtomicBool, +pub(super) struct TestStore { + pub(super) inner: InMemoryStore, + pub(super) writes: AtomicUsize, + pub(super) write_failure: AtomicUsize, + pub(super) fail_at: AtomicUsize, + pub(super) read_failure: AtomicBool, } impl TestStore { - fn new() -> Arc { + pub(super) fn new() -> Arc { Arc::new(Self { inner: InMemoryStore::new(), writes: AtomicUsize::new(0), write_failure: AtomicUsize::new(0), + fail_at: AtomicUsize::new(0), read_failure: AtomicBool::new(false), }) } - fn bytes(&self, key: &str) -> Vec { + pub(super) fn bytes(&self, key: &str) -> Vec { KVStoreSync::read(&self.inner, PRIMARY_NAMESPACE, SECONDARY_NAMESPACE, key).unwrap() } - fn replace(&self, key: &str, bytes: Vec) { + pub(super) fn replace(&self, key: &str, bytes: Vec) { KVStoreSync::write(&self.inner, PRIMARY_NAMESPACE, SECONDARY_NAMESPACE, key, bytes) .unwrap(); } @@ -52,8 +54,13 @@ impl KVStoreSync for TestStore { KVStoreSync::read(&self.inner, primary, secondary, key) } fn write(&self, primary: &str, secondary: &str, key: &str, bytes: Vec) -> io::Result<()> { - self.writes.fetch_add(1, Ordering::SeqCst); - let failure = self.write_failure.swap(0, Ordering::SeqCst); + let number = self.writes.fetch_add(1, Ordering::SeqCst) + 1; + let target = self.fail_at.load(Ordering::SeqCst); + let failure = if target == 0 || target == number { + self.write_failure.swap(0, Ordering::SeqCst) + } else { + 0 + }; if failure != 1 { KVStoreSync::write(&self.inner, primary, secondary, key, bytes)?; } @@ -98,7 +105,7 @@ impl KVStore for TestStore { } } -fn key(byte: u8) -> PublicKey { +pub(super) fn key(byte: u8) -> PublicKey { PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[byte; 32]).unwrap()) } @@ -168,19 +175,19 @@ pub(super) fn evidence( (identity, setup, activation, ack) } -fn binding(epoch: u8) -> WitnessStorageBinding { +pub(super) fn binding(epoch: u8) -> WitnessStorageBinding { let (identity, setup, activation, ack) = evidence(epoch, 2); WitnessStorageBinding::new(identity, setup, &activation, &ack).unwrap() } -fn policies() -> Vec { +pub(super) fn policies() -> Vec { vec![ WitnessPolicy { witness: key(2), retention_until: 2144, minimum_receipts: 0 }, WitnessPolicy { witness: key(3), retention_until: 2200, minimum_receipts: 2 }, ] } -fn checked_acknowledgement( +pub(super) fn checked_acknowledgement( record: &StoredWitnessEpoch, witness: PublicKey, id: u8, ) -> CheckedAcknowledgement { checked_manifest_acknowledgement(record.manifest(witness).unwrap().clone(), witness, id) @@ -204,7 +211,7 @@ fn legacy_plaintext(record: &StoredWitnessEpoch) -> zeroize::Zeroizing> let encoded = record.encode(); let mut legacy = zeroize::Zeroizing::new(encoded[..67].to_vec()); legacy[..2].copy_from_slice(&1u16.to_be_bytes()); - let mut offset = 67; + let mut offset = if encoded[..2] == 3u16.to_be_bytes() { 72 } else { 67 }; for _ in record.policies() { let length = u32::from_be_bytes(encoded[offset + 65..offset + 69].try_into().unwrap()) as usize; @@ -239,14 +246,14 @@ fn ffor_witness_store_acknowledgements_are_durable_monotonic_and_preserve_keys() store.acknowledge(&binding, &checked_acknowledgement(&initial, key(2), 3)).unwrap(); assert_eq!(*repeated.encode(), *complete.encode()); assert_eq!(backend.bytes(&binding.storage_key()), complete_bytes); - assert_eq!(backend.writes.load(Ordering::SeqCst), 3); + assert_eq!(backend.writes.load(Ordering::SeqCst), 5); drop(store); let restored = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); let loaded = restored.load(&binding).unwrap(); assert!(loaded.all_witnesses_acknowledged()); assert_eq!(*loaded.encode(), *complete.encode()); assert_eq!(backend.bytes(&binding.storage_key()), complete_bytes); - assert_eq!(backend.writes.load(Ordering::SeqCst), 4); + assert_eq!(backend.writes.load(Ordering::SeqCst), 7); } #[test] @@ -270,7 +277,7 @@ fn ffor_witness_store_acknowledgement_requires_exact_manifest_and_selected_witne assert_eq!(store.acknowledge(&binding(1), &ack).unwrap_err(), WitnessStoreError::Binding); } assert!(!store.load(&binding(1)).unwrap().all_witnesses_acknowledged()); - assert_eq!(backend.writes.load(Ordering::SeqCst), 1); + assert_eq!(backend.writes.load(Ordering::SeqCst), 3); } #[test] @@ -300,7 +307,7 @@ fn ffor_witness_store_acknowledgement_uncertain_update_reuses_exact_ciphertext() assert_ne!(*first.encode(), *initial.encode()); assert!(!first.all_witnesses_acknowledged()); assert_eq!(first.manifest(key(2)), initial.manifest(key(2))); - assert_eq!(backend.writes.load(Ordering::SeqCst), 4); + assert_eq!(backend.writes.load(Ordering::SeqCst), 6); } } @@ -422,6 +429,7 @@ fn ffor_witness_store_sealed_legacy_ack_upgrade_handles_failure_and_capacity() { .seal_plaintext_fixture(&binding, &legacy_plaintext(&original)) .unwrap(); backend.replace(&binding.storage_key(), legacy.clone()); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); let mut remaining = MAX_STORE_BYTES - legacy.len(); for index in 0..MAX_EPOCHS { if remaining == 0 { @@ -430,11 +438,10 @@ fn ffor_witness_store_sealed_legacy_ack_upgrade_handles_failure_and_capacity() { let key = format!("f{index:063x}"); assert_ne!(key, binding.storage_key()); let count = remaining.min(MAX_RECORD_BYTES); - backend.replace(&key, vec![9; count]); + store.state.lock().unwrap().records.insert(key, vec![9; count]); remaining -= count; } assert_eq!(remaining, 0); - let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); let loaded = store.load(&binding).unwrap(); assert_eq!( store.acknowledge(&binding, &checked_acknowledgement(&loaded, key(2), 1)).unwrap_err(), @@ -466,7 +473,7 @@ fn ffor_witness_store_concurrent_acknowledgements_do_not_lose_a_promise() { task.join().unwrap(); } assert!(store.load(&binding).unwrap().all_witnesses_acknowledged()); - assert_eq!(backend.writes.load(Ordering::SeqCst), 3); + assert_eq!(backend.writes.load(Ordering::SeqCst), 5); } #[test] @@ -481,7 +488,7 @@ fn ffor_witness_store_exact_manifests_survive_retry_and_restart() { let repeated = store.create(&binding, &reordered).unwrap(); let reopened = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); let restored = reopened.load(&binding).unwrap(); - assert_eq!(backend.writes.load(Ordering::SeqCst), 2); + assert_eq!(backend.writes.load(Ordering::SeqCst), 5); assert_eq!(backend.bytes(&binding.storage_key()), saved); for policy in policies() { let manifest = first.manifest(policy.witness).unwrap(); @@ -547,7 +554,7 @@ fn ffor_witness_key_use_fetch_retries_and_reload_use_fresh_nonces() { } } // One initial write and one restored durability confirmation, with no mutable nonce counter. - assert_eq!(backend.writes.load(Ordering::SeqCst), 2); + assert_eq!(backend.writes.load(Ordering::SeqCst), 5); } #[test] @@ -600,7 +607,7 @@ fn ffor_witness_store_uncertain_writes_recover_only_the_exact_candidate() { store.recover_write().unwrap(); assert_eq!(backend.bytes(&binding.storage_key()), candidate); assert!(store.load(&binding).is_ok()); - assert_eq!(backend.writes.load(Ordering::SeqCst), 2); + assert_eq!(backend.writes.load(Ordering::SeqCst), 4); let reopened = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); assert!(reopened.load(&binding).is_ok()); } @@ -638,8 +645,12 @@ fn ffor_witness_store_restart_recovers_a_write_whose_success_reply_was_lost() { let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); assert!(store.load(&binding).is_ok()); assert!(store.create(&binding, &policies()).is_ok()); - assert_eq!(backend.bytes(&binding.storage_key()), candidate); - assert_eq!(backend.writes.load(Ordering::SeqCst), 2); + let pending = WrappingKey::derive(&[5; 64]).open(&binding, &candidate).unwrap(); + let loaded = store.load(&binding).unwrap(); + for policy in policies() { + assert_eq!(pending.manifest(policy.witness), loaded.manifest(policy.witness)); + } + assert_eq!(backend.writes.load(Ordering::SeqCst), 4); } #[test] @@ -663,8 +674,12 @@ fn ffor_witness_store_visible_bytes_need_a_successful_durability_retry() { assert_eq!(backend.bytes(&binding.storage_key()), candidate); restored.recover_write().unwrap(); assert!(restored.load(&binding).is_ok()); - assert_eq!(backend.bytes(&binding.storage_key()), candidate); - assert_eq!(backend.writes.load(Ordering::SeqCst), 4); + let pending = WrappingKey::derive(&[5; 64]).open(&binding, &candidate).unwrap(); + let loaded = restored.load(&binding).unwrap(); + for policy in policies() { + assert_eq!(pending.manifest(policy.witness), loaded.manifest(policy.witness)); + } + assert_eq!(backend.writes.load(Ordering::SeqCst), 6); } #[test] @@ -698,16 +713,16 @@ fn ffor_witness_store_corruption_missing_and_wrong_seed_never_regenerate() { let binding = binding(1); store.create(&binding, &policies()).unwrap(); let original = backend.bytes(&binding.storage_key()); - let wrong_seed = WitnessSecretStore::open(&[6; 64], backend.clone()).unwrap(); - assert_eq!(wrong_seed.load(&binding).unwrap_err(), WitnessStoreError::Corrupt); + assert_eq!( + WitnessSecretStore::open(&[6; 64], backend.clone()).unwrap_err(), + WitnessStoreError::Corrupt + ); for index in [0, 4, 8, 50, original.len() - 1] { let mut corrupted = original.clone(); corrupted[index] ^= 1; backend.replace(&binding.storage_key(), corrupted.clone()); assert_eq!(store.load(&binding).unwrap_err(), WitnessStoreError::Conflict); - let restored = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); - assert!(restored.load(&binding).is_err()); - assert!(restored.create(&binding, &policies()).is_err()); + assert!(WitnessSecretStore::open(&[5; 64], backend.clone()).is_err()); assert_eq!(backend.bytes(&binding.storage_key()), corrupted); } KVStoreSync::remove( @@ -720,7 +735,7 @@ fn ffor_witness_store_corruption_missing_and_wrong_seed_never_regenerate() { .unwrap(); assert_eq!(store.load(&binding).unwrap_err(), WitnessStoreError::Missing); assert_eq!(store.create(&binding, &policies()).unwrap_err(), WitnessStoreError::Missing); - assert_eq!(backend.writes.load(Ordering::SeqCst), 1); + assert_eq!(backend.writes.load(Ordering::SeqCst), 3); } #[test] @@ -755,10 +770,16 @@ fn ffor_witness_store_plaintext_parser_checks_every_key_and_manifest() { #[test] fn ffor_witness_store_limits_refuse_without_eviction_or_partial_write() { let backend = TestStore::new(); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); for index in 0..16 { backend.replace(&format!("{index:064x}"), vec![9; MAX_RECORD_BYTES]); + store + .state + .lock() + .unwrap() + .records + .insert(format!("{index:064x}"), vec![9; MAX_RECORD_BYTES]); } - let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); assert_eq!(store.create(&binding(1), &policies()).unwrap_err(), WitnessStoreError::Capacity); assert_eq!(backend.writes.load(Ordering::SeqCst), 0); backend.replace(&format!("{:064x}", 16), vec![9]); @@ -767,10 +788,11 @@ fn ffor_witness_store_limits_refuse_without_eviction_or_partial_write() { WitnessStoreError::Capacity ); let backend = TestStore::new(); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); for index in 0..64 { backend.replace(&format!("{index:064x}"), vec![9]); + store.state.lock().unwrap().records.insert(format!("{index:064x}"), vec![9]); } - let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); assert_eq!(store.create(&binding(1), &policies()).unwrap_err(), WitnessStoreError::Capacity); backend.replace(&format!("{:064x}", 64), vec![9]); assert_eq!( @@ -816,7 +838,7 @@ fn ffor_witness_store_concurrent_create_shares_one_durable_candidate() { .collect(); let manifests: Vec<_> = tasks.into_iter().map(|task| task.join().unwrap()).collect(); assert!(manifests.iter().all(|manifest| manifest == &manifests[0])); - assert_eq!(backend.writes.load(Ordering::SeqCst), 1); + assert_eq!(backend.writes.load(Ordering::SeqCst), 3); } proptest! { From 0bd0349c64523cd46cbfb6bf2edde1c4813ffa02 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 21 Sep 2026 01:26:41 -0400 Subject: [PATCH 15/23] feat: connect native receiver activation and close --- Cargo.lock | 30 ++++++------- Cargo.toml | 26 +++++------ FFOR.md | 70 ++++++++++++++++++++++-------- src/message_handler.rs | 4 +- src/message_handler/ffor/setup.rs | 41 +++++++++++++---- src/message_handler/tests/setup.rs | 37 +++++++++++++--- 6 files changed, 144 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 934fdf037c..4d0ba55cca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1994,7 +1994,7 @@ dependencies = [ [[package]] name = "lightning" version = "0.2.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" dependencies = [ "bech32", "bitcoin", @@ -2006,7 +2006,7 @@ dependencies = [ "lightning-macros 0.2.1", "lightning-types 0.3.5", "musig2", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14)", "regex", ] @@ -2026,7 +2026,7 @@ dependencies = [ [[package]] name = "lightning-background-processor" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" dependencies = [ "bitcoin", "bitcoin-io", @@ -2034,7 +2034,7 @@ dependencies = [ "lightning 0.2.5", "lightning-liquidity 0.2.3", "lightning-rapid-gossip-sync 0.2.3", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14)", ] [[package]] @@ -2053,7 +2053,7 @@ dependencies = [ [[package]] name = "lightning-block-sync" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" dependencies = [ "bitcoin", "chunked_transfer", @@ -2065,7 +2065,7 @@ dependencies = [ [[package]] name = "lightning-ffor" version = "0.1.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" dependencies = [ "bitcoin", ] @@ -2085,7 +2085,7 @@ dependencies = [ [[package]] name = "lightning-invoice" version = "0.34.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" dependencies = [ "bech32", "bitcoin", @@ -2111,7 +2111,7 @@ dependencies = [ [[package]] name = "lightning-liquidity" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" dependencies = [ "bitcoin", "chrono", @@ -2137,7 +2137,7 @@ dependencies = [ [[package]] name = "lightning-macros" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" dependencies = [ "proc-macro2", "quote", @@ -2158,7 +2158,7 @@ dependencies = [ [[package]] name = "lightning-net-tokio" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2179,7 +2179,7 @@ dependencies = [ [[package]] name = "lightning-persister" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2202,7 +2202,7 @@ dependencies = [ [[package]] name = "lightning-rapid-gossip-sync" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" dependencies = [ "bitcoin", "bitcoin-io", @@ -2227,7 +2227,7 @@ dependencies = [ [[package]] name = "lightning-transaction-sync" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" dependencies = [ "bitcoin", "electrum-client 0.24.1", @@ -2249,7 +2249,7 @@ dependencies = [ [[package]] name = "lightning-types" version = "0.3.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" dependencies = [ "bitcoin", ] @@ -2676,7 +2676,7 @@ dependencies = [ [[package]] name = "possiblyrandom" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=dec0df9b5ff7e04255e48d7db506fd12a3216a89#dec0df9b5ff7e04255e48d7db506fd12a3216a89" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" dependencies = [ "getrandom 0.2.17", ] diff --git a/Cargo.toml b/Cargo.toml index ca72646de4..cb18f72d2e 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,18 +34,18 @@ default = [] [dependencies] # Pin runtime dependencies directly so downstream consumers use the same native engine. -lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89" } -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89", features = ["std"] } -lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89" } -lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89" } -lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89" } -lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89" } -lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89", features = ["std"] } -lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89" } +lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14" } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14", features = ["std"] } +lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14" } +lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14" } +lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14" } +lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14" } +lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14", features = ["std"] } +lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14" } bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} @@ -81,7 +81,7 @@ prost = { version = "0.11.6", default-features = false} winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "dec0df9b5ff7e04255e48d7db506fd12a3216a89", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14", features = ["std", "_test_utils"] } proptest = "1.0.0" regex = "1.5.6" criterion = { version = "0.7.0", features = ["async_tokio"] } diff --git a/FFOR.md b/FFOR.md index 6ab38c6a67..4f0fe7f00f 100644 --- a/FFOR.md +++ b/FFOR.md @@ -31,16 +31,22 @@ signed lifecycle types, witness acknowledgement type 55057 and fetch response ty native-authorized release callback. The production builder leaves this transport disabled. This slice has no feature advertisement or public setting. -The private setup adapter uses the concrete native manager and pairs its opaque +The private receiver adapter uses the concrete native manager and pairs its opaque authenticated generation with the transport token in the same bounded peer map. -Accept and pre-accept Abort are processed synchronously before the custom callback -returns, so following ordinary HTLC frames cannot overtake native voucher ownership. +Accept, ActivateAck, Abort and CloseAck are processed synchronously before the custom +callback returns, so following ordinary HTLC frames cannot overtake native ownership. A native error requests disconnection and never falls back to the mailbox. Other unsupported lifecycle inputs and witness replies remain bounded queued work. Failed LSPS connection callbacks, disconnect and replacement clear both tokens and queues. -Preparation, exact Init release, retry lookup and cancellation delegate to native -authority. No builder constructs this adapter yet; activation and recovery orchestration -remain necessary before offering offline invoices. +Preparation, exact wire release, close intent, retry lookup and cancellation delegate +to native authority. Advancement first asks the native planner for its next operation. +Only a native request for proof reads the concrete ChainMonitor; the adapter drops +that monitor guard before passing the opaque snapshot and original paired generation +back to the manager. Stock peer events, STFU, commitment rounds and persistence must +continue between advances. The adapter retains no independent lifecycle state and +does not treat Active progress as invoice readiness. No builder constructs it yet; +runtime scheduling, witness orchestration and payment recovery remain necessary before +offering offline invoices. The transport accepts peer identity only from PeerManager's authenticated callback. Each successful connection gets a distinct opaque token. Disconnect or replacement @@ -89,7 +95,7 @@ Parser fuzzing remains in the shared `lightning-ffor` wire and witness targets; Node also runs arbitrary-byte property checks at the length-limited reader boundary. -## Private witness secret storage +## Private witness recovery storage The private `ffor::witness_store` module retains an immutable encryption key per epoch, plus a separate fetch key, mailbox and exact signed manifest per witness. @@ -110,9 +116,11 @@ additional plaintext allocations which it does not zeroize; complete memory eras is not claimed. The construction follows [HKDF](https://www.rfc-editor.org/rfc/rfc5869) and [ChaCha20Poly1305](https://www.rfc-editor.org/rfc/rfc8439). -One exclusive owner serializes the namespace. Admission is bounded to four witnesses -per epoch, 512 KiB per encrypted record, 64 epochs and 8 MiB overall. Records are not -evicted to admit new work. Keys and signed manifests cannot be replaced. A failed write blocks that owner until the +One exclusive owner serializes the secret and receipt namespaces. Secret admission +is bounded to four witnesses per epoch, 512 KiB per encrypted record, 64 epochs and +8 MiB overall. The separate receipt namespace permits at most 1 MiB per epoch and +8 MiB overall. Records are not evicted to admit new work. Keys and signed manifests +cannot be replaced. A failed write blocks that owner until the exact retained ciphertext is written successfully. Readable bytes alone do not prove durability: a filesystem rename can become visible before directory synchronization succeeds. Reopening likewise requires an authenticated, byte-identical successful @@ -122,13 +130,33 @@ manifests for an existing record. The store also retains each selected witness's first exactly correlated provisioning acknowledgement. It rechecks the complete immutable manifest and retention promise before sealing the update. Subsequent requests cannot replace the first promise. -Schema 2 reserves fixed acknowledgement slots when keys are created, so recording -all promises does not grow the record. Schema 1 loads with no promises; its first -update upgrades the format only if the existing global capacity permits it. +Fixed acknowledgement slots are reserved when keys are created, so recording all +promises does not grow the record. Historical schema 1 records load without promises; +their first acknowledgement update upgrades the format only within capacity. + +New schema 3 records also reserve encrypted evidence capacity before any material is +returned. The fixed receipt book contains one slot per selected witness and native +voucher. Creation first writes the immutable secrets with a pending allocation, then +the empty encrypted receipt book, then the same secrets marked reserved. Interrupted +creation resumes with exactly those keys and manifests. Inventory accounting charges +the complete reservation even when the pending receipt write has not appeared yet. +A missing or damaged completed reservation is never recreated. Existing schema 1 +and 2 records remain available for historical key operations and acknowledgements, +but cannot be provisioned or silently acquire a new receipt reservation. + +Each received record must match the selected witness and immutable manifest and pass +native authenticated decryption and voucher checks before retention. The receipt book +stores the signed encrypted core, excludes unsigned guardian attachments and contains +no plaintext preimages. Its wrapping key and associated-data domain are separate from +secret storage. Identical cores deduplicate; a different valid core for the same +witness and slot is reported as a conflict while preserving the first evidence. +Loading retained evidence confirms durability and authenticates it again. An empty +slot or completed fetch cannot establish that a voucher is unpaid. A failed update retains the exact proposed ciphertext and its original predecessor. -Retry accepts only those bytes and always requires another successful write. A deleted -existing record or conflicting replacement blocks recovery. Restored promises likewise +Retry accepts only those bytes and always requires another successful write. This +rule applies across both namespaces. A deleted existing record or conflicting +replacement blocks recovery. Restored promises likewise require successful durability confirmation before being returned. Historical promises from every selected witness are necessary storage evidence, not current native provisioning authority or invoice readiness. @@ -145,16 +173,20 @@ exporting either private key. Each fetch call draws a fresh operating-system req identity, 256-bit nonce and signature entropy, including retries after a lost response or restart. The witness still enforces replay refusal. Decryption authenticates the retained witness and manifest before native AEAD and body checks. These helpers do not -correlate a response connection, persist a receipt, change a channel or credit a -payment. Witness transport orchestration, durable payment-receipt retention and native -monitor reconciliation remain separate work. +correlate a response connection, change a channel or credit a payment. Receipt +retention preserves authenticated evidence for later native reconciliation. Witness +transport orchestration and native monitor reconciliation remain separate work. Run `cargo test --lib ffor_witness` for exact retries and restart, failed writes with readable bytes, corruption, wrong seeds and bindings, key separation, capacity, concurrent creation, fallible entropy, truncated records and property-based mutation checks, fresh fetch authorization after restart, protected decryption of pinned Beignet records, exact acknowledgement retention, concurrent acknowledgements, failed -updates and sealed legacy-record upgrades. These checks do not establish complete branch coverage or process-crash +updates and sealed legacy-record upgrades. Receipt tests cover all initialization +write boundaries, visible failed writes after restart, exact deduplication, witness +equivocation, allocation quotas, missing reservations, legacy refusal and concurrent +acknowledgement and receipt retention. Fixture provenance is recorded beside the +receipt tests. These checks do not establish complete branch coverage or process-crash interoperability. ## Required runtime integration diff --git a/src/message_handler.rs b/src/message_handler.rs index 64a2e9140a..1e38e3f447 100644 --- a/src/message_handler.rs +++ b/src/message_handler.rs @@ -141,10 +141,10 @@ where }, NodeCustomMessage::Ffor(message) => { if let Some(setup) = self.ffor_setup.as_ref() { - if matches!(message.type_id(), 55003 | 55049) { + if matches!(message.type_id(), 55003 | 55047 | 55049 | 55053) { return setup.handle(sender, &message).map(|_| ()).map_err(|error| { LightningError { - err: format!("FFOR receiver setup: {}", error), + err: format!("FFOR receiver: {}", error), // Do not process following HTLC frames after failed native admission. action: ErrorAction::DisconnectPeer { msg: None }, } diff --git a/src/message_handler/ffor/setup.rs b/src/message_handler/ffor/setup.rs index 2acb03ab87..db08e5c05f 100644 --- a/src/message_handler/ffor/setup.rs +++ b/src/message_handler/ffor/setup.rs @@ -1,5 +1,5 @@ -//! Private setup bridge. The concrete native manager owns admission, signatures and persistence. -//! No production builder constructs this bridge, and setup progress grants no invoice authority. +//! Private receiver bridge. The concrete native manager owns lifecycle and persistence authority. +//! No production builder constructs this bridge, and progress grants no invoice authority. use std::sync::Arc; @@ -11,19 +11,23 @@ use lightning::ln::ffor::{ use lightning::ln::types::ChannelId; use super::{FforFrame, FforReceiverTransport, NativeConnection}; -use crate::types::ChannelManager; +use crate::types::{ChainMonitor, ChannelManager}; /// Holds no duplicate epoch or wire state. Connection pairs live in the bounded transport map. pub(crate) struct FforSetupAdapter { manager: Arc, + monitor: Arc, transport: Arc, } // The production builder deliberately does not install this experimental adapter yet. #[allow(dead_code)] impl FforSetupAdapter { - pub(crate) fn new(manager: Arc, transport: Arc) -> Self { - Self { manager, transport } + pub(crate) fn new( + manager: Arc, monitor: Arc, + transport: Arc, + ) -> Self { + Self { manager, monitor, transport } } pub(in crate::message_handler) fn transport(&self) -> &Arc { @@ -70,11 +74,32 @@ impl FforSetupAdapter { &self, peer: PublicKey, id: &FFORReceiverId, ) -> Result { let connection = self.connection(peer)?; - self.manager.advance_ffor_receiver(id, &connection.native, |wire| { + let enqueue = |wire: &[u8]| { // Native -> transport is the only nested lock order. No I/O or manager reentry. - // Refusal leaves the exact Init and one-shot release permission owned natively. + // Refusal leaves exact bytes and any replay permission owned natively. self.transport.enqueue(peer, &connection.transport, wire).map_err(|_| ()) - }) + }; + let progress = self.manager.advance_ffor_receiver(id, &connection.native, enqueue)?; + if progress != FFORReceiverProgress::NeedsMonitorSnapshot { + return Ok(progress); + } + let snapshot = { + let monitor = self + .monitor + .get_monitor(id.channel_id()) + .map_err(|_| FFORCommitmentError::ChannelUnavailable)?; + monitor.ffor_commitment_snapshot()? + }; + // The monitor guard is gone before entering native peer locks. Native checks the same + // paired generation again and validates this snapshot against its current channel state. + self.manager.advance_ffor_receiver_with_monitor(id, &connection.native, snapshot, enqueue) + } + + pub(crate) fn close( + &self, peer: PublicKey, id: &FFORReceiverId, + ) -> Result { + let connection = self.connection(peer)?; + self.manager.request_ffor_receiver_close(id, &connection.native) } pub(crate) fn cancel( diff --git a/src/message_handler/tests/setup.rs b/src/message_handler/tests/setup.rs index 9642df066c..9214550e7e 100644 --- a/src/message_handler/tests/setup.rs +++ b/src/message_handler/tests/setup.rs @@ -5,7 +5,7 @@ use bitcoin::Network; use lightning::ln::ffor::{FFORCommitmentError, FFORReceiverError, FFORReceiverParameters}; use lightning::ln::msgs::BaseMessageHandler; use lightning::ln::types::ChannelId; -use lightning_ffor::wire::{Abort, Payload}; +use lightning_ffor::wire::{Abort, CloseAck, Payload}; use super::*; use crate::builder::NodeBuilder; @@ -39,9 +39,15 @@ fn setup_frame(name: &str) -> FforFrame { } fn signed_abort(seed: u8) -> FforFrame { + signed_message( + seed, + Payload::Abort(Abort { transcript_hash: [0; 32], reason: 1, data: Vec::new() }), + ) +} + +fn signed_message(seed: u8, payload: Payload) -> FforFrame { let mut message = FforMessage::decode(setup_frame("accept").wire()).unwrap(); - message.payload = - Payload::Abort(Abort { transcript_hash: [0; 32], reason: 1, data: Vec::new() }); + message.payload = payload; let digest = bitcoin::secp256k1::Message::from_digest(message.signature_digest().unwrap()); message.signature = Secp256k1::new() .sign_ecdsa_with_noncedata(&digest, &SecretKey::from_slice(&[seed; 32]).unwrap(), &[1; 32]) @@ -52,8 +58,11 @@ fn signed_abort(seed: u8) -> FforFrame { fn adapter(node: &Node) -> (Arc, Arc) { let transport = Arc::new(FforReceiverTransport::default()); - let setup = - Arc::new(FforSetupAdapter::new(Arc::clone(&node.channel_manager), Arc::clone(&transport))); + let setup = Arc::new(FforSetupAdapter::new( + Arc::clone(&node.channel_manager), + Arc::clone(&node.chain_monitor), + Arc::clone(&transport), + )); (setup, transport) } @@ -71,14 +80,28 @@ fn ffor_setup_dispatch_is_synchronous_and_propagates_native_error() { node.channel_manager.peer_connected(peer, &native_init(), false).unwrap(); handler.peer_connected(peer, &native_init(), false).unwrap(); let native = node.channel_manager.ffor_peer_connection(&peer).unwrap(); - for frame in [setup_frame("accept"), signed_abort(72)] { + for frame in [ + setup_frame("accept"), + signed_abort(72), + signed_message(72, Payload::ActivateAck([1; 32])), + signed_message( + 72, + Payload::CloseAck(CloseAck { + activation_hash: [1; 32], + num_slots: 1, + settled: vec![0], + preimages: Vec::new(), + preimages_tlv_present: false, + }), + ), + ] { let expected = node.channel_manager.handle_ffor_receiver_message(&native, frame.wire()).unwrap_err(); assert_eq!(expected, unavailable()); assert_eq!(setup.handle(peer, &frame), Err(expected)); let error = handler.handle_custom_message(NodeCustomMessage::Ffor(frame), peer).unwrap_err(); - assert_eq!(error.err, format!("FFOR receiver setup: {}", expected)); + assert_eq!(error.err, format!("FFOR receiver: {}", expected)); assert!(matches!(error.action, ErrorAction::DisconnectPeer { msg: None })); // A deferred mailbox handler would return Ok and leave the input here. Neither is // allowed before PeerManager considers delivering a subsequent ordinary frame. From e43cf4b65af3ebdcc1dea1cd2b5613e34cd74343 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 21 Sep 2026 02:04:07 -0400 Subject: [PATCH 16/23] feat: orchestrate durable witness provisioning and recovery --- Cargo.lock | 31 +- Cargo.toml | 27 +- FFOR.md | 68 ++- src/ffor/mod.rs | 12 +- src/ffor/witness_owner.rs | 108 +++++ src/ffor/witness_owner/fetch.rs | 332 +++++++++++++ src/ffor/witness_owner/fetch/tests.rs | 355 ++++++++++++++ src/ffor/witness_owner/fixtures/README.md | 20 + .../witness_owner/fixtures/active-manager.bin | Bin 0 -> 6786 bytes .../witness_owner/fixtures/active-monitor.bin | Bin 0 -> 7121 bytes src/ffor/witness_owner/fixtures/fixture.txt | 10 + src/ffor/witness_owner/pending.rs | 244 ++++++++++ src/ffor/witness_owner/pending/tests.rs | 296 ++++++++++++ src/ffor/witness_owner/provisioning.rs | 224 +++++++++ src/ffor/witness_owner/tests.rs | 450 ++++++++++++++++++ src/ffor/witness_store.rs | 42 +- src/ffor/witness_store/receipt.rs | 2 +- src/ffor/witness_store/receipt/tests.rs | 6 +- src/ffor/witness_store/record.rs | 6 +- src/ffor/witness_store/record/key_use.rs | 2 +- src/ffor/witness_store/tests.rs | 56 ++- 21 files changed, 2230 insertions(+), 61 deletions(-) create mode 100644 src/ffor/witness_owner.rs create mode 100644 src/ffor/witness_owner/fetch.rs create mode 100644 src/ffor/witness_owner/fetch/tests.rs create mode 100644 src/ffor/witness_owner/fixtures/README.md create mode 100644 src/ffor/witness_owner/fixtures/active-manager.bin create mode 100644 src/ffor/witness_owner/fixtures/active-monitor.bin create mode 100644 src/ffor/witness_owner/fixtures/fixture.txt create mode 100644 src/ffor/witness_owner/pending.rs create mode 100644 src/ffor/witness_owner/pending/tests.rs create mode 100644 src/ffor/witness_owner/provisioning.rs create mode 100644 src/ffor/witness_owner/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 4d0ba55cca..6e43047760 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1940,6 +1940,7 @@ dependencies = [ "rand 0.9.5", "regex", "reqwest 0.12.28", + "ring", "rusqlite", "rustls 0.23.43", "serde", @@ -1994,7 +1995,7 @@ dependencies = [ [[package]] name = "lightning" version = "0.2.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" +source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" dependencies = [ "bech32", "bitcoin", @@ -2006,7 +2007,7 @@ dependencies = [ "lightning-macros 0.2.1", "lightning-types 0.3.5", "musig2", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17)", "regex", ] @@ -2026,7 +2027,7 @@ dependencies = [ [[package]] name = "lightning-background-processor" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" +source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" dependencies = [ "bitcoin", "bitcoin-io", @@ -2034,7 +2035,7 @@ dependencies = [ "lightning 0.2.5", "lightning-liquidity 0.2.3", "lightning-rapid-gossip-sync 0.2.3", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17)", ] [[package]] @@ -2053,7 +2054,7 @@ dependencies = [ [[package]] name = "lightning-block-sync" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" +source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" dependencies = [ "bitcoin", "chunked_transfer", @@ -2065,7 +2066,7 @@ dependencies = [ [[package]] name = "lightning-ffor" version = "0.1.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" +source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" dependencies = [ "bitcoin", ] @@ -2085,7 +2086,7 @@ dependencies = [ [[package]] name = "lightning-invoice" version = "0.34.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" +source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" dependencies = [ "bech32", "bitcoin", @@ -2111,7 +2112,7 @@ dependencies = [ [[package]] name = "lightning-liquidity" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" +source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" dependencies = [ "bitcoin", "chrono", @@ -2137,7 +2138,7 @@ dependencies = [ [[package]] name = "lightning-macros" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" +source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" dependencies = [ "proc-macro2", "quote", @@ -2158,7 +2159,7 @@ dependencies = [ [[package]] name = "lightning-net-tokio" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" +source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2179,7 +2180,7 @@ dependencies = [ [[package]] name = "lightning-persister" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" +source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2202,7 +2203,7 @@ dependencies = [ [[package]] name = "lightning-rapid-gossip-sync" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" +source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" dependencies = [ "bitcoin", "bitcoin-io", @@ -2227,7 +2228,7 @@ dependencies = [ [[package]] name = "lightning-transaction-sync" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" +source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" dependencies = [ "bitcoin", "electrum-client 0.24.1", @@ -2249,7 +2250,7 @@ dependencies = [ [[package]] name = "lightning-types" version = "0.3.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" +source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" dependencies = [ "bitcoin", ] @@ -2676,7 +2677,7 @@ dependencies = [ [[package]] name = "possiblyrandom" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a20b34f9822e7131d229a526249c917971c25d14#a20b34f9822e7131d229a526249c917971c25d14" +source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" dependencies = [ "getrandom 0.2.17", ] diff --git a/Cargo.toml b/Cargo.toml index cb18f72d2e..088e1b79f4 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,18 +34,18 @@ default = [] [dependencies] # Pin runtime dependencies directly so downstream consumers use the same native engine. -lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14" } -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14", features = ["std"] } -lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14" } -lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14" } -lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14" } -lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14" } -lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14", features = ["std"] } -lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14" } +lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17" } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17", features = ["std"] } +lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17" } +lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17" } +lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17" } +lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17" } +lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17", features = ["std"] } +lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17" } bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} @@ -81,8 +81,9 @@ prost = { version = "0.11.6", default-features = false} winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "a20b34f9822e7131d229a526249c917971c25d14", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17", features = ["std", "_test_utils"] } proptest = "1.0.0" +ring = "0.17.14" regex = "1.5.6" criterion = { version = "0.7.0", features = ["async_tokio"] } diff --git a/FFOR.md b/FFOR.md index 4f0fe7f00f..75b8c58982 100644 --- a/FFOR.md +++ b/FFOR.md @@ -45,7 +45,7 @@ that monitor guard before passing the opaque snapshot and original paired genera back to the manager. Stock peer events, STFU, commitment rounds and persistence must continue between advances. The adapter retains no independent lifecycle state and does not treat Active progress as invoice readiness. No builder constructs it yet; -runtime scheduling, witness orchestration and payment recovery remain necessary before +runtime scheduling and payment recovery remain necessary before offering offline invoices. The transport accepts peer identity only from PeerManager's authenticated callback. @@ -99,7 +99,8 @@ property checks at the length-limited reader boundary. The private `ffor::witness_store` module retains an immutable encryption key per epoch, plus a separate fetch key, mailbox and exact signed manifest per witness. -It has no builder, transport or payment-provider caller. Its non-test binding +A private witness owner composes its storage and transport operations; no production +builder or payment provider constructs either owner. Its non-test binding constructor accepts only an opaque native recovery context with a retained activation acknowledgement. It binds the native context digest, original funding output, identities and exact signed setup and activation. Historical evidence and successful @@ -166,8 +167,9 @@ entropy, interrupted writes and concurrent callers within one owner. Store succe is the backend's durability contract. Authenticated encryption does not detect a rollback to an older valid backup or protect against wallet-seed compromise. KVStore has no cross-process compare-and-swap, so concurrent independent owners are not -supported. A future native registration must distinguish a new epoch from an -existing epoch whose entire sidecar is missing; `load` never regenerates secrets. +supported. Native immutable registration distinguishes a new epoch from an +existing epoch whose sidecar is missing; the witness owner uses load-only recovery +for registered epochs, and `load` never regenerates secrets. Protected key operations sign fetch requests and decrypt witness records without exporting either private key. Each fetch call draws a fresh operating-system request identity, 256-bit nonce and signature entropy, including retries after a lost response @@ -175,7 +177,8 @@ or restart. The witness still enforces replay refusal. Decryption authenticates retained witness and manifest before native AEAD and body checks. These helpers do not correlate a response connection, change a channel or credit a payment. Receipt retention preserves authenticated evidence for later native reconciliation. Witness -transport orchestration and native monitor reconciliation remain separate work. +transport orchestration is described below; native monitor reconciliation remains +separate work. Run `cargo test --lib ffor_witness` for exact retries and restart, failed writes with readable bytes, corruption, wrong seeds and bindings, key separation, capacity, @@ -189,6 +192,61 @@ acknowledgement and receipt retention. Fixture provenance is recorded beside the receipt tests. These checks do not establish complete branch coverage or process-crash interoperability. +## Private witness owner + +The concrete `ffor::witness_owner` composes the actual ChannelManager, protected +store and authenticated transport. Exclusive access serializes bounded transient +work, with a combined 64-request and 8 MiB encoded-payload reservation across +provisioning and fetches. Each fetch reserves its complete response, request and +bounded pagination history before release. Fixed metadata has a separate count +bound. Capacity refusal preserves admitted work. + +New registration first completes all protected sidecar writes, then registers the +exact immutable selection in native state. Existing native registration requires +load-only recovery with identical manifests, selected identities and key metadata. +Provisioning waits for the native registration barrier and fresh Active authority. +Native release rechecks phase, deadline, original commitment evidence and current +persistence under its transition locks; its callback only checks the witness's +original transport token and enqueues. No storage or network operation holds those +native locks. The settlement peer need not be connected for witness provisioning. + +The owner stages exact response correlation before queue acceptance. Backpressure +keeps the same unsent request. Accepted requests are not resent after transport +drain; explicit timeout retry generates a fresh request ID with the same manifest. +Acknowledgements must match the actual witness, original connection and exact +pending request. The owner rejoins retained native identity and sidecar manifests +before storing a historical promise, then removes correlation only after a +successful write. Such a promise is not invoice authority. + +Historical fetches remain available after channel removal and admission expiry. +Each traversal and retry uses fresh signed request identity and nonce. Every page +must match the actual witness and connection, retained manifest, request, signed +records and strictly increasing cursor. Each encrypted record becomes durable +before the cursor advances. Checked pages survive disconnect and uncertain writes; +recovery must complete the exact write before they can be replaced. Once retained, +evidence remains even if a later page is invalid or the witness disappears. + +Candidate-only authentication failures and valid conflicting cores are distinct +from local storage corruption. The owner rejects those candidates while retaining +later valid evidence from the same page. Once all such evidence is durable, it +reports a rejected page and permits a fresh traversal from slot zero. Storage +uncertainty or local corruption keeps the page and its cursor intact. A completed +or empty traversal does not establish that any slot is unpaid, and this owner +emits no payment credit or native claim. Receipt retention currently rewrites the +fixed receipt book for each newly retained core; large-book recovery performance +still needs measurement before production scheduling is enabled. + +Tests restore a genuinely funded and signed native Active manager and stock monitor +through NodeBuilder with a public test seed. They cover registration persistence, +all three sidecar write boundaries, failed and stale manager persistence tokens, +exact queued retries, fresh timeout identities, current witness correlation, +missing-sidecar refusal after reload, historical acknowledgements after channel +removal, shared quotas, fetch backpressure, pagination, disconnect and storage +failures, rejected candidates followed by valid evidence and zero payment credit. +Fixture provenance is in `src/ffor/witness_owner/fixtures/README.md`; test-only +witness encryption uses ring against the retained public epoch key and never +exports a protected private key. No builder or scheduler enables this owner yet. + ## Required runtime integration - Bind signed setup to the actual local identity, peer, chain and channel limits. diff --git a/src/ffor/mod.rs b/src/ffor/mod.rs index 5e4239b65f..9ec4e63ccc 100644 --- a/src/ffor/mod.rs +++ b/src/ffor/mod.rs @@ -1,9 +1,11 @@ -//! Private witness recovery storage, not a native activation or invoice authority. +//! Private witness recovery storage and bounded orchestration, not invoice authority. //! -//! No builder, transport, or payment provider constructs this module yet. Bindings come from opaque -//! native historical contexts; future registration and external work still require current native -//! authority checked under the manager's transition locks. +//! No builder or payment provider constructs the witness owner. Bindings come from opaque native +//! historical contexts; provisioning requires current native authority checked under the manager's +//! transition locks. Retained acknowledgements and encrypted evidence confer no payment credit. -// This bounded storage boundary remains disconnected until native lifecycle ownership exists. #[allow(dead_code)] mod witness_store; + +#[allow(dead_code)] +mod witness_owner; diff --git a/src/ffor/witness_owner.rs b/src/ffor/witness_owner.rs new file mode 100644 index 0000000000..e4a46c7496 --- /dev/null +++ b/src/ffor/witness_owner.rs @@ -0,0 +1,108 @@ +//! Private orchestration of native-authorized witness provisioning and durable acknowledgement. +//! +//! No builder constructs this owner. Methods require exclusive access, while the protected store +//! remains the single persistence owner. Native registration and release own phase authority; +//! this module retains only bounded transient response correlation. Historical acknowledgements +//! authorize neither an invoice nor a native transition. Transport callbacks never call the store. + +use std::sync::Arc; + +use lightning::ln::ffor::FFORReceiverError; +use lightning_ffor::witness::{Acknowledgement, WitnessError}; + +use super::witness_store::{ + WitnessKeyUseError, WitnessSecretStore, WitnessStorageBinding, WitnessStoreError, +}; +use crate::message_handler::ffor::{FforReceiverTransport, ReceivedFforMessage}; +use crate::types::ChannelManager; +use fetch::FetchRequests; +use pending::PendingProvisions; + +mod fetch; +mod pending; +mod provisioning; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum WitnessOwnerError { + Native(FFORReceiverError), + Storage(WitnessStoreError), + Protocol(WitnessError), + KeyUse(WitnessKeyUseError), + Capacity, + Conflict, + Entropy, + StaleConnection, + UnknownRequest, + UnknownWitness, + Unregistered, +} + +/// Storage completion only. This is deliberately not a provisioning or invoice readiness value. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AcknowledgementProgress { + Retained, +} + +pub(crate) struct WitnessOwner { + manager: Arc, + store: Arc, + transport: Arc, + pending: PendingProvisions, + fetches: FetchRequests, +} + +impl WitnessOwner { + pub(crate) fn new( + manager: Arc, store: Arc, + transport: Arc, + ) -> Self { + Self { + manager, + store, + transport, + pending: PendingProvisions::default(), + fetches: FetchRequests::default(), + } + } + + /// Only the actual transport message supplies W and connection identity. An invalid or failed + /// write leaves pending correlation intact; persistence recovery must finish before retry. + pub(crate) fn acknowledge( + &mut self, message: &ReceivedFforMessage, + ) -> Result { + self.synchronize_connections(); + if !self.transport.is_current(message) { + return Err(WitnessOwnerError::StaleConnection); + } + let ack = + Acknowledgement::decode(message.frame().wire()).map_err(WitnessOwnerError::Protocol)?; + let (epoch, checked) = self.pending.check(message, &ack)?; + let context = self + .manager + .ffor_receiver_recovery_context(&epoch.channel, epoch.epoch) + .map_err(WitnessOwnerError::Native)?; + if context.context_digest() != epoch.context_digest { + return Err(WitnessOwnerError::Conflict); + } + self.retained_manifests(&context)?; + let binding = WitnessStorageBinding::from_native_context(&context) + .map_err(WitnessOwnerError::Storage)?; + self.store.acknowledge(&binding, &checked).map_err(WitnessOwnerError::Storage)?; + self.pending.complete(ack.request_id()); + Ok(AcknowledgementProgress::Retained) + } + + /// Retry only the protected store's exact uncertain write; this grants no release authority. + pub(crate) fn recover_storage(&mut self) -> Result<(), WitnessOwnerError> { + self.store.recover_write().map_err(WitnessOwnerError::Storage) + } + + /// Drop only transient correlation. Exact manifests, keys and historical promises remain stored. + pub(crate) fn synchronize_connections(&mut self) { + self.pending.discard_disconnected(&self.transport); + self.fetches.discard_disconnected(&self.transport); + } +} + +#[cfg(test)] +mod tests; diff --git a/src/ffor/witness_owner/fetch.rs b/src/ffor/witness_owner/fetch.rs new file mode 100644 index 0000000000..40a7a6ba50 --- /dev/null +++ b/src/ffor/witness_owner/fetch.rs @@ -0,0 +1,332 @@ +//! Historical witness retrieval. A completed traversal is not evidence that missing slots are unpaid. +//! Checked pages remain bounded and survive storage backpressure until their evidence is retained. + +use bitcoin::secp256k1::PublicKey; +use lightning::ln::ffor::FFORReceiverRecoveryContext; +use lightning_ffor::witness::{ + CheckedFetchPage, FetchResponse, PendingFetch, SignedFetch, WitnessConnection, +}; + +use super::pending::Epoch; +use super::{WitnessOwner, WitnessOwnerError}; +use crate::ffor::witness_store::{WitnessStorageBinding, WitnessStoreError}; +use crate::message_handler::ffor::{ + ConnectionToken, FforReceiverTransport, OutboundError, ReceivedFforMessage, +}; + +const MAX_WORK_COUNT: usize = 64; +const MAX_WORK_BYTES: usize = 8 * 1024 * 1024; + +/// Retrieval progress only. No variant establishes settlement, payment credit or invoice authority. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FetchProgress { + Queued, + AwaitingResponse, + Backpressured, + Complete, + RestartRequired, + /// Invalid or conflicting candidates were rejected, after all other page evidence was retained. + RejectedPage, +} + +enum FetchState { + Request { pending: PendingFetch, queued: bool }, + Page { checked: CheckedFetchPage, next_record: usize, rejected: bool }, +} + +struct FetchEntry { + epoch: Epoch, + source: WitnessConnection, + request_id: [u8; 16], + nonce: [u8; 32], + reserved_bytes: usize, + state: FetchState, +} + +#[derive(Default)] +pub(super) struct FetchRequests { + entries: Vec, +} + +impl FetchRequests { + pub(super) fn usage(&self) -> (usize, usize) { + (self.entries.len(), self.entries.iter().map(|entry| entry.reserved_bytes).sum()) + } + + pub(super) fn contains_request_id(&self, id: [u8; 16]) -> bool { + self.entries.iter().any(|entry| entry.request_id == id) + } + + pub(super) fn discard_disconnected(&mut self, transport: &FforReceiverTransport) { + self.entries.retain(|entry| { + // An authenticated page may contain valuable evidence even when its connection is gone. + matches!(entry.state, FetchState::Page { .. }) + || transport.connection(entry.source.node_id).as_ref() + == Some(&entry.source.identity) + }); + } + + fn find(&self, epoch: Epoch, witness: PublicKey) -> Result, WitnessOwnerError> { + let index = self.entries.iter().position(|entry| { + entry.epoch.channel == epoch.channel + && entry.epoch.epoch == epoch.epoch + && entry.source.node_id == witness + }); + if index.is_some_and(|index| self.entries[index].epoch != epoch) { + return Err(WitnessOwnerError::Conflict); + } + Ok(index) + } + + fn install( + &mut self, epoch: Epoch, pending: PendingFetch, + other_usage: (usize, usize), + ) -> Result { + let source = pending.connection().clone(); + let previous = self.find(epoch, source.node_id)?; + if previous + .is_some_and(|index| matches!(self.entries[index].state, FetchState::Page { .. })) + { + return Err(WitnessOwnerError::Conflict); + } + let parameters = pending.request().unsigned().parameters(); + if self.contains_request_id(parameters.request_id) + || self.entries.iter().any(|entry| { + entry.source.node_id == source.node_id && entry.nonce == parameters.nonce + }) { + return Err(WitnessOwnerError::Entropy); + } + let manifest = pending.manifest(); + // Reserve a complete response and all traversal history before the first request leaves. + // A second full frame covers a request and any future signed paging extensions. + // Encoded payload accounting excludes fixed metadata, whose count is bounded separately. + let reserved_bytes = manifest.encode().len() + + 2 * lightning_ffor::wire::MAX_MESSAGE_LEN + + lightning_ffor::book::MAX_VOUCHERS * 48; + let (count, bytes) = self.usage(); + let old_bytes = previous.map_or(0, |index| self.entries[index].reserved_bytes); + let new_count = count + usize::from(previous.is_none()); + if new_count + other_usage.0 > MAX_WORK_COUNT + || (bytes - old_bytes) + .checked_add(reserved_bytes) + .and_then(|bytes| bytes.checked_add(other_usage.1)) + .filter(|bytes| *bytes <= MAX_WORK_BYTES) + .is_none() + { + return Err(WitnessOwnerError::Capacity); + } + let entry = FetchEntry { + epoch, + source, + request_id: parameters.request_id, + nonce: parameters.nonce, + reserved_bytes, + state: FetchState::Request { pending, queued: false }, + }; + Ok(if let Some(index) = previous { + self.entries[index] = entry; + index + } else { + self.entries.push(entry); + self.entries.len() - 1 + }) + } + + fn accept(&mut self, message: &ReceivedFforMessage) -> Result { + let response = + FetchResponse::decode(message.frame().wire()).map_err(WitnessOwnerError::Protocol)?; + let index = self + .entries + .iter() + .position(|entry| entry.request_id == response.request_id()) + .ok_or(WitnessOwnerError::UnknownRequest)?; + let entry = &mut self.entries[index]; + let pending = match &entry.state { + FetchState::Request { pending, queued: true } => pending, + _ => return Err(WitnessOwnerError::UnknownRequest), + }; + let source = + WitnessConnection { node_id: message.peer(), identity: message.connection().clone() }; + // Validate the entire response's identity, manifest, signatures and pagination first. + let checked = + pending.check_response(&response, &source).map_err(WitnessOwnerError::Protocol)?; + entry.state = FetchState::Page { checked, next_record: 0, rejected: false }; + Ok(index) + } +} + +impl WitnessOwner { + /// Begin or advance historical recovery. Current Active state and S connectivity are unnecessary. + /// A queued request is never resent. Continue a failed storage write before invoking this again. + pub(crate) fn fetch( + &mut self, context: &FFORReceiverRecoveryContext, witness: PublicKey, + ) -> Result { + self.fetch_inner(context, witness, false) + } + + /// Restart an ambiguous traversal from slot zero with fresh signed request identity and nonce. + /// Already retained evidence remains. A received page must finish persistence before replacement. + pub(crate) fn retry_fetch( + &mut self, context: &FFORReceiverRecoveryContext, witness: PublicKey, + ) -> Result { + self.fetch_inner(context, witness, true) + } + + fn fetch_inner( + &mut self, context: &FFORReceiverRecoveryContext, witness: PublicKey, restart: bool, + ) -> Result { + self.pending.discard_disconnected(&self.transport); + self.fetches.discard_disconnected(&self.transport); + let manifest = self + .retained_manifests(context)? + .into_iter() + .find_map(|(selected, manifest)| (selected == witness).then_some(manifest)) + .ok_or(WitnessOwnerError::UnknownWitness)?; + let binding = WitnessStorageBinding::from_native_context(context) + .map_err(WitnessOwnerError::Storage)?; + let epoch = Epoch { + channel: context.channel_id(), + epoch: context.epoch_id(), + context_digest: context.context_digest(), + }; + let previous = self.fetches.find(epoch, witness)?; + if let Some(index) = previous { + if matches!(self.fetches.entries[index].state, FetchState::Page { .. }) { + // Protect the received evidence even if restart was requested during storage failure. + return self.advance_fetch(index, &binding); + } + if !restart { + return self.advance_fetch(index, &binding); + } + } + let connection = + self.transport.connection(witness).ok_or(WitnessOwnerError::StaleConnection)?; + let request = self.fresh_fetch(&binding, witness, None)?; + let pending = PendingFetch::first( + request, + manifest, + WitnessConnection { node_id: witness, identity: connection }, + ) + .map_err(WitnessOwnerError::Protocol)?; + let index = self.fetches.install(epoch, pending, self.pending.usage())?; + self.advance_fetch(index, &binding) + } + + /// Retain an exactly correlated page. Encrypted evidence is persisted before any cursor advances. + /// Malformed input or a later failure cannot erase records retained from an earlier response. + pub(crate) fn accept_fetch_page( + &mut self, message: &ReceivedFforMessage, + ) -> Result { + if !self.transport.is_current(message) { + return Err(WitnessOwnerError::StaleConnection); + } + let index = self.fetches.accept(message)?; + let epoch = self.fetches.entries[index].epoch; + let context = self + .manager + .ffor_receiver_recovery_context(&epoch.channel, epoch.epoch) + .map_err(WitnessOwnerError::Native)?; + if context.context_digest() != epoch.context_digest { + return Err(WitnessOwnerError::Conflict); + } + self.retained_manifests(&context)?; + let binding = WitnessStorageBinding::from_native_context(&context) + .map_err(WitnessOwnerError::Storage)?; + self.advance_fetch(index, &binding) + } + + fn fresh_fetch( + &self, binding: &WitnessStorageBinding, witness: PublicKey, after_slot: Option, + ) -> Result { + let secrets = self.store.load(binding).map_err(WitnessOwnerError::Storage)?; + for _ in 0..16 { + let request = + secrets.prepare_fetch(witness, after_slot).map_err(WitnessOwnerError::KeyUse)?; + let parameters = request.unsigned().parameters(); + if !self.pending.contains_request_id(parameters.request_id) + && !self.fetches.contains_request_id(parameters.request_id) + && self + .fetches + .entries + .iter() + .all(|entry| entry.source.node_id != witness || entry.nonce != parameters.nonce) + { + return Ok(request); + } + } + Err(WitnessOwnerError::Entropy) + } + + fn advance_fetch( + &mut self, index: usize, binding: &WitnessStorageBinding, + ) -> Result { + let entry = &mut self.fetches.entries[index]; + if let FetchState::Page { checked, next_record, rejected } = &mut entry.state { + while *next_record < checked.records().len() { + // Store authenticates native AEAD/body terms before accepting each encrypted core. + match self.store.retain_receipt( + binding, + entry.source.node_id, + checked.records()[*next_record].record(), + ) { + Ok(_) => {}, + Err(WitnessStoreError::InvalidReceipt | WitnessStoreError::ReceiptConflict) => { + // Only candidate rejection is skippable. Local corruption or uncertainty keeps + // this page intact, including later records which may prove valid preimages. + *rejected = true; + }, + Err(error) => return Err(WitnessOwnerError::Storage(error)), + } + *next_record += 1; + } + if *rejected { + self.fetches.entries.remove(index); + return Ok(FetchProgress::RejectedPage); + } + if checked.next_after_slot().is_none() { + self.fetches.entries.remove(index); + return Ok(FetchProgress::Complete); + } + if self.transport.connection(entry.source.node_id).as_ref() + != Some(&entry.source.identity) + { + self.fetches.entries.remove(index); + return Ok(FetchProgress::RestartRequired); + } + let witness = entry.source.node_id; + let after = checked.next_after_slot(); + let request = self.fresh_fetch(binding, witness, after)?; + let entry = &mut self.fetches.entries[index]; + let FetchState::Page { checked, .. } = &entry.state else { unreachable!() }; + let pending = checked.next(request).map_err(WitnessOwnerError::Protocol)?; + let parameters = pending.request().unsigned().parameters(); + entry.request_id = parameters.request_id; + entry.nonce = parameters.nonce; + entry.state = FetchState::Request { pending, queued: false }; + } + let entry = &mut self.fetches.entries[index]; + let FetchState::Request { pending, queued } = &mut entry.state else { unreachable!() }; + if *queued { + return Ok(FetchProgress::AwaitingResponse); + } + match self.transport.enqueue_fetch( + entry.source.node_id, + &entry.source.identity, + pending.request(), + ) { + Ok(()) => { + *queued = true; + Ok(FetchProgress::Queued) + }, + Err(OutboundError::Capacity) => Ok(FetchProgress::Backpressured), + Err(OutboundError::StaleConnection) => { + self.fetches.entries.remove(index); + Err(WitnessOwnerError::StaleConnection) + }, + Err(OutboundError::InvalidMessage) => Err(WitnessOwnerError::Conflict), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/ffor/witness_owner/fetch/tests.rs b/src/ffor/witness_owner/fetch/tests.rs new file mode 100644 index 0000000000..8778bb62b6 --- /dev/null +++ b/src/ffor/witness_owner/fetch/tests.rs @@ -0,0 +1,355 @@ +use std::sync::atomic::Ordering; + +use bitcoin::hashes::{sha256, Hash, HashEngine, Hmac, HmacEngine}; +use bitcoin::secp256k1::{ecdh::SharedSecret, Message, Secp256k1, SecretKey}; +use lightning::ln::peer_handler::CustomMessageHandler; +use lightning::ln::wire::CustomMessageReader; +use lightning_ffor::witness::{EncryptedRecord, FetchResult, RecordHeader, SignedManifest}; +use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, CHACHA20_POLY1305}; + +use super::*; +use crate::ffor::witness_owner::tests::Harness; +use crate::message_handler::NodeCustomMessage; + +fn secret(byte: u8) -> SecretKey { + SecretKey::from_slice(&[byte; 32]).unwrap() +} + +fn public(byte: u8) -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &secret(byte)) +} + +fn start(h: &mut Harness) -> SignedFetch { + h.register_and_persist(); + h.connect(h.policies[0].witness); + assert_eq!(h.owner.fetch(&h.context, h.policies[0].witness), Ok(FetchProgress::Queued)); + outgoing(h).remove(0) +} + +fn outgoing(h: &Harness) -> Vec { + let manifests = h.owner.retained_manifests(&h.context).unwrap(); + h.handler + .get_and_clear_pending_msg() + .into_iter() + .map(|(peer, message)| { + let manifest = &manifests.iter().find(|(w, _)| *w == peer).unwrap().1; + match message { + NodeCustomMessage::Ffor(frame) => SignedFetch::decode( + frame.wire(), + manifest.unsigned().parameters().fetch_public_key, + ) + .unwrap(), + _ => panic!("unexpected LSPS message"), + } + }) + .collect() +} + +fn receive(h: &Harness, peer: PublicKey, response: &FetchResponse) -> ReceivedFforMessage { + let wire = response.encode(); + let message = + h.handler.read(u16::from_be_bytes([wire[0], wire[1]]), &mut &wire[2..]).unwrap().unwrap(); + h.handler.handle_custom_message(message, peer).unwrap(); + h.owner.transport.pop().unwrap() +} + +fn response( + request: &SignedFetch, records: Vec, after: Option, +) -> FetchResponse { + FetchResponse::new( + request.unsigned().parameters().request_id, + FetchResult::Page { records, next_after_slot: after, extensions: Vec::new() }, + ) + .unwrap() +} + +fn record(h: &Harness, slot: u16, invalid: bool, identity: u8) -> EncryptedRecord { + let manifest: SignedManifest = h.owner.retained_manifests(&h.context).unwrap().remove(0).1; + let params = manifest.unsigned().parameters(); + let voucher = &h.context.setup().vouchers()[usize::from(slot) - 1]; + // The native fixture is public test data. Its two generated preimages repeat a counter byte. + let preimage = (0..=255) + .map(|byte| [byte; 32]) + .find(|value| sha256::Hash::hash(value).to_byte_array() == voucher.payment_hash) + .unwrap(); + let mut body = Vec::new(); + body.extend_from_slice(&h.context.epoch_id()); + body.extend_from_slice(&slot.to_be_bytes()); + body.extend_from_slice(&preimage); + body.extend_from_slice(&voucher.payment_hash); + body.extend_from_slice(&voucher.amount_msat.to_be_bytes()); + body.extend_from_slice(&voucher.expiry.to_be_bytes()); + body.extend_from_slice(&voucher.deadline.to_be_bytes()); + body.extend_from_slice(&[0; 28]); + assert_eq!(body.len(), lightning_ffor::witness::RECORD_BODY_LEN); + if invalid { + body[34] ^= 1; + } + let book = manifest.unsigned().canonical_book(); + let offset = 36 + 58 * (usize::from(slot) - 1); + let mut terms = b"ffor/terms".to_vec(); + terms.extend_from_slice(&book[offset..offset + 58]); + let mut header = RecordHeader { + mailbox_id: params.mailbox_id, + record_id: [identity; 32], + slot, + activation_hash: manifest.unsigned().activation_hash(), + terms_hash: sha256::Hash::hash(&terms).to_byte_array(), + witness: h.policies[0].witness, + encryption_public_key: params.encryption_public_key, + recorded_height: 0, + unbarriered: false, + ciphertext_hash: [0; 32], + }; + let shared = SharedSecret::new(¶ms.encryption_public_key, &secret(47)); + let mut extract = HmacEngine::::new(&[]); + extract.input(&shared.secret_bytes()); + let mut expand = HmacEngine::::new(&Hmac::from_engine(extract).to_byte_array()); + expand.input(b"ffor/witness/body"); + expand.input(&[1]); + let key = Hmac::from_engine(expand).to_byte_array(); + let cipher = LessSafeKey::new(UnboundKey::new(&CHACHA20_POLY1305, &key).unwrap()); + cipher + .seal_in_place_append_tag( + Nonce::assume_unique_for_key([0; 12]), + Aad::from(header.associated_data()), + &mut body, + ) + .unwrap(); + let mut encrypted = public(47).serialize().to_vec(); + encrypted.extend_from_slice(&body); + header.ciphertext_hash = sha256::Hash::hash(&encrypted).to_byte_array(); + let signature = + Secp256k1::new().sign_ecdsa(&Message::from_digest(header.signing_digest()), &secret(80)); + let mut wire = header.encode(); + wire.extend_from_slice(&signature.serialize_compact()); + wire.extend_from_slice(&(encrypted.len() as u16).to_be_bytes()); + wire.extend_from_slice(&encrypted); + wire.push(0); + EncryptedRecord::decode(&wire).unwrap() +} + +fn retained(h: &Harness, slot: u16) -> Option { + let binding = WitnessStorageBinding::from_native_context(&h.context).unwrap(); + h.owner.store.load_receipt(&binding, h.policies[0].witness, slot).unwrap() +} + +#[test] +fn ffor_witness_fetch_real_retry_correlates_identity_and_empty_response_grants_no_credit() { + let mut h = Harness::new(); + let first = start(&mut h); + let witness = h.policies[0].witness; + assert_eq!(h.owner.fetch(&h.context, witness), Ok(FetchProgress::AwaitingResponse)); + assert!(outgoing(&h).is_empty()); + assert_eq!(h.owner.retry_fetch(&h.context, witness), Ok(FetchProgress::Queued)); + let second = outgoing(&h).remove(0); + assert_ne!(first.unsigned().parameters().request_id, second.unsigned().parameters().request_id); + assert_ne!(first.unsigned().parameters().nonce, second.unsigned().parameters().nonce); + let old = receive(&h, witness, &response(&first, vec![], None)); + assert_eq!(h.owner.accept_fetch_page(&old), Err(WitnessOwnerError::UnknownRequest)); + h.connect(public(81)); + let wrong = receive(&h, public(81), &response(&second, vec![], None)); + assert!(matches!(h.owner.accept_fetch_page(&wrong), Err(WitnessOwnerError::Protocol(_)))); + let stale = receive(&h, witness, &response(&second, vec![], None)); + h.handler.peer_disconnected(witness); + h.connect(witness); + assert_eq!(h.owner.accept_fetch_page(&stale), Err(WitnessOwnerError::StaleConnection)); + assert_eq!(h.owner.fetch(&h.context, witness), Ok(FetchProgress::Queued)); + let third = outgoing(&h).remove(0); + let empty = receive(&h, witness, &response(&third, vec![], None)); + assert_eq!(h.owner.accept_fetch_page(&empty), Ok(FetchProgress::Complete)); + assert_eq!(h.owner.fetches.usage(), (0, 0)); + assert!(retained(&h, 1).is_none()); + assert!(h.node.list_payments().is_empty()); +} + +#[test] +fn ffor_witness_fetch_real_paging_preserves_receipts_after_later_rejection_and_channel_removal() { + let mut h = Harness::new(); + let first = start(&mut h); + let witness = h.policies[0].witness; + let receipt = record(&h, 1, false, 1); + let page = receive(&h, witness, &response(&first, vec![receipt.clone()], Some(1))); + assert_eq!(h.owner.accept_fetch_page(&page), Ok(FetchProgress::Queued)); + assert_eq!(retained(&h, 1).unwrap().header(), receipt.header()); + let second = outgoing(&h).remove(0); + assert_eq!(second.unsigned().parameters().after_slot, Some(1)); + assert_ne!(first.unsigned().parameters().nonce, second.unsigned().parameters().nonce); + let repeated = receive(&h, witness, &response(&second, vec![receipt], None)); + assert!(matches!(h.owner.accept_fetch_page(&repeated), Err(WitnessOwnerError::Protocol(_)))); + assert!(retained(&h, 1).is_some()); + h.node + .channel_manager + .force_close_broadcasting_latest_txn( + &h.context.channel_id(), + &h.context.settlement_node_id(), + "receipt fixture close".to_owned(), + ) + .unwrap(); + let final_page = receive(&h, witness, &response(&second, vec![record(&h, 2, false, 2)], None)); + assert_eq!(h.owner.accept_fetch_page(&final_page), Ok(FetchProgress::Complete)); + assert!(retained(&h, 1).is_some() && retained(&h, 2).is_some()); + assert!(h.node.list_payments().is_empty()); +} + +#[test] +fn ffor_witness_fetch_real_storage_failure_keeps_page_through_disconnect_and_retry() { + for failure in [1, 2] { + let mut h = Harness::new(); + let first = start(&mut h); + let witness = h.policies[0].witness; + let page = receive(&h, witness, &response(&first, vec![record(&h, 1, false, 1)], Some(1))); + h.storage.write_failure.store(failure, Ordering::SeqCst); + assert_eq!( + h.owner.accept_fetch_page(&page), + Err(WitnessOwnerError::Storage(WitnessStoreError::Storage)) + ); + h.handler.peer_disconnected(witness); + h.owner.synchronize_connections(); + assert_eq!(h.owner.fetches.usage().0, 1); + assert_eq!( + h.owner.retry_fetch(&h.context, witness), + Err(WitnessOwnerError::Storage(WitnessStoreError::Uncertain)) + ); + h.owner.recover_storage().unwrap(); + assert_eq!(h.owner.retry_fetch(&h.context, witness), Ok(FetchProgress::RestartRequired)); + assert!(retained(&h, 1).is_some()); + h.connect(witness); + assert_eq!(h.owner.fetch(&h.context, witness), Ok(FetchProgress::Queued)); + let retry = outgoing(&h).remove(0); + assert_eq!(retry.unsigned().parameters().after_slot, None); + assert_ne!(first.unsigned().parameters().nonce, retry.unsigned().parameters().nonce); + } +} + +#[test] +fn ffor_witness_fetch_real_rejected_record_does_not_discard_later_valid_evidence() { + for conflict in [false, true] { + let mut h = Harness::new(); + let first = start(&mut h); + let witness = h.policies[0].witness; + if conflict { + let binding = WitnessStorageBinding::from_native_context(&h.context).unwrap(); + h.owner.store.retain_receipt(&binding, witness, &record(&h, 1, false, 9)).unwrap(); + } + let page = receive( + &h, + witness, + &response(&first, vec![record(&h, 1, !conflict, 1), record(&h, 2, false, 2)], None), + ); + // The first candidate is rejected without a write. The later valid record still needs one. + h.storage.write_failure.store(2, Ordering::SeqCst); + assert_eq!( + h.owner.accept_fetch_page(&page), + Err(WitnessOwnerError::Storage(WitnessStoreError::Storage)) + ); + h.owner.recover_storage().unwrap(); + assert_eq!(h.owner.retry_fetch(&h.context, witness), Ok(FetchProgress::RejectedPage)); + assert_eq!(h.owner.fetches.usage(), (0, 0)); + assert!(retained(&h, 2).is_some()); + assert_eq!(retained(&h, 1).is_some(), conflict); + if conflict { + assert_eq!(retained(&h, 1).unwrap().header().record_id, [9; 32]); + } + assert_eq!(h.owner.retry_fetch(&h.context, witness), Ok(FetchProgress::Queued)); + let retry = outgoing(&h).remove(0); + assert_ne!(first.unsigned().parameters().nonce, retry.unsigned().parameters().nonce); + let corrected = receive( + &h, + witness, + &response( + &retry, + vec![record(&h, 1, false, if conflict { 9 } else { 1 }), record(&h, 2, false, 2)], + None, + ), + ); + assert_eq!(h.owner.accept_fetch_page(&corrected), Ok(FetchProgress::Complete)); + assert!(retained(&h, 1).is_some() && retained(&h, 2).is_some()); + } +} + +#[test] +fn ffor_witness_fetch_and_provision_share_capacity_without_evicting_work() { + let mut h = Harness::new(); + let first = start(&mut h); + let witness = h.policies[0].witness; + let manifest = h.owner.retained_manifests(&h.context).unwrap().remove(0).1; + let source = WitnessConnection { + node_id: witness, + identity: h.owner.transport.connection(witness).unwrap(), + }; + let usage = h.owner.fetches.usage(); + h.owner.pending.set_other_usage(usage.0, usage.1).unwrap(); + // These are transient correlation records only. No native registration or release is fabricated. + for byte in 1..64 { + let epoch = Epoch { + channel: h.context.channel_id(), + epoch: [byte; 32], + context_digest: h.context.context_digest(), + }; + h.owner.pending.stage(epoch, source.clone(), manifest.clone()).unwrap(); + } + assert_eq!(h.owner.pending.usage().0 + h.owner.fetches.usage().0, MAX_WORK_COUNT); + assert_eq!(h.owner.provision(&h.context, witness), Err(WitnessOwnerError::Capacity)); + assert_eq!(h.owner.pending.usage().0, 63); + assert_eq!(h.owner.fetches.usage(), usage); + assert_eq!(h.owner.fetch(&h.context, witness), Ok(FetchProgress::AwaitingResponse)); + assert!(outgoing(&h).is_empty()); + let page = receive(&h, witness, &response(&first, vec![], None)); + assert_eq!(h.owner.accept_fetch_page(&page), Ok(FetchProgress::Complete)); + assert_eq!( + h.owner.provision(&h.context, witness), + Ok(super::super::provisioning::ProvisioningProgress::Queued) + ); +} + +#[test] +fn ffor_witness_fetch_byte_reservation_and_replacement_fail_without_eviction() { + let mut h = Harness::new(); + let first = start(&mut h); + let witness = h.policies[0].witness; + let manifest = h.owner.retained_manifests(&h.context).unwrap().remove(0).1; + let binding = WitnessStorageBinding::from_native_context(&h.context).unwrap(); + let source = WitnessConnection { + node_id: witness, + identity: h.owner.transport.connection(witness).unwrap(), + }; + let epoch = h.owner.fetches.entries[0].epoch; + let usage = h.owner.fetches.usage(); + let request = h.owner.fresh_fetch(&binding, witness, None).unwrap(); + let pending = PendingFetch::first(request, manifest, source).unwrap(); + assert_eq!( + h.owner.fetches.install(epoch, pending, (0, MAX_WORK_BYTES - usage.1 + 1)), + Err(WitnessOwnerError::Capacity) + ); + assert_eq!(h.owner.fetches.usage(), usage); + assert!(h.owner.fetches.contains_request_id(first.unsigned().parameters().request_id)); + let entry = &h.owner.fetches.entries[0]; + assert!(matches!(entry.state, FetchState::Request { queued: true, .. })); +} + +#[test] +fn ffor_witness_fetch_backpressure_keeps_exact_unsent_request() { + let mut h = Harness::new(); + h.register_and_persist(); + let witness = h.policies[0].witness; + h.connect(witness); + let token = h.owner.transport.connection(witness).unwrap(); + let binding = WitnessStorageBinding::from_native_context(&h.context).unwrap(); + for _ in 0..8 { + let request = h.owner.store.load(&binding).unwrap().prepare_fetch(witness, None).unwrap(); + h.owner.transport.enqueue_fetch(witness, &token, &request).unwrap(); + } + assert_eq!(h.owner.fetch(&h.context, witness), Ok(FetchProgress::Backpressured)); + let id = h.owner.fetches.entries[0].request_id; + let nonce = h.owner.fetches.entries[0].nonce; + let usage = h.owner.fetches.usage(); + assert_eq!(outgoing(&h).len(), 8); + assert_eq!(h.owner.fetch(&h.context, witness), Ok(FetchProgress::Queued)); + let queued = outgoing(&h).remove(0); + assert_eq!(queued.unsigned().parameters().request_id, id); + assert_eq!(queued.unsigned().parameters().nonce, nonce); + assert_eq!(h.owner.fetches.usage(), usage); + assert_eq!(h.owner.fetch(&h.context, witness), Ok(FetchProgress::AwaitingResponse)); + assert!(outgoing(&h).is_empty()); +} diff --git a/src/ffor/witness_owner/fixtures/README.md b/src/ffor/witness_owner/fixtures/README.md new file mode 100644 index 0000000000..f3993c50a7 --- /dev/null +++ b/src/ffor/witness_owner/fixtures/README.md @@ -0,0 +1,20 @@ +These are public test fixtures, not wallet backups. + +The funded Testnet channel and signed Active epoch were produced by the native +`ffor_witness_registration_release_requires_current_durability_and_survives_archive_only_restore` +test in `lightning/src/ln/channelmanager/ffor_activation/witness/tests.rs`, using +native checkpoint `7ed8161b7e3ac375b639de5e4d55542cf936af17` and +`FFOR_NODE_WITNESS_FIXTURE_DIR` for opt-in output. The exported snapshot precedes +witness registration. The native test runs ordinary commitment rounds, derives +the activation evidence from the actual channel and monitor, and authenticates +the settlement acknowledgement. + +The receiver uses the public 64-byte seed consisting entirely of `0x5b`. Both +the native fixture and NodeBuilder derive the signing seed with the private key +of `Xpriv::new_master(Network::Testnet, wallet_seed)`. No production authority +constructor or exported private wallet material is used. The fixture metadata +records the channel, epoch, funding outpoint, and public identities. + +Node tests restore these bytes through the ordinary NodeBuilder path. Restored +native authority requires a fresh successful manager persistence barrier before +registration or provisioning. Test snapshots alone grant no live authority. diff --git a/src/ffor/witness_owner/fixtures/active-manager.bin b/src/ffor/witness_owner/fixtures/active-manager.bin new file mode 100644 index 0000000000000000000000000000000000000000..37064ef7cd3d9d4534e3eb85b3e84acc6a1b1809 GIT binary patch literal 6786 zcmeGhc{tVE_V@dp!5Pe)97N}k%res;GGvNqGUTRYxS8o@EJGnt(aA9uz0e?2QKY0g zBs3u6kd%-)7ol|Ye#da@R_~to-u=G!$NTK>+h^^)&szIid+jyswSFL|XB^rxy^89C znaJd#Kc(fD1tyUq`Z@E!0TnDT-*7o7Ix;T#cSp^)X+^m${_GijB;}dxBO(M^46r&x}eziH!gkfW(!+7`RaKWha za}WTKkhhF?u7|$luh|xv)@@1;g`S0)y)WBzp0KNds^F;RSl{CKu@d%>N*$LcAF zQhI%qf`6l~H3Wb!%=Y;!1OUjvc41!tS{%$l(TFe@raAu_lsD~A?(ZIVKlW6{DH0}$ zKhm>Hr6^v;Mufv&S^_bL3;^I9Tp;p&;u#@U$u{GGjf(%B4}W+#t376TLzzWn@W6m5i{Fj z){Z1Y7ax%wCb!kPn_~xNWTQPFtBIGE0)0M#wP$_m0o}7wXfh%*e4?@5wgV@zS(vb5 z4760u7FAbfSKaR=k(|6?B={qSjNs`#SWikdU!zKn8t6#18@Jw_QJ?>CTY45Pn)jN& ze=r0A@kkIrm)xA^{efu2@;i7V!NX{FpEt|yM}ERR2rsG6v03>+ji9elyAI~v`#P?E z?7?lt>>cr+{rw}TcZ4--9R1>X3_7xZJ1ri{g(M@k(526m*^h z8E&;guy-hZTr+HNed``kbfW`3;I2B2j~FcKBQB)6HIQltPE$(TwAk)BVCtJC? z%1V(33dtW1Mqi;tREP^yz1q@->l{!{dr@h1Bp@lczAChsa_YvIfc{SUtuoc{aiXr za#@lKQvyqRu%rh|dhl}{_`j4310w;1`4^LO!v!wu(5ghqMebA6j%BW^v(6p8q)nOP zen!SwI5y&Sc`ihI(ULc{D5xnk^!82~+A<;>12jvrG&HyypBLM>)MU2edV$hJTb+?j zb-H@GUPAHVMB?3<-3f)#g(|(o^t3RO@_UMO@&#RsQ|_;R8(36*4Wj}AY9OFZ zhK}`UoR)XkaS1x`CPBFMR(z6LKHtiVx7Vm&y6VL%eNKY$WtM?9VxSEGk|m1<1+54E zI4wkCmFghpap4>X&6Hx=S7Hz%u}XQqCq^=$ZU*QDXnoxU_|I8@7@!=$0Hxr*vX4th z$mu%&FOHYD8CQIwhTN*+H0?@Ki|pKQuOfLGp%lK>Zit|B{hG5iof>^+d3jN0`>bYz zX|PnYaB?3I)lF-B=h>Uk+`22hHh*%GpX*5R`&_wO??D&LLn-D-U(UQ7OAg2+or%-cx)ZT3)x-Ed`{++CdO*2l^0 z5@L<@xU;Cj{xMR9UrE5uWKS!l&iZiWJ2Enr4PmRI-d(6Pf6aTdWK`sqtibf0l}8f8 zP%n}759Ca?)?DjC@Ru1R78+ZvaEU!i-?{pbyRhzgUEya@aTW=;uU|sZ&LZ;8iInP# zRp!5Z(sL-|1it2ok6WO50$*L~kf&2bNOXPRhJnmvDE>Q%{}=c6t;F9RUq+Unnpk6} zH1dqoTuAM)V`a%Esm`Lqc*kbKEwkbZ@}V}5#%WqIsb3ly87w#DFWxsQW*#p0rRmr) zDH&O(mK)hbi7?lS0IPf3-M1;KVIL?3iw?f$BG*;ca11wUl?nwMHcB5}raz=9aG_}i ztf~&m9jodYb;FW%dvkVvDJn7AqK%;4wN4N@g6tFQepUCRNFlGnICeDcVO`JTq-#d~ z>-{{^W5u%=SxGC+gS<~3IyoE5nbLbr%*)qIP>XOpIFeeO26aB2vK~EMI+!n~l*7H3 za%u04qNH~xWw-cMkCb|^bNG-tv?7wzUTlN6VWR$B3TFuWDaC9)#oD0F`6H!yqUv>R z{O|1zqjJdkynen? zF<)#r#x%w691}dna?h>J;{X5*oe)?In`BnmJ3^vGyY*VJjuGCzp@QGq$6s-3?>}=d+J9UoOLpaCbGUg;hoSOKe|C+Y#jV*lroG;q{?nxHe1$9{Y z99C|H?GOL;|#2R+remRHRhwvMZ?DIG_=F+V+Jz?0SeK2Drjuz69pN6JS zV2obIg}N2JfD0Nq>HCXgsMOLV^!aWR=c=tG z8}8qz2e8je11>2Myf_*#`!$)Wtwe z_X;~lBR(DCDMi)l__S;4bC+B^YX)f40$>hz>@~7Ee4x}KAOyU zi-k`&T~XxZWFwro5PtnxYld`nLz4>^Nug!+ZOX&MD@J-A5<`A(dA7<7@(#L8vQq5Vh+!Y@KT2E+~YwxIrQe39CzPBanRV|_yz zN09WfT~731`$tDT9-U*T-R=9?jMCiuLB&M!l91P|1b5_>!=I(C{dV|-ZE}Q?TV!h* zBhW`bX&(*g-x5!DA66+EZR3@nnu#2^Ioo__!)2W5BY{Eq2smXzJPx@p^xlq~8nnMs z;?q;Ha>fIJsxibjs0U%~Ay?OP6_?By_gl-&n7*!ndIH`V3v5=oylyg~!Zf{QJlp`v z0xq<${oRwY^50f(J?}JUlG7HR?XY)4lbl4$^Hd0{>x5&jir*%*s|wwKR$4dn z>{0E+{BpvRK89J0#So69f4a`y^KeyoOu}#%sE9-^ zC2Nt^ts0j|ibfi@v?6TrvhSGm=(p`N_SxS)&;FjL_m6Yl^Un92^F5z)KA-bFXFw2O zKIp-Ud!embfCwPO&C%^3X7w(5tBNFr?)hDS&jpNKtNYSe**xF};`GG_Ih=EyrrpED--K>tCD}V9RGM&~U&-CR zrmf^Q`;inETkm#G4mb^I1aVIZ#K=A}>pefB!9Bf6J*s+*o5Ok1>q<&?ZH!bUZBqhn zTYQt7`u8SM!w^3RhXRMPRVkZBjpBw|_V_r8ai04;qSEmlkuv&5I~bb~TE`b6vl1GpzG2rHYs|LEHlluL8(* zJ!a1^y~7(*I__JC`X>fZ>w<1mVoSzF9ad{pKkSqSfQ7#11tT)To;PK+kvGgr<*JKI zB~HBdy|8(o_RW=qD0!_>1bwDYpW*wnm&LXAXTL9g|7N#{#FzOuT)xC(vGfg>MI^q= zzv1#F9*d=KxGW;^W&RD9FY#C`{Y!8`Ei-!2XVsDc6>oddFp!lfHM$J&djb~TB(jh=aN+b!%5-jf&KnAg4sF=9O6Uv3%#bp3Hg zxrcVtM}0V_FvW8tqBzLC>F#XK>2x76C8tP@3TDfHNFi2^FT^P@D@2%9APgP`ut5%Q zekv?_Qgrx(i`UKiK(pZ!$r81)BL?1DMH(aMp2r|3(~DFB5)Vf6-l5AUgBnk}HCs-H z;9VX1jkI?CnB=9BEJY4oYxTM+LZL$j z*JE7!Q#ExTT;pVx6ULS^HeKgM{4M=}% zPPxm2MjVIv0(Ft8hnRyv77Uv4HH96^3P?_d9i*?re(-Lfr`EGLS@w@)3_aR*#0UGn z8$CT6bFviAdtZ#hy3Xf`Z{L`Ir(5?ML!*ppt>*>hg^~EURmowkSe|?U273YQQOU}D zHDT2ziKsl83gK?NcnKtH^JL{Ad@tw0827I6r5HmJ-MY${&B2dqYl*ZgyM*}2qz>8A zd8Yq7icI=|gDL#{;x-$mM>E*=AEE3u&fLNCj+koA z*Pvv{l|XK6D``5=+}#9xy?0h5SeRjyJ}AJobHYZo>bdFs@R3Og(v=q_PvIRQdY5A( zBgIHuX4|I^YtCKse@k@Du(FnQ@^A?~UM>&@(37(j?`?^~&M=P#Fq2YDs{Ap{W$Qx6 zU3zy!D?fSLV^W*f&69QAQ_2(i6NAF)r2gs6NDa(?)dH7sBxF=!iqA3*hlou5$P=b*9 zxz#n^?aeI%e2(|ikIXdFED5Gg17^^rX(gHadbix!BHZqDd^UZMyeV3PUDGA=AW7XT zHeJ({WN3Rfm}6~TS#X~~S07oYH53c66fqwEuk&zuO8fP?%i}N~z>gxV03d<`KFm?j zgGx^|6M9Kh*Ey!v?aVg7gr{8`3O(Lb(z*JEbovBj}#t ziBpK&{5*s2Mb&(WarJjn78mJ>@kSYqZzg@;ZY}9qHqEpbXvQiYPU%B)0vmD#I(VdUYUw-#`@>>3A4TZ@n_#jZ-Z+a4hO`@73};)YT(qW#{*ASo17M@ z7I~TnT8ESJo;CCzm67D)&--jQyQ)^9DTu&70+@k$m zxZuR8JcAVjUP*}WtS)R3-&tMQGJNNhX1)1g0B(k)@-p{*hOyEH*j2waa2-OF%`)Ce)fiX+@nZdfFEK0N_9xVUinp8@$;HU z)tDNmUjNW*rLDD!8E2E;rySGC*X@f%x`mlt(+in-DnSs-Y80X%AcnLrv5euafx$5t z9Q`b|^Gn5+UbqT#_P>E~CMBM`PrFUraO^b%>MYo?VLO*X-?c<0pvq0v3UZz}5mlCnl^QD5$ zyssW9IK5}EWOG-MwDhW8O1p(B63d4PD_fJpBI!C6d7;-vWZnb~?;s2_lW@=N0Rw6h|{#+5i* tcPFEGZR1AD6&|ex{bY5r<^euwpcD+|!r(Xk`Tx)#9I|j_KtK)ze+P#0R-^y` literal 0 HcmV?d00001 diff --git a/src/ffor/witness_owner/fixtures/fixture.txt b/src/ffor/witness_owner/fixtures/fixture.txt new file mode 100644 index 0000000000..4f7e5c141c --- /dev/null +++ b/src/ffor/witness_owner/fixtures/fixture.txt @@ -0,0 +1,10 @@ +network=testnet +wallet_seed=5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b +channel_id=ea6bf814aca4995e5f93bd4c0b94110bd41dd3b5b406649b685973d40807fd9e +epoch_id=5151515151515151515151515151515151515151515151515151515151515151 +receiver=02aa9e69b48a9595b89706a0e44a66c5526ffa70897b2986e2e27e310dcf0f5ac7 +settlement=027f921585f2ac0c7c70e36110adecfd8fd14b8a99bfb3d000a283fcac358fce88 +funding_txid=9efd0708d47359689b6406b4b5d31dd40b11940b4cbd935f5e99a4ac14f86bea +funding_vout=0 +generator=ffor_witness_registration_release_requires_current_durability_and_survives_archive_only_restore +base_revision=a20b34f98 diff --git a/src/ffor/witness_owner/pending.rs b/src/ffor/witness_owner/pending.rs new file mode 100644 index 0000000000..8aa952e7b5 --- /dev/null +++ b/src/ffor/witness_owner/pending.rs @@ -0,0 +1,244 @@ +//! Bounded transient correlation. This table carries no native phase or persistence authority. + +use std::sync::Arc; + +use lightning::ln::types::ChannelId; +use lightning_ffor::witness::{ + Acknowledgement, CheckedAcknowledgement, PendingProvision, Provision, SignedManifest, + WitnessConnection, +}; +use rand::{rngs::OsRng, TryRngCore}; + +use crate::message_handler::ffor::{ConnectionToken, FforReceiverTransport, ReceivedFforMessage}; + +use super::WitnessOwnerError; + +const MAX_PENDING: usize = 64; +const MAX_PENDING_BYTES: usize = 8 * 1024 * 1024; + +/// Historical lookup identity, checked again against the native archive before using the sidecar. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct Epoch { + pub(super) channel: ChannelId, + pub(super) epoch: [u8; 32], + pub(super) context_digest: [u8; 32], +} + +struct Entry { + epoch: Epoch, + request: Arc>, + encoded_bytes: usize, + queued: bool, +} + +pub(super) struct PendingProvisions { + entries: Vec, + encoded_bytes: usize, + available_count: usize, + available_bytes: usize, +} + +impl Default for PendingProvisions { + fn default() -> Self { + Self { + entries: Vec::new(), + encoded_bytes: 0, + available_count: MAX_PENDING, + available_bytes: MAX_PENDING_BYTES, + } + } +} + +impl PendingProvisions { + pub(super) fn usage(&self) -> (usize, usize) { + (self.entries.len(), self.encoded_bytes) + } + + pub(super) fn contains_request_id(&self, id: [u8; 16]) -> bool { + self.entries.iter().any(|entry| entry.request.provision().request_id() == id) + } + + /// Called under the exclusive owner before admission; existing work is never evicted. + pub(super) fn set_other_usage( + &mut self, count: usize, bytes: usize, + ) -> Result<(), WitnessOwnerError> { + let available_count = MAX_PENDING.checked_sub(count).ok_or(WitnessOwnerError::Capacity)?; + let available_bytes = + MAX_PENDING_BYTES.checked_sub(bytes).ok_or(WitnessOwnerError::Capacity)?; + if self.entries.len() > available_count || self.encoded_bytes > available_bytes { + return Err(WitnessOwnerError::Capacity); + } + self.available_count = available_count; + self.available_bytes = available_bytes; + Ok(()) + } + pub(super) fn stage( + &mut self, epoch: Epoch, source: WitnessConnection, + manifest: SignedManifest, + ) -> Result>, WitnessOwnerError> { + self.stage_with(epoch, source, manifest, random_id) + } + + pub(super) fn stage_excluding( + &mut self, epoch: Epoch, source: WitnessConnection, + manifest: SignedManifest, excluded: impl Fn([u8; 16]) -> bool, + ) -> Result>, WitnessOwnerError> { + self.stage_with(epoch, source, manifest, || fresh_external_id(&excluded)) + } + + fn stage_with( + &mut self, epoch: Epoch, source: WitnessConnection, + manifest: SignedManifest, draw: impl FnMut() -> Result<[u8; 16], WitnessOwnerError>, + ) -> Result>, WitnessOwnerError> { + let previous = self.existing(epoch, &source, &manifest)?; + if let Some(index) = previous { + if self.entries[index].request.connection() == &source { + return Ok(Arc::clone(&self.entries[index].request)); + } + } + self.install_new(epoch, source, manifest, previous, draw) + } + + pub(super) fn restart( + &mut self, epoch: Epoch, source: WitnessConnection, + manifest: SignedManifest, + ) -> Result>, WitnessOwnerError> { + let previous = self.existing(epoch, &source, &manifest)?; + self.install_new(epoch, source, manifest, previous, random_id) + } + + pub(super) fn restart_excluding( + &mut self, epoch: Epoch, source: WitnessConnection, + manifest: SignedManifest, excluded: impl Fn([u8; 16]) -> bool, + ) -> Result>, WitnessOwnerError> { + let previous = self.existing(epoch, &source, &manifest)?; + self.install_new(epoch, source, manifest, previous, || fresh_external_id(&excluded)) + } + + fn existing( + &self, epoch: Epoch, source: &WitnessConnection, manifest: &SignedManifest, + ) -> Result, WitnessOwnerError> { + let previous = self.entries.iter().position(|entry| { + entry.epoch.channel == epoch.channel + && entry.epoch.epoch == epoch.epoch + && entry.request.connection().node_id == source.node_id + }); + if let Some(index) = previous { + let entry = &self.entries[index]; + if entry.epoch.context_digest != epoch.context_digest + || entry.request.provision().manifest() != manifest + { + return Err(WitnessOwnerError::Conflict); + } + } + Ok(previous) + } + + fn install_new( + &mut self, epoch: Epoch, source: WitnessConnection, + manifest: SignedManifest, previous: Option, + mut draw: impl FnMut() -> Result<[u8; 16], WitnessOwnerError>, + ) -> Result>, WitnessOwnerError> { + // Keep the predecessor while drawing: even an explicit retry cannot reuse its request ID. + let id = self.fresh_id(&mut draw)?; + let provision = Provision::new(id, manifest); + let bytes = provision.encode().len(); + let previous_bytes = previous.map_or(0, |index| self.entries[index].encoded_bytes); + let total = self.encoded_bytes - previous_bytes; + if (previous.is_none() && self.entries.len() >= self.available_count) + || total.checked_add(bytes).filter(|size| *size <= self.available_bytes).is_none() + { + return Err(WitnessOwnerError::Capacity); + } + let request = Arc::new(PendingProvision::new(provision, source)); + let entry = + Entry { epoch, request: Arc::clone(&request), encoded_bytes: bytes, queued: false }; + if let Some(index) = previous { + self.entries[index] = entry; + } else { + self.entries.push(entry); + } + self.encoded_bytes = total + bytes; + Ok(request) + } + + pub(super) fn is_queued(&self, request: &Arc>) -> bool { + self.entries.iter().any(|entry| Arc::ptr_eq(&entry.request, request) && entry.queued) + } + + fn fresh_id( + &self, draw: &mut impl FnMut() -> Result<[u8; 16], WitnessOwnerError>, + ) -> Result<[u8; 16], WitnessOwnerError> { + for _ in 0..16 { + let id = draw()?; + if self.entries.iter().all(|entry| entry.request.provision().request_id() != id) { + return Ok(id); + } + } + Err(WitnessOwnerError::Entropy) + } + + pub(super) fn mark_queued(&mut self, request: &Arc>) { + if let Some(entry) = + self.entries.iter_mut().find(|entry| Arc::ptr_eq(&entry.request, request)) + { + entry.queued = true; + } + } + + pub(super) fn check( + &self, message: &ReceivedFforMessage, ack: &Acknowledgement, + ) -> Result<(Epoch, CheckedAcknowledgement), WitnessOwnerError> { + let entry = self + .entries + .iter() + .find(|entry| { + entry.queued && entry.request.provision().request_id() == ack.request_id() + }) + .ok_or(WitnessOwnerError::UnknownRequest)?; + let source = + WitnessConnection { node_id: message.peer(), identity: message.connection().clone() }; + let checked = entry + .request + .check_acknowledgement(ack, &source) + .map_err(WitnessOwnerError::Protocol)?; + Ok((entry.epoch, checked)) + } + + pub(super) fn complete(&mut self, request_id: [u8; 16]) { + if let Some(index) = self + .entries + .iter() + .position(|entry| entry.request.provision().request_id() == request_id) + { + self.encoded_bytes -= self.entries.remove(index).encoded_bytes; + } + } + + pub(super) fn discard_disconnected(&mut self, transport: &FforReceiverTransport) { + self.entries.retain(|entry| { + transport.connection(entry.request.connection().node_id).as_ref() + == Some(&entry.request.connection().identity) + }); + self.encoded_bytes = self.entries.iter().map(|entry| entry.encoded_bytes).sum(); + } +} + +fn random_id() -> Result<[u8; 16], WitnessOwnerError> { + let mut id = [0; 16]; + OsRng.try_fill_bytes(&mut id).map_err(|_| WitnessOwnerError::Entropy)?; + Ok(id) +} + +fn fresh_external_id(excluded: &impl Fn([u8; 16]) -> bool) -> Result<[u8; 16], WitnessOwnerError> { + for _ in 0..16 { + let id = random_id()?; + if !excluded(id) { + return Ok(id); + } + } + Err(WitnessOwnerError::Entropy) +} + +#[cfg(test)] +mod tests; diff --git a/src/ffor/witness_owner/pending/tests.rs b/src/ffor/witness_owner/pending/tests.rs new file mode 100644 index 0000000000..0277711fee --- /dev/null +++ b/src/ffor/witness_owner/pending/tests.rs @@ -0,0 +1,296 @@ +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use lightning::ln::msgs::Init; +use lightning::ln::peer_handler::CustomMessageHandler; +use lightning::ln::wire::CustomMessageReader; +use lightning_ffor::setup::AuthenticatedSetup; +use lightning_ffor::wire::Message; +use lightning_ffor::witness::AcknowledgementResult; +use lightning_types::features::InitFeatures; + +use super::*; +use crate::message_handler::NodeCustomMessageHandler; + +fn public(byte: u8) -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[byte; 32]).unwrap()) +} + +fn bytes(value: &str) -> Vec { + (0..value.len()).step_by(2).map(|i| u8::from_str_radix(&value[i..i + 2], 16).unwrap()).collect() +} + +fn manifest(index: usize) -> SignedManifest { + let fixture = include_str!("../../witness_store/record/key_use/fixtures.txt") + .trim() + .split("\n\n") + .nth(index) + .unwrap(); + let field = |name: &str| { + bytes(fixture.lines().find_map(|line| line.strip_prefix(&format!("{name}="))).unwrap()) + }; + let receiver = PublicKey::from_slice(&bytes( + "039fca7f8157aa768708894ffd92550fe970edd18526a5f936583ea3b54dab3228", + )) + .unwrap(); + let settlement = PublicKey::from_slice(&bytes( + "02087b7d1b4789170f6e374f0a0e58a1b7a899e34929795314ab6964e69609e9c0", + )) + .unwrap(); + let setup = AuthenticatedSetup::new( + &Message::decode(&field("init")).unwrap(), + &Message::decode(&field("accept")).unwrap(), + receiver, + settlement, + ) + .unwrap(); + SignedManifest::decode(&field("manifest"), &setup).unwrap() +} + +fn epoch(value: u8) -> Epoch { + Epoch { channel: ChannelId([1; 32]), epoch: [value; 32], context_digest: [value; 32] } +} + +struct Harness { + transport: Arc, + handler: NodeCustomMessageHandler, +} + +impl Harness { + fn new() -> Self { + let transport = Arc::new(FforReceiverTransport::default()); + let handler = + NodeCustomMessageHandler::new_ignoring().with_ffor_receiver(Arc::clone(&transport)); + Self { transport, handler } + } + fn connect(&self, peer: PublicKey) -> WitnessConnection { + let init = + Init { features: InitFeatures::empty(), networks: None, remote_network_address: None }; + self.handler.peer_connected(peer, &init, false).unwrap(); + WitnessConnection { node_id: peer, identity: self.transport.connection(peer).unwrap() } + } + fn receive(&self, peer: PublicKey, ack: &Acknowledgement) -> ReceivedFforMessage { + let wire = ack.encode(); + let message = self + .handler + .read(u16::from_be_bytes([wire[0], wire[1]]), &mut &wire[2..]) + .unwrap() + .unwrap(); + self.handler.handle_custom_message(message, peer).unwrap(); + self.transport.pop().unwrap() + } +} + +fn accepted(request: &PendingProvision) -> Acknowledgement { + Acknowledgement::new( + request.provision().request_id(), + AcknowledgementResult::Accepted { + witness: request.connection().node_id, + retention_until: request.provision().manifest().unsigned().parameters().retention_until, + }, + ) + .unwrap() +} + +#[test] +fn ffor_witness_owner_shared_work_budget_preserves_existing_requests() { + let harness = Harness::new(); + let source = harness.connect(public(43)); + let mut pending = PendingProvisions::default(); + let existing = pending.stage(epoch(1), source.clone(), manifest(0)).unwrap(); + let usage = pending.usage(); + pending.set_other_usage(MAX_PENDING - 1, MAX_PENDING_BYTES - usage.1).unwrap(); + assert!(matches!( + pending.stage(epoch(2), source.clone(), manifest(0)), + Err(WitnessOwnerError::Capacity) + )); + assert_eq!(pending.usage(), usage); + assert!(Arc::ptr_eq(&existing, &pending.stage(epoch(1), source.clone(), manifest(0)).unwrap())); + assert_eq!(pending.set_other_usage(MAX_PENDING, 0), Err(WitnessOwnerError::Capacity)); + assert_eq!(pending.usage(), usage); + assert!(pending.contains_request_id(existing.provision().request_id())); + // Excluding every new ID simulates a failed freshness allocation without replacing correlation. + assert!(matches!( + pending.restart_excluding(epoch(1), source, manifest(0), |_| true), + Err(WitnessOwnerError::Entropy) + )); + assert!(pending.contains_request_id(existing.provision().request_id())); +} + +#[test] +fn ffor_witness_owner_pending_precedes_queue_and_requires_successful_enqueue() { + let harness = Harness::new(); + let source = harness.connect(public(43)); + let mut pending = PendingProvisions::default(); + let request = pending.stage(epoch(1), source.clone(), manifest(0)).unwrap(); + let ack = accepted(&request); + let message = harness.receive(public(43), &ack); + assert_eq!(pending.check(&message, &ack), Err(WitnessOwnerError::UnknownRequest)); + assert_eq!(pending.entries.len(), 1); + harness.transport.enqueue_provision(public(43), &source.identity, request.provision()).unwrap(); + pending.mark_queued(&request); + let (found, checked) = pending.check(&message, &ack).unwrap(); + assert_eq!(found, epoch(1)); + assert_eq!(checked.provision(), request.provision()); + // Checking does not remove work: a subsequent storage failure must retain correlation. + assert!(pending.check(&message, &ack).is_ok()); + pending.complete(ack.request_id()); + assert!(pending.entries.is_empty()); + assert_eq!(pending.encoded_bytes, 0); +} + +#[test] +fn ffor_witness_owner_retry_preserves_exact_request_and_reconnect_replaces_it() { + let harness = Harness::new(); + let first = harness.connect(public(43)); + let mut pending = PendingProvisions::default(); + let request = pending.stage_with(epoch(1), first.clone(), manifest(0), || Ok([1; 16])).unwrap(); + let retry = pending + .stage_with(epoch(1), first.clone(), manifest(0), || panic!("retry drew entropy")) + .unwrap(); + assert!(Arc::ptr_eq(&request, &retry)); + assert_eq!(pending.stage(epoch(1), first, manifest(1)), Err(WitnessOwnerError::Conflict)); + let second = harness.connect(public(43)); + pending.discard_disconnected(&harness.transport); + assert_eq!(pending.encoded_bytes, 0); + let replacement = pending.stage_with(epoch(1), second, manifest(0), || Ok([2; 16])).unwrap(); + assert_ne!(request.connection(), replacement.connection()); + assert_ne!(request.provision().request_id(), replacement.provision().request_id()); + assert_eq!(request.provision().manifest(), replacement.provision().manifest()); + harness.handler.peer_disconnected(public(43)); + pending.discard_disconnected(&harness.transport); + assert!(pending.entries.is_empty()); +} + +#[test] +fn ffor_witness_owner_rejects_wrong_source_request_retention_and_claimed_identity() { + let harness = Harness::new(); + let source = harness.connect(public(43)); + harness.connect(public(44)); + let mut pending = PendingProvisions::default(); + let request = pending.stage(epoch(1), source, manifest(0)).unwrap(); + pending.mark_queued(&request); + let ack = accepted(&request); + let wrong_peer = harness.receive(public(44), &ack); + assert!(pending.check(&wrong_peer, &ack).is_err()); + for result in [ + AcknowledgementResult::Accepted { witness: public(44), retention_until: u32::MAX }, + AcknowledgementResult::Accepted { witness: public(43), retention_until: 0 }, + AcknowledgementResult::Refused(b"unavailable".to_vec()), + ] { + let bad = Acknowledgement::new(ack.request_id(), result).unwrap(); + let message = harness.receive(public(43), &bad); + assert!(pending.check(&message, &bad).is_err()); + } + let wrong_id = Acknowledgement::new([7; 16], ack.result().clone()).unwrap(); + assert_eq!( + pending.check(&harness.receive(public(43), &wrong_id), &wrong_id), + Err(WitnessOwnerError::UnknownRequest) + ); + harness.connect(public(43)); + let new_connection = harness.receive(public(43), &ack); + assert_eq!( + pending.check(&new_connection, &ack), + Err(WitnessOwnerError::Protocol(lightning_ffor::witness::WitnessError::Connection)) + ); + assert_eq!(pending.entries.len(), 1); +} + +#[test] +fn ffor_witness_owner_pending_capacity_never_evicts_or_changes_existing_work() { + let harness = Harness::new(); + let source = harness.connect(public(43)); + let mut pending = PendingProvisions::default(); + for index in 0..MAX_PENDING { + pending + .stage_with(epoch(index as u8), source.clone(), manifest(0), || Ok([index as u8; 16])) + .unwrap(); + } + let initial = pending.entries[0].request.clone(); + let bytes = pending.encoded_bytes; + assert_eq!( + pending.stage(epoch(80), source.clone(), manifest(0)), + Err(WitnessOwnerError::Capacity) + ); + assert_eq!(pending.entries.len(), MAX_PENDING); + assert_eq!(pending.encoded_bytes, bytes); + assert!(Arc::ptr_eq(&initial, &pending.stage(epoch(0), source, manifest(0)).unwrap())); + let mut wrong = epoch(0); + wrong.context_digest = [99; 32]; + assert_eq!( + pending.stage(wrong, initial.connection().clone(), manifest(0)), + Err(WitnessOwnerError::Conflict) + ); + assert_eq!(pending.encoded_bytes, bytes); +} + +#[test] +fn ffor_witness_owner_entropy_failure_and_collisions_preserve_pending_work() { + let harness = Harness::new(); + let source = harness.connect(public(43)); + let mut pending = PendingProvisions::default(); + pending.stage_with(epoch(1), source.clone(), manifest(0), || Ok([1; 16])).unwrap(); + assert_eq!( + pending + .stage_with(epoch(2), source.clone(), manifest(0), || Err(WitnessOwnerError::Entropy)), + Err(WitnessOwnerError::Entropy) + ); + let mut draws = 0; + assert_eq!( + pending.stage_with(epoch(2), source, manifest(0), || { + draws += 1; + Ok([1; 16]) + }), + Err(WitnessOwnerError::Entropy) + ); + assert_eq!(draws, 16); + assert_eq!(pending.entries.len(), 1); +} + +#[test] +fn ffor_witness_owner_accepted_queue_waits_after_drain_until_explicit_fresh_retry() { + let harness = Harness::new(); + let source = harness.connect(public(43)); + let mut pending = PendingProvisions::default(); + let request = pending.stage(epoch(1), source.clone(), manifest(0)).unwrap(); + harness.transport.enqueue_provision(public(43), &source.identity, request.provision()).unwrap(); + pending.mark_queued(&request); + assert_eq!(harness.handler.get_and_clear_pending_msg().len(), 1); + let unchanged = pending.stage(epoch(1), source.clone(), manifest(0)).unwrap(); + assert!(pending.is_queued(&unchanged)); + assert!(Arc::ptr_eq(&request, &unchanged)); + assert!(harness.handler.get_and_clear_pending_msg().is_empty()); + let replacement = pending.restart(epoch(1), source, manifest(0)).unwrap(); + assert!(!pending.is_queued(&replacement)); + assert_ne!(replacement.provision().request_id(), request.provision().request_id()); + assert_eq!(replacement.provision().manifest(), request.provision().manifest()); + assert_eq!(pending.entries.len(), 1); +} + +#[test] +fn ffor_witness_owner_connection_sweep_preserves_replacement_pending_work() { + let harness = Harness::new(); + let old = harness.connect(public(43)); + let mut pending = PendingProvisions::default(); + let request = pending.stage(epoch(1), old, manifest(0)).unwrap(); + pending.mark_queued(&request); + let current = harness.connect(public(43)); + let replacement = pending.stage(epoch(1), current, manifest(0)).unwrap(); + // A delayed observer of the old disconnect consults current tokens rather than removing by W. + pending.discard_disconnected(&harness.transport); + assert_eq!(pending.entries.len(), 1); + assert!(Arc::ptr_eq(&pending.entries[0].request, &replacement)); + assert!(!pending.is_queued(&replacement)); + let index = pending + .existing(epoch(1), replacement.connection(), replacement.provision().manifest()) + .unwrap(); + assert_eq!( + pending.install_new( + epoch(1), + replacement.connection().clone(), + replacement.provision().manifest().clone(), + index, + || Err(WitnessOwnerError::Entropy) + ), + Err(WitnessOwnerError::Entropy) + ); + assert!(Arc::ptr_eq(&pending.entries[0].request, &replacement)); +} diff --git a/src/ffor/witness_owner/provisioning.rs b/src/ffor/witness_owner/provisioning.rs new file mode 100644 index 0000000000..c433b065d2 --- /dev/null +++ b/src/ffor/witness_owner/provisioning.rs @@ -0,0 +1,224 @@ +//! Native registration is the only permission to release a Provision. Storage work stays outside +//! native transition locks, and the release callback touches only the bounded transport queue. + +use std::sync::Arc; + +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::secp256k1::PublicKey; +use lightning::ln::ffor::{FFORReceiverRecoveryContext, FFORReceiverWitnessRegistration}; +use lightning_ffor::witness::{SignedManifest, WitnessConnection}; + +use super::pending::Epoch; +use super::{WitnessOwner, WitnessOwnerError}; +use crate::ffor::witness_store::{WitnessPolicy, WitnessStorageBinding}; +use crate::message_handler::ffor::OutboundError; + +/// Observations of registration persistence, not invoice or receiving authority. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RegistrationProgress { + AwaitingPersistence, + Retained, +} + +/// One bounded release attempt. Queued means accepted locally, never delivered or acknowledged. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ProvisioningProgress { + AwaitingPersistence, + Backpressured, + Queued, + AwaitingAcknowledgement, +} + +enum Attempt { + Advance, + Retry, +} + +type Manifests = Vec<(PublicKey, SignedManifest)>; + +impl WitnessOwner { + /// Create protected material only when native has no immutable registration. An existing native + /// selection always requires load-only recovery, including when the caller changes its policy. + pub(crate) fn register( + &mut self, context: &FFORReceiverRecoveryContext, policies: &[WitnessPolicy], + ) -> Result { + self.synchronize_connections(); + let registration = self + .manager + .ffor_receiver_witness_registration(context) + .map_err(WitnessOwnerError::Native)?; + let binding = WitnessStorageBinding::from_native_context(context) + .map_err(WitnessOwnerError::Storage)?; + if registration.is_none() { + self.store.create(&binding, policies).map_err(WitnessOwnerError::Storage)?; + } + let manifests = + self.store.provisioning_manifests(&binding).map_err(WitnessOwnerError::Storage)?; + check_policies(policies, &manifests)?; + if let Some(registration) = registration { + check_registration(context, ®istration, &manifests)?; + } + let requirement = self + .manager + .register_ffor_receiver_witnesses(context, &manifests) + .map_err(WitnessOwnerError::Native)?; + Ok(if self.manager.is_ffor_state_persisted(&requirement) { + RegistrationProgress::Retained + } else { + RegistrationProgress::AwaitingPersistence + }) + } + + /// Reconfirm the sidecar, require durable native selection, then stage exact response correlation + /// before the native callback can enqueue. Missing protected data never allocates replacement keys. + pub(crate) fn provision( + &mut self, context: &FFORReceiverRecoveryContext, witness: PublicKey, + ) -> Result { + self.provision_inner(context, witness, Attempt::Advance) + } + + /// Explicit timeout or ambiguous-delivery retry. Retain exact manifest/keys but use a fresh ID. + pub(crate) fn retry_provision( + &mut self, context: &FFORReceiverRecoveryContext, witness: PublicKey, + ) -> Result { + self.provision_inner(context, witness, Attempt::Retry) + } + + fn provision_inner( + &mut self, context: &FFORReceiverRecoveryContext, witness: PublicKey, attempt: Attempt, + ) -> Result { + self.synchronize_connections(); + let manifests = self.retained_manifests(context)?; + let requirement = self + .manager + .register_ffor_receiver_witnesses(context, &manifests) + .map_err(WitnessOwnerError::Native)?; + if !self.manager.is_ffor_state_persisted(&requirement) { + return Ok(ProvisioningProgress::AwaitingPersistence); + } + let active = self + .manager + .capture_ffor_receiver_active_context( + &context.channel_id(), + &context.settlement_node_id(), + context.epoch_id(), + ) + .map_err(WitnessOwnerError::Native)?; + let manifest = manifests + .into_iter() + .find_map(|(selected, manifest)| (selected == witness).then_some(manifest)) + .ok_or(WitnessOwnerError::UnknownWitness)?; + let connection = + self.transport.connection(witness).ok_or(WitnessOwnerError::StaleConnection)?; + let epoch = Epoch { + channel: context.channel_id(), + epoch: context.epoch_id(), + context_digest: context.context_digest(), + }; + let source = WitnessConnection { node_id: witness, identity: connection.clone() }; + let (other_count, other_bytes) = self.fetches.usage(); + self.pending.set_other_usage(other_count, other_bytes)?; + let fetches = &self.fetches; + let pending = match attempt { + Attempt::Advance => self + .pending + .stage_excluding(epoch, source, manifest, |id| fetches.contains_request_id(id))?, + Attempt::Retry => self + .pending + .restart_excluding(epoch, source, manifest, |id| fetches.contains_request_id(id))?, + }; + if self.pending.is_queued(&pending) { + return Ok(ProvisioningProgress::AwaitingAcknowledgement); + } + let transport = Arc::clone(&self.transport); + let mut refusal = None; + let queued = self + .manager + .release_ffor_receiver_witness_provision( + &active, + &witness, + pending.provision(), + |provision| { + // This is the sole nested native -> transport lock acquisition. No owner/store reentry. + transport.enqueue_provision(witness, &connection, provision).map_err(|error| { + refusal = Some(error); + }) + }, + ) + .map_err(WitnessOwnerError::Native)?; + if queued { + self.pending.mark_queued(&pending); + return Ok(ProvisioningProgress::Queued); + } + match refusal { + Some(OutboundError::Capacity) => Ok(ProvisioningProgress::Backpressured), + Some(OutboundError::StaleConnection) => { + self.pending.discard_disconnected(&self.transport); + Err(WitnessOwnerError::StaleConnection) + }, + Some(OutboundError::InvalidMessage) => Err(WitnessOwnerError::Conflict), + None => Ok(ProvisioningProgress::AwaitingPersistence), + } + } + + pub(super) fn retained_manifests( + &self, context: &FFORReceiverRecoveryContext, + ) -> Result { + let registration = self + .manager + .ffor_receiver_witness_registration(context) + .map_err(WitnessOwnerError::Native)? + .ok_or(WitnessOwnerError::Unregistered)?; + let binding = WitnessStorageBinding::from_native_context(context) + .map_err(WitnessOwnerError::Storage)?; + let manifests = + self.store.provisioning_manifests(&binding).map_err(WitnessOwnerError::Storage)?; + check_registration(context, ®istration, &manifests)?; + Ok(manifests) + } +} + +fn check_policies( + policies: &[WitnessPolicy], manifests: &Manifests, +) -> Result<(), WitnessOwnerError> { + if policies.len() != manifests.len() || policies.is_empty() || policies.len() > 4 { + return Err(WitnessOwnerError::Conflict); + } + let mut policies = policies.to_vec(); + policies.sort_unstable_by_key(|policy| policy.witness); + for (policy, (witness, manifest)) in policies.iter().zip(manifests) { + let params = manifest.unsigned().parameters(); + if policy.witness != *witness + || policy.retention_until != params.retention_until + || policy.minimum_receipts != params.minimum_receipts + { + return Err(WitnessOwnerError::Conflict); + } + } + Ok(()) +} + +fn check_registration( + context: &FFORReceiverRecoveryContext, registration: &FFORReceiverWitnessRegistration, + manifests: &Manifests, +) -> Result<(), WitnessOwnerError> { + if registration.context_digest() != context.context_digest() + || registration.witnesses().len() != manifests.len() + { + return Err(WitnessOwnerError::Conflict); + } + for (selected, (witness, manifest)) in registration.witnesses().iter().zip(manifests) { + let params = manifest.unsigned().parameters(); + if selected.witness_node_id() != *witness + || selected.manifest_digest() != sha256::Hash::hash(&manifest.encode()).to_byte_array() + || selected.mailbox_id() != params.mailbox_id + || selected.fetch_public_key() != params.fetch_public_key + || selected.encryption_public_key() != params.encryption_public_key + || selected.retention_until() != params.retention_until + || selected.minimum_receipts() != params.minimum_receipts + { + return Err(WitnessOwnerError::Conflict); + } + } + Ok(()) +} diff --git a/src/ffor/witness_owner/tests.rs b/src/ffor/witness_owner/tests.rs new file mode 100644 index 0000000000..0bc0325a26 --- /dev/null +++ b/src/ffor/witness_owner/tests.rs @@ -0,0 +1,450 @@ +//! Real NodeBuilder restore of a publicly reproducible funded, signed Active epoch. + +use std::str::FromStr; +use std::sync::atomic::Ordering; + +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use bitcoin::Network; +use lightning::ln::ffor::FFORReceiverRecoveryContext; +use lightning::ln::msgs::Init; +use lightning::ln::peer_handler::CustomMessageHandler; +use lightning::ln::wire::CustomMessageReader; +use lightning::util::persist::{ + KVStoreSync, CHANNEL_MANAGER_PERSISTENCE_KEY, CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, CHANNEL_MONITOR_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MONITOR_PERSISTENCE_SECONDARY_NAMESPACE, +}; +use lightning::util::ser::Writeable; +use lightning_ffor::witness::{AcknowledgementResult, Provision, WitnessConnection}; +use lightning_types::features::InitFeatures; + +use super::provisioning::{ProvisioningProgress, RegistrationProgress}; +use super::*; +use crate::builder::NodeBuilder; +use crate::ffor::witness_store::tests::TestStore; +use crate::ffor::witness_store::WitnessPolicy; +use crate::message_handler::{NodeCustomMessage, NodeCustomMessageHandler}; +use crate::types::DynStore; +use crate::{Config, Node}; + +const SEED: [u8; 64] = [91; 64]; + +fn field(name: &str) -> &'static str { + include_str!("fixtures/fixture.txt") + .lines() + .find_map(|line| line.strip_prefix(&format!("{name}="))) + .unwrap() +} + +fn key(byte: u8) -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[byte; 32]).unwrap()) +} + +fn restore(storage: Arc) -> Node { + let mut builder = + NodeBuilder::from_config(Config { network: Network::Testnet, ..Config::default() }); + builder.set_entropy_seed_bytes(SEED); + builder.set_log_facade_logger(); + let storage: Arc = storage; + builder.build_with_store(storage).unwrap() +} + +pub(super) struct Harness { + pub(super) node: Node, + pub(super) storage: Arc, + pub(super) owner: WitnessOwner, + pub(super) handler: NodeCustomMessageHandler, + pub(super) context: FFORReceiverRecoveryContext, + pub(super) policies: Vec, +} + +impl Harness { + pub(super) fn new() -> Self { + let storage = TestStore::new(); + KVStoreSync::write( + &*storage, + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_KEY, + include_bytes!("fixtures/active-manager.bin").to_vec(), + ) + .unwrap(); + KVStoreSync::write( + &*storage, + CHANNEL_MONITOR_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MONITOR_PERSISTENCE_SECONDARY_NAMESPACE, + &format!("{}_{}", field("funding_txid"), field("funding_vout")), + include_bytes!("fixtures/active-monitor.bin").to_vec(), + ) + .unwrap(); + Self::from_store(storage) + } + + pub(super) fn from_store(storage: Arc) -> Self { + let node = restore(Arc::clone(&storage)); + assert_eq!(node.node_id(), PublicKey::from_str(field("receiver")).unwrap()); + let context = + node.channel_manager.list_ffor_receiver_recovery_contexts().unwrap().remove(0); + assert_eq!(context.settlement_node_id(), PublicKey::from_str(field("settlement")).unwrap()); + let transport = Arc::new(FforReceiverTransport::default()); + let handler = + NodeCustomMessageHandler::new_ignoring().with_ffor_receiver(Arc::clone(&transport)); + let backing: Arc = storage.clone(); + let store = Arc::new(WitnessSecretStore::open(&SEED, backing).unwrap()); + let owner = WitnessOwner::new(Arc::clone(&node.channel_manager), store, transport); + let expiry = context.setup().terms().voucher_expiry; + let policies = vec![WitnessPolicy { + witness: key(80), + retention_until: expiry + 144, + minimum_receipts: 0, + }]; + Self { node, storage, owner, handler, context, policies } + } + + pub(super) fn persist_manager(&self) { + let token = self.node.channel_manager.capture_ffor_persistence(); + KVStoreSync::write( + &*self.storage, + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_KEY, + self.node.channel_manager.encode(), + ) + .unwrap(); + self.node.channel_manager.ffor_persistence_completed(token).unwrap(); + } + + pub(super) fn connect(&self, peer: PublicKey) { + let init = + Init { features: InitFeatures::empty(), networks: None, remote_network_address: None }; + self.handler.peer_connected(peer, &init, false).unwrap(); + } + + pub(super) fn register_and_persist(&mut self) { + self.persist_manager(); + assert_eq!( + self.owner.register(&self.context, &self.policies), + Ok(RegistrationProgress::AwaitingPersistence) + ); + self.persist_manager(); + assert_eq!( + self.owner.register(&self.context, &self.policies), + Ok(RegistrationProgress::Retained) + ); + } + + fn outgoing(&self) -> Vec { + self.handler + .get_and_clear_pending_msg() + .into_iter() + .map(|(peer, message)| { + assert_eq!(peer, self.policies[0].witness); + match message { + NodeCustomMessage::Ffor(frame) => { + Provision::decode(frame.wire(), self.context.setup()).unwrap() + }, + _ => panic!("unexpected LSPS message"), + } + }) + .collect() + } + + fn accepted(&self, provision: &Provision) -> Acknowledgement { + Acknowledgement::new( + provision.request_id(), + AcknowledgementResult::Accepted { + witness: self.policies[0].witness, + retention_until: provision.manifest().unsigned().parameters().retention_until, + }, + ) + .unwrap() + } + + fn receive(&self, peer: PublicKey, ack: &Acknowledgement) -> ReceivedFforMessage { + let bytes = ack.encode(); + let message = self + .handler + .read(u16::from_be_bytes([bytes[0], bytes[1]]), &mut &bytes[2..]) + .unwrap() + .unwrap(); + self.handler.handle_custom_message(message, peer).unwrap(); + self.owner.transport.pop().unwrap() + } + + fn secret_bytes(&self) -> Vec { + let binding = WitnessStorageBinding::from_native_context(&self.context).unwrap(); + self.storage.bytes(&binding.storage_key()) + } +} + +#[test] +fn ffor_witness_owner_real_restore_registration_and_queue_require_durability() { + let mut h = Harness::new(); + let w = h.policies[0].witness; + h.connect(w); + assert!(h + .node + .channel_manager + .capture_ffor_receiver_active_context( + &h.context.channel_id(), + &h.context.settlement_node_id(), + h.context.epoch_id() + ) + .is_err()); + assert!(matches!(h.owner.register(&h.context, &h.policies), Err(WitnessOwnerError::Native(_)))); + assert!(h + .node + .channel_manager + .ffor_receiver_witness_registration(&h.context) + .unwrap() + .is_none()); + assert!(h.outgoing().is_empty()); + h.persist_manager(); + assert_eq!( + h.owner.register(&h.context, &h.policies), + Ok(RegistrationProgress::AwaitingPersistence) + ); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::AwaitingPersistence)); + assert!(h.outgoing().is_empty()); + h.persist_manager(); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::Queued)); + let first = h.outgoing().remove(0); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::AwaitingAcknowledgement)); + assert!(h.outgoing().is_empty()); + assert_eq!(h.owner.retry_provision(&h.context, w), Ok(ProvisioningProgress::Queued)); + let second = h.outgoing().remove(0); + assert_ne!(first.request_id(), second.request_id()); + assert_eq!(first.manifest(), second.manifest()); + let old = h.receive(w, &h.accepted(&first)); + assert_eq!(h.owner.acknowledge(&old), Err(WitnessOwnerError::UnknownRequest)); + let current = h.receive(w, &h.accepted(&second)); + let before = h.secret_bytes(); + assert_eq!(h.owner.acknowledge(¤t), Ok(AcknowledgementProgress::Retained)); + assert_ne!(before, h.secret_bytes()); + assert_eq!(h.owner.pending.usage(), (0, 0)); +} + +#[test] +fn ffor_witness_owner_real_ack_correlates_connection_and_retains_failed_write() { + let mut h = Harness::new(); + h.register_and_persist(); + let w = h.policies[0].witness; + h.connect(w); + h.connect(key(81)); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::Queued)); + let request = h.outgoing().remove(0); + let ack = h.accepted(&request); + let wrong = h.receive(key(81), &ack); + assert!(matches!(h.owner.acknowledge(&wrong), Err(WitnessOwnerError::Protocol(_)))); + let stale = h.receive(w, &ack); + h.handler.peer_disconnected(w); + h.connect(w); + assert_eq!(h.owner.acknowledge(&stale), Err(WitnessOwnerError::StaleConnection)); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::Queued)); + let current = h.outgoing().remove(0); + assert_ne!(request.request_id(), current.request_id()); + let input = h.receive(w, &h.accepted(¤t)); + let before = h.secret_bytes(); + h.storage.write_failure.store(2, Ordering::SeqCst); + assert_eq!( + h.owner.acknowledge(&input), + Err(WitnessOwnerError::Storage(WitnessStoreError::Storage)) + ); + let visible_candidate = h.secret_bytes(); + assert_ne!(before, visible_candidate); + assert_eq!(h.owner.pending.usage().0, 1); + h.owner.recover_storage().unwrap(); + assert_eq!(h.owner.acknowledge(&input), Ok(AcknowledgementProgress::Retained)); + assert_eq!(visible_candidate, h.secret_bytes()); + assert_eq!(h.owner.pending.usage(), (0, 0)); +} + +#[test] +fn ffor_witness_owner_real_native_registration_never_recreates_missing_sidecar() { + let mut h = Harness::new(); + h.register_and_persist(); + let binding = WitnessStorageBinding::from_native_context(&h.context).unwrap(); + let key = binding.storage_key(); + let before = h.secret_bytes(); + let mut changed = h.policies.clone(); + changed[0].minimum_receipts = 1; + assert_eq!(h.owner.register(&h.context, &changed), Err(WitnessOwnerError::Conflict)); + assert_eq!(before, h.secret_bytes()); + let storage = Arc::clone(&h.storage); + drop(h); + for namespace in ["ffor_witness", "ffor_witness_receipts"] { + KVStoreSync::remove(&*storage, namespace, "", &key, false).unwrap(); + } + let mut restored = Harness::from_store(storage); + restored.persist_manager(); + let writes = restored.storage.writes.load(Ordering::SeqCst); + assert_eq!( + restored.owner.register(&restored.context, &restored.policies), + Err(WitnessOwnerError::Storage(WitnessStoreError::Missing)) + ); + assert_eq!(restored.storage.writes.load(Ordering::SeqCst), writes); + assert!(restored.outgoing().is_empty()); +} + +#[test] +fn ffor_witness_owner_real_queue_backpressure_retains_exact_unsent_request() { + let mut h = Harness::new(); + h.register_and_persist(); + let w = h.policies[0].witness; + h.connect(w); + let token = h.owner.transport.connection(w).unwrap(); + let manifests = h.owner.retained_manifests(&h.context).unwrap(); + for i in 0..8 { + h.owner + .transport + .enqueue_provision(w, &token, &Provision::new([i; 16], manifests[0].1.clone())) + .unwrap(); + } + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::Backpressured)); + let count = h.owner.pending.usage(); + assert_eq!(count.0, 1); + assert_eq!(h.outgoing().len(), 8); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::Queued)); + let sent = h.outgoing().remove(0); + assert!(h.owner.pending.contains_request_id(sent.request_id())); + assert_eq!(h.owner.pending.usage(), count); +} + +#[test] +fn ffor_witness_owner_real_channel_removal_refuses_release_but_retains_historical_ack() { + let mut h = Harness::new(); + h.register_and_persist(); + let w = h.policies[0].witness; + h.connect(w); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::Queued)); + let provision = h.outgoing().remove(0); + let response = h.receive(w, &h.accepted(&provision)); + h.node + .channel_manager + .force_close_broadcasting_latest_txn( + &h.context.channel_id(), + &h.context.settlement_node_id(), + "fixture close".to_owned(), + ) + .unwrap(); + assert!(h.node.channel_manager.list_channels().is_empty()); + assert!(matches!(h.owner.retry_provision(&h.context, w), Err(WitnessOwnerError::Native(_)))); + assert!(h.outgoing().is_empty()); + assert_eq!(h.owner.retained_manifests(&h.context).unwrap()[0].1, *provision.manifest()); + // This is a historical storage promise. It never restores the removed channel's authority. + assert_eq!(h.owner.acknowledge(&response), Ok(AcknowledgementProgress::Retained)); + assert!(h.node.channel_manager.list_channels().is_empty()); +} + +#[test] +fn ffor_witness_owner_real_sidecar_failure_precedes_native_registration() { + for stage in 1..=3 { + for failure in [1, 2] { + let mut h = Harness::new(); + h.persist_manager(); + let w = h.policies[0].witness; + h.connect(w); + h.storage + .fail_at + .store(h.storage.writes.load(Ordering::SeqCst) + stage, Ordering::SeqCst); + h.storage.write_failure.store(failure, Ordering::SeqCst); + assert_eq!( + h.owner.register(&h.context, &h.policies), + Err(WitnessOwnerError::Storage(WitnessStoreError::Storage)) + ); + assert!(h + .node + .channel_manager + .ffor_receiver_witness_registration(&h.context) + .unwrap() + .is_none()); + assert!(h.outgoing().is_empty()); + assert_eq!(h.owner.provision(&h.context, w), Err(WitnessOwnerError::Unregistered)); + h.owner.recover_storage().unwrap(); + assert_eq!( + h.owner.register(&h.context, &h.policies), + Ok(RegistrationProgress::AwaitingPersistence) + ); + h.persist_manager(); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::Queued)); + assert_eq!(h.outgoing().len(), 1); + } + } +} + +#[test] +fn ffor_witness_owner_real_failed_and_older_native_writes_do_not_release_registration() { + let mut h = Harness::new(); + h.persist_manager(); + let older = h.node.channel_manager.capture_ffor_persistence(); + assert_eq!( + h.owner.register(&h.context, &h.policies), + Ok(RegistrationProgress::AwaitingPersistence) + ); + let w = h.policies[0].witness; + h.connect(w); + let cancelled = h.node.channel_manager.capture_ffor_persistence(); + h.storage.write_failure.store(1, Ordering::SeqCst); + assert!(KVStoreSync::write( + &*h.storage, + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_KEY, + h.node.channel_manager.encode() + ) + .is_err()); + drop(cancelled); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::AwaitingPersistence)); + // Persisting bytes after a captured older revision still cannot certify the new registration. + KVStoreSync::write( + &*h.storage, + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_KEY, + h.node.channel_manager.encode(), + ) + .unwrap(); + h.node.channel_manager.ffor_persistence_completed(older).unwrap(); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::AwaitingPersistence)); + assert!(h.outgoing().is_empty()); + h.persist_manager(); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::Queued)); + assert_eq!(h.outgoing().len(), 1); +} + +#[test] +fn ffor_witness_owner_real_fetch_admission_counts_pending_provisions() { + let mut h = Harness::new(); + h.register_and_persist(); + let w = h.policies[0].witness; + h.connect(w); + let manifest = h.owner.retained_manifests(&h.context).unwrap().remove(0).1; + let source = + WitnessConnection { node_id: w, identity: h.owner.transport.connection(w).unwrap() }; + // Inject correlation-only pressure, not fake channel authority. None of these synthetic + // lookup epochs is registered or released. The final attempted fetch uses the genuine epoch. + let mut ids = Vec::new(); + for index in 0..64 { + let epoch = pending::Epoch { + channel: h.context.channel_id(), + epoch: [index; 32], + context_digest: h.context.context_digest(), + }; + let request = h.owner.pending.stage(epoch, source.clone(), manifest.clone()).unwrap(); + ids.push(request.provision().request_id()); + } + let before = h.owner.pending.usage(); + assert_eq!(h.owner.fetch(&h.context, w), Err(WitnessOwnerError::Capacity)); + assert_eq!(h.owner.pending.usage(), before); + assert_eq!(h.owner.fetches.usage(), (0, 0)); + assert!(ids.iter().all(|id| h.owner.pending.contains_request_id(*id))); + assert!(h.handler.get_and_clear_pending_msg().is_empty()); + h.owner.pending.complete(ids[0]); + assert_eq!(h.owner.fetch(&h.context, w), Ok(fetch::FetchProgress::Queued)); + assert_eq!(h.owner.pending.usage().0 + h.owner.fetches.usage().0, 64); + let messages = h.handler.get_and_clear_pending_msg(); + assert_eq!(messages.len(), 1); + assert!( + matches!(&messages[0].1, NodeCustomMessage::Ffor(frame) if frame.wire()[..2] == 55059u16.to_be_bytes()) + ); +} diff --git a/src/ffor/witness_store.rs b/src/ffor/witness_store.rs index 85d8a4c37b..3ea97f30cd 100644 --- a/src/ffor/witness_store.rs +++ b/src/ffor/witness_store.rs @@ -24,7 +24,7 @@ use std::sync::{Arc, Mutex}; use bitcoin::hashes::{sha256, Hash, HashEngine}; use lightning::ln::ffor::FFORWitnessReceipt; use lightning::util::persist::KVStoreSync; -use lightning_ffor::witness::{CheckedAcknowledgement, EncryptedRecord}; +use lightning_ffor::witness::{CheckedAcknowledgement, EncryptedRecord, SignedManifest}; use crate::types::DynStore; @@ -39,9 +39,7 @@ pub(super) use binding::WitnessStorageBinding; #[cfg(test)] use binding::WitnessStorageIdentity; use envelope::WrappingKey; -#[cfg(test)] -use record::WitnessKeyUseError; -pub(super) use record::{StoredWitnessEpoch, WitnessPolicy}; +pub(super) use record::{StoredWitnessEpoch, WitnessKeyUseError, WitnessPolicy}; const PRIMARY_NAMESPACE: &str = "ffor_witness"; const SECONDARY_NAMESPACE: &str = ""; @@ -64,6 +62,10 @@ pub(crate) enum WitnessStoreError { Entropy, Uncertain, Unreserved, + /// Only the supplied candidate failed authentication after retained storage was checked. + InvalidReceipt, + /// A valid candidate differs from the already durable core for this witness and slot. + ReceiptConflict, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -241,6 +243,28 @@ impl WitnessSecretStore { self.load_locked(&mut state, binding) } + /// Reload and confirm exact selected manifests with their full encrypted-receipt reservation. + /// Legacy material stays historical-only. This storage check grants no native release authority. + pub(super) fn provisioning_manifests( + &self, binding: &WitnessStorageBinding, + ) -> Result, WitnessStoreError> { + let record = self.load(binding)?; + if !matches!(record.receipt_allocation, ReceiptAllocation::Reserved(_)) { + return Err(WitnessStoreError::Unreserved); + } + record + .policies() + .iter() + .map(|policy| { + record + .manifest(policy.witness) + .cloned() + .map(|manifest| (policy.witness, manifest)) + .ok_or(WitnessStoreError::Corrupt) + }) + .collect() + } + /// Durably retain a correlated witness promise for the exact stored manifest. /// The caller owns real transport correlation and current native provisioning authority. /// Neither a returned record nor all historical promises authorize an offline invoice. @@ -359,7 +383,13 @@ impl WitnessSecretStore { ensure_certain(&state)?; let record = self.load_locked(&mut state, binding)?; let mut receipts = self.load_receipt_locked(&mut state, binding, &record)?; - record.decrypt_record(witness, receipt.clone()).map_err(|_| WitnessStoreError::Corrupt)?; + record.decrypt_record(witness, receipt.clone()).map_err(|error| match error { + WitnessKeyUseError::Record(_) | WitnessKeyUseError::Decryption(_) => { + WitnessStoreError::InvalidReceipt + }, + WitnessKeyUseError::UnknownWitness => WitnessStoreError::Binding, + _ => WitnessStoreError::Corrupt, + })?; let result = receipts.insert(witness, receipt)?; if result == ReceiptRetention::Stored { let bytes = self.receipt_key.seal_plaintext(binding, &receipts.encode())?; @@ -515,4 +545,4 @@ fn hash(parts: &[&[u8]]) -> [u8; 32] { } #[cfg(test)] -mod tests; +pub(in crate::ffor) mod tests; diff --git a/src/ffor/witness_store/receipt.rs b/src/ffor/witness_store/receipt.rs index fba22477b0..81efddb058 100644 --- a/src/ffor/witness_store/receipt.rs +++ b/src/ffor/witness_store/receipt.rs @@ -173,7 +173,7 @@ impl ReceiptBook { let core = canonical_core(record)?; match slot { Some(existing) if existing == &core => Ok(ReceiptRetention::AlreadyStored), - Some(_) => Err(WitnessStoreError::Conflict), + Some(_) => Err(WitnessStoreError::ReceiptConflict), None => { *slot = Some(core); Ok(ReceiptRetention::Stored) diff --git a/src/ffor/witness_store/receipt/tests.rs b/src/ffor/witness_store/receipt/tests.rs index 32ff3d383e..30a07ffea4 100644 --- a/src/ffor/witness_store/receipt/tests.rs +++ b/src/ffor/witness_store/receipt/tests.rs @@ -164,13 +164,13 @@ fn ffor_receipt_first_valid_evidence_survives_equivocation_and_later_errors() { assert!(secrets.decrypt_record(key(43), different.clone()).is_ok()); assert_eq!( store.retain_receipt(&binding, key(43), &different), - Err(WitnessStoreError::Conflict) + Err(WitnessStoreError::ReceiptConflict) ); - assert_eq!(store.retain_receipt(&binding, key(40), &first), Err(WitnessStoreError::Corrupt)); + assert_eq!(store.retain_receipt(&binding, key(40), &first), Err(WitnessStoreError::Binding)); let (_, _, other_epoch) = fixture(1); assert_eq!( store.retain_receipt(&binding, key(43), &other_epoch), - Err(WitnessStoreError::Corrupt) + Err(WitnessStoreError::InvalidReceipt) ); assert!(store.load_receipt(&binding, key(43), 0).is_err()); assert!(store.load_receipt(&binding, key(43), 2).is_err()); diff --git a/src/ffor/witness_store/record.rs b/src/ffor/witness_store/record.rs index 182dbbdb2e..29abc5623f 100644 --- a/src/ffor/witness_store/record.rs +++ b/src/ffor/witness_store/record.rs @@ -31,9 +31,9 @@ pub(crate) enum WitnessKeyUseError { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct WitnessPolicy { - pub(super) witness: PublicKey, - pub(super) retention_until: u32, - pub(super) minimum_receipts: u8, + pub(in crate::ffor) witness: PublicKey, + pub(in crate::ffor) retention_until: u32, + pub(in crate::ffor) minimum_receipts: u8, } struct WitnessKeys { diff --git a/src/ffor/witness_store/record/key_use.rs b/src/ffor/witness_store/record/key_use.rs index e305f9cfd5..762c087a7a 100644 --- a/src/ffor/witness_store/record/key_use.rs +++ b/src/ffor/witness_store/record/key_use.rs @@ -18,7 +18,7 @@ impl StoredWitnessEpoch { /// The runtime must correlate responses with the authenticated connection and must never resend /// an earlier request after ambiguous delivery. The witness still enforces nonce replay refusal. /// This operation neither sends a request nor establishes current native activation authority. - pub(in crate::ffor::witness_store) fn prepare_fetch( + pub(in crate::ffor) fn prepare_fetch( &self, witness: PublicKey, after_slot: Option, ) -> Result { self.prepare_fetch_with(witness, after_slot, random_bytes::<80>) diff --git a/src/ffor/witness_store/tests.rs b/src/ffor/witness_store/tests.rs index 9fce1f8959..c065015892 100644 --- a/src/ffor/witness_store/tests.rs +++ b/src/ffor/witness_store/tests.rs @@ -19,16 +19,16 @@ use crate::io::test_utils::InMemoryStore; use super::*; -pub(super) struct TestStore { - pub(super) inner: InMemoryStore, - pub(super) writes: AtomicUsize, - pub(super) write_failure: AtomicUsize, - pub(super) fail_at: AtomicUsize, - pub(super) read_failure: AtomicBool, +pub(in crate::ffor) struct TestStore { + pub(in crate::ffor) inner: InMemoryStore, + pub(in crate::ffor) writes: AtomicUsize, + pub(in crate::ffor) write_failure: AtomicUsize, + pub(in crate::ffor) fail_at: AtomicUsize, + pub(in crate::ffor) read_failure: AtomicBool, } impl TestStore { - pub(super) fn new() -> Arc { + pub(in crate::ffor) fn new() -> Arc { Arc::new(Self { inner: InMemoryStore::new(), writes: AtomicUsize::new(0), @@ -37,10 +37,10 @@ impl TestStore { read_failure: AtomicBool::new(false), }) } - pub(super) fn bytes(&self, key: &str) -> Vec { + pub(in crate::ffor) fn bytes(&self, key: &str) -> Vec { KVStoreSync::read(&self.inner, PRIMARY_NAMESPACE, SECONDARY_NAMESPACE, key).unwrap() } - pub(super) fn replace(&self, key: &str, bytes: Vec) { + pub(in crate::ffor) fn replace(&self, key: &str, bytes: Vec) { KVStoreSync::write(&self.inner, PRIMARY_NAMESPACE, SECONDARY_NAMESPACE, key, bytes) .unwrap(); } @@ -863,3 +863,41 @@ proptest! { prop_assert!(wrapping.open(&binding, &bytes).is_err()); } } + +#[test] +fn ffor_witness_owner_material_requires_confirmed_reserved_storage() { + let backend = TestStore::new(); + let binding = binding(1); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + let record = store.create(&binding, &policies()).unwrap(); + let expected: Vec<_> = record + .policies() + .iter() + .map(|policy| (policy.witness, record.manifest(policy.witness).unwrap().clone())) + .collect(); + assert_eq!(store.provisioning_manifests(&binding).unwrap(), expected); + drop(store); + let restored = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + backend.write_failure.store(2, Ordering::SeqCst); + assert_eq!(restored.provisioning_manifests(&binding), Err(WitnessStoreError::Storage)); + assert_eq!(restored.provisioning_manifests(&binding), Err(WitnessStoreError::Uncertain)); + restored.recover_write().unwrap(); + assert_eq!(restored.provisioning_manifests(&binding).unwrap(), expected); + KVStoreSync::remove(&backend.inner, receipt::NAMESPACE, "", &binding.storage_key(), false) + .unwrap(); + assert_eq!(restored.provisioning_manifests(&binding), Err(WitnessStoreError::Missing)); +} + +#[test] +fn ffor_witness_owner_material_refuses_historical_legacy_record() { + let backend = TestStore::new(); + let binding = binding(1); + let record = StoredWitnessEpoch::generate(&binding, &policies()).unwrap(); + backend.replace( + &binding.storage_key(), + WrappingKey::derive(&[5; 64]).seal(&binding, &record).unwrap(), + ); + let store = WitnessSecretStore::open(&[5; 64], backend.clone()).unwrap(); + assert_eq!(store.provisioning_manifests(&binding), Err(WitnessStoreError::Unreserved)); + assert!(KVStoreSync::list(&*backend, receipt::NAMESPACE, "").unwrap().is_empty()); +} From c38e9da2fe98ef031fa8b745c6fd5b38ff45af3f Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 21 Sep 2026 02:15:23 -0400 Subject: [PATCH 17/23] perf: retain witness receipt pages with one durable write --- FFOR.md | 10 ++-- src/ffor/witness_owner/fetch.rs | 32 ++++------- src/ffor/witness_owner/fetch/tests.rs | 43 +++++++++++++++ src/ffor/witness_store.rs | 76 ++++++++++++++++++++------- src/ffor/witness_store/receipt.rs | 26 ++++++++- 5 files changed, 140 insertions(+), 47 deletions(-) diff --git a/FFOR.md b/FFOR.md index 75b8c58982..91e06d4f19 100644 --- a/FFOR.md +++ b/FFOR.md @@ -232,9 +232,10 @@ later valid evidence from the same page. Once all such evidence is durable, it reports a rejected page and permits a fresh traversal from slot zero. Storage uncertainty or local corruption keeps the page and its cursor intact. A completed or empty traversal does not establish that any slot is unpaid, and this owner -emits no payment credit or native claim. Receipt retention currently rewrites the -fixed receipt book for each newly retained core; large-book recovery performance -still needs measurement before production scheduling is enabled. +emits no payment credit or native claim. Each authenticated page retains all valid +cores with one fixed-book write, and an exact retry performs no additional write. +This avoids revalidating and rewriting the complete book for every candidate. +Large-book recovery performance still needs measurement before production scheduling. Tests restore a genuinely funded and signed native Active manager and stock monitor through NodeBuilder with a public test seed. They cover registration persistence, @@ -242,7 +243,8 @@ all three sidecar write boundaries, failed and stale manager persistence tokens, exact queued retries, fresh timeout identities, current witness correlation, missing-sidecar refusal after reload, historical acknowledgements after channel removal, shared quotas, fetch backpressure, pagination, disconnect and storage -failures, rejected candidates followed by valid evidence and zero payment credit. +failures, rejected candidates followed by valid evidence, one write per page, +exact batch recovery and zero payment credit. Fixture provenance is in `src/ffor/witness_owner/fixtures/README.md`; test-only witness encryption uses ring against the retained public epoch key and never exports a protected private key. No builder or scheduler enables this owner yet. diff --git a/src/ffor/witness_owner/fetch.rs b/src/ffor/witness_owner/fetch.rs index 40a7a6ba50..d274c9c803 100644 --- a/src/ffor/witness_owner/fetch.rs +++ b/src/ffor/witness_owner/fetch.rs @@ -9,7 +9,7 @@ use lightning_ffor::witness::{ use super::pending::Epoch; use super::{WitnessOwner, WitnessOwnerError}; -use crate::ffor::witness_store::{WitnessStorageBinding, WitnessStoreError}; +use crate::ffor::witness_store::{ReceiptPageRetention, WitnessStorageBinding}; use crate::message_handler::ffor::{ ConnectionToken, FforReceiverTransport, OutboundError, ReceivedFforMessage, }; @@ -31,7 +31,7 @@ pub(crate) enum FetchProgress { enum FetchState { Request { pending: PendingFetch, queued: bool }, - Page { checked: CheckedFetchPage, next_record: usize, rejected: bool }, + Page { checked: CheckedFetchPage }, } struct FetchEntry { @@ -150,7 +150,7 @@ impl FetchRequests { // Validate the entire response's identity, manifest, signatures and pagination first. let checked = pending.check_response(&response, &source).map_err(WitnessOwnerError::Protocol)?; - entry.state = FetchState::Page { checked, next_record: 0, rejected: false }; + entry.state = FetchState::Page { checked }; Ok(index) } } @@ -261,25 +261,13 @@ impl WitnessOwner { &mut self, index: usize, binding: &WitnessStorageBinding, ) -> Result { let entry = &mut self.fetches.entries[index]; - if let FetchState::Page { checked, next_record, rejected } = &mut entry.state { - while *next_record < checked.records().len() { - // Store authenticates native AEAD/body terms before accepting each encrypted core. - match self.store.retain_receipt( - binding, - entry.source.node_id, - checked.records()[*next_record].record(), - ) { - Ok(_) => {}, - Err(WitnessStoreError::InvalidReceipt | WitnessStoreError::ReceiptConflict) => { - // Only candidate rejection is skippable. Local corruption or uncertainty keeps - // this page intact, including later records which may prove valid preimages. - *rejected = true; - }, - Err(error) => return Err(WitnessOwnerError::Storage(error)), - } - *next_record += 1; - } - if *rejected { + if let FetchState::Page { checked } = &mut entry.state { + // One bounded page write retains every valid core before any cursor advances. + let retained = self + .store + .retain_receipt_page(binding, entry.source.node_id, checked) + .map_err(WitnessOwnerError::Storage)?; + if retained == ReceiptPageRetention::Rejected { self.fetches.entries.remove(index); return Ok(FetchProgress::RejectedPage); } diff --git a/src/ffor/witness_owner/fetch/tests.rs b/src/ffor/witness_owner/fetch/tests.rs index 8778bb62b6..07eedb35bc 100644 --- a/src/ffor/witness_owner/fetch/tests.rs +++ b/src/ffor/witness_owner/fetch/tests.rs @@ -9,6 +9,7 @@ use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, CHACHA20_POLY1305}; use super::*; use crate::ffor::witness_owner::tests::Harness; +use crate::ffor::witness_store::WitnessStoreError; use crate::message_handler::NodeCustomMessage; fn secret(byte: u8) -> SecretKey { @@ -353,3 +354,45 @@ fn ffor_witness_fetch_backpressure_keeps_exact_unsent_request() { assert_eq!(h.owner.fetch(&h.context, witness), Ok(FetchProgress::AwaitingResponse)); assert!(outgoing(&h).is_empty()); } + +#[test] +fn ffor_witness_fetch_retains_a_whole_page_with_one_write_and_deduplicates_retry() { + let mut h = Harness::new(); + let first = start(&mut h); + let witness = h.policies[0].witness; + let records = vec![record(&h, 1, false, 1), record(&h, 2, false, 2)]; + let page = receive(&h, witness, &response(&first, records.clone(), None)); + let before = h.storage.writes.load(Ordering::SeqCst); + assert_eq!(h.owner.accept_fetch_page(&page), Ok(FetchProgress::Complete)); + assert_eq!(h.storage.writes.load(Ordering::SeqCst), before + 1); + assert!(retained(&h, 1).is_some() && retained(&h, 2).is_some()); + assert_eq!(h.owner.retry_fetch(&h.context, witness), Ok(FetchProgress::Queued)); + let retry = outgoing(&h).remove(0); + let repeated = receive(&h, witness, &response(&retry, records, None)); + assert_eq!(h.owner.accept_fetch_page(&repeated), Ok(FetchProgress::Complete)); + assert_eq!(h.storage.writes.load(Ordering::SeqCst), before + 1); +} + +#[test] +fn ffor_witness_fetch_uncertain_page_keeps_all_valid_evidence_until_exact_recovery() { + for failure in [1, 2] { + let mut h = Harness::new(); + let first = start(&mut h); + let witness = h.policies[0].witness; + let records = vec![record(&h, 1, false, 1), record(&h, 2, false, 2)]; + let page = receive(&h, witness, &response(&first, records, None)); + let before = h.storage.writes.load(Ordering::SeqCst); + h.storage.write_failure.store(failure, Ordering::SeqCst); + assert_eq!( + h.owner.accept_fetch_page(&page), + Err(WitnessOwnerError::Storage(WitnessStoreError::Storage)) + ); + assert_eq!(h.storage.writes.load(Ordering::SeqCst), before + 1); + assert_eq!(h.owner.fetches.usage().0, 1); + h.owner.recover_storage().unwrap(); + assert_eq!(h.storage.writes.load(Ordering::SeqCst), before + 2); + assert_eq!(h.owner.fetch(&h.context, witness), Ok(FetchProgress::Complete)); + assert_eq!(h.storage.writes.load(Ordering::SeqCst), before + 2); + assert!(retained(&h, 1).is_some() && retained(&h, 2).is_some()); + } +} diff --git a/src/ffor/witness_store.rs b/src/ffor/witness_store.rs index 3ea97f30cd..ecf40f0220 100644 --- a/src/ffor/witness_store.rs +++ b/src/ffor/witness_store.rs @@ -6,7 +6,7 @@ //! supplies record bindings. Live native registration, fetch response correlation, transport and //! native claim reconciliation and deletion are deliberately absent. Retained acknowledgements are historical promises, not current //! provisioning authority or invoice readiness. -//! A future native registration must distinguish a new epoch from a missing sidecar after restore; +//! The witness owner uses native registration to distinguish new epochs from missing sidecars; //! `load` never recreates secrets, and `create` is only for explicitly new registration material. //! //! Fresh material reserves the exact full receipt envelope before any write. Pending initialization @@ -24,7 +24,9 @@ use std::sync::{Arc, Mutex}; use bitcoin::hashes::{sha256, Hash, HashEngine}; use lightning::ln::ffor::FFORWitnessReceipt; use lightning::util::persist::KVStoreSync; -use lightning_ffor::witness::{CheckedAcknowledgement, EncryptedRecord, SignedManifest}; +use lightning_ffor::witness::{ + CheckedAcknowledgement, CheckedFetchPage, EncryptedRecord, SignedManifest, +}; use crate::types::DynStore; @@ -32,6 +34,7 @@ mod binding; mod envelope; mod receipt; mod record; +pub(super) use receipt::ReceiptPageRetention; use receipt::{ReceiptBook, ReceiptRetention}; use record::ReceiptAllocation; @@ -291,6 +294,12 @@ impl WitnessSecretStore { fn load_locked( &self, state: &mut StoreState, binding: &WitnessStorageBinding, ) -> Result { + self.load_with_receipts_locked(state, binding).map(|(record, _)| record) + } + + fn load_with_receipts_locked( + &self, state: &mut StoreState, binding: &WitnessStorageBinding, + ) -> Result<(StoredWitnessEpoch, Option), WitnessStoreError> { let key = binding.storage_key(); let expected = state.records.get(&key).ok_or(WitnessStoreError::Missing)?; let actual = read(&*self.storage, Namespace::Secrets, &key)?; @@ -301,16 +310,17 @@ impl WitnessSecretStore { if !state.confirmed.contains(&key) { self.confirm_write(state, Namespace::Secrets, key.clone(), actual)?; } - match record.receipt_allocation { + let receipts = match record.receipt_allocation { ReceiptAllocation::Pending(_) => { - self.finish_initialization(state, binding, &mut record)? + self.finish_initialization(state, binding, &mut record)?; + Some(self.load_receipt_locked(state, binding, &record)?) }, ReceiptAllocation::Reserved(_) => { - self.load_receipt_locked(state, binding, &record)?; + Some(self.load_receipt_locked(state, binding, &record)?) }, - ReceiptAllocation::Legacy => {}, - } - Ok(record) + ReceiptAllocation::Legacy => None, + }; + Ok((record, receipts)) } fn finish_initialization( @@ -381,16 +391,9 @@ impl WitnessSecretStore { ) -> Result { let mut state = self.state.lock().map_err(|_| WitnessStoreError::Uncertain)?; ensure_certain(&state)?; - let record = self.load_locked(&mut state, binding)?; - let mut receipts = self.load_receipt_locked(&mut state, binding, &record)?; - record.decrypt_record(witness, receipt.clone()).map_err(|error| match error { - WitnessKeyUseError::Record(_) | WitnessKeyUseError::Decryption(_) => { - WitnessStoreError::InvalidReceipt - }, - WitnessKeyUseError::UnknownWitness => WitnessStoreError::Binding, - _ => WitnessStoreError::Corrupt, - })?; - let result = receipts.insert(witness, receipt)?; + let (record, receipts) = self.load_with_receipts_locked(&mut state, binding)?; + let mut receipts = receipts.ok_or(WitnessStoreError::Unreserved)?; + let result = receipts.insert_authenticated(&record, witness, receipt)?; if result == ReceiptRetention::Stored { let bytes = self.receipt_key.seal_plaintext(binding, &receipts.encode())?; if bytes.len() != record.receipt_allocation.bytes() { @@ -401,6 +404,39 @@ impl WitnessSecretStore { Ok(result) } + /// Retain all valid records in one already bounded, correlated page with one book write. + /// Candidate-only rejection does not discard later evidence. A failed write retains its exact + /// encrypted candidate for recovery. The caller keeps its page after any failure. + pub(super) fn retain_receipt_page( + &self, binding: &WitnessStorageBinding, witness: bitcoin::secp256k1::PublicKey, + page: &CheckedFetchPage, + ) -> Result { + let mut state = self.state.lock().map_err(|_| WitnessStoreError::Uncertain)?; + ensure_certain(&state)?; + let (record, receipts) = self.load_with_receipts_locked(&mut state, binding)?; + let mut receipts = receipts.ok_or(WitnessStoreError::Unreserved)?; + let mut changed = false; + let mut rejected = false; + for candidate in page.records() { + match receipts.insert_authenticated(&record, witness, candidate.record()) { + Ok(ReceiptRetention::Stored) => changed = true, + Ok(ReceiptRetention::AlreadyStored) => {}, + Err(WitnessStoreError::InvalidReceipt | WitnessStoreError::ReceiptConflict) => { + rejected = true; + }, + Err(error) => return Err(error), + } + } + if changed { + let bytes = self.receipt_key.seal_plaintext(binding, &receipts.encode())?; + if bytes.len() != record.receipt_allocation.bytes() { + return Err(WitnessStoreError::Corrupt); + } + self.confirm_write(&mut state, Namespace::Receipts, binding.storage_key(), bytes)?; + } + Ok(if rejected { ReceiptPageRetention::Rejected } else { ReceiptPageRetention::Retained }) + } + /// Re-decrypts durable retained evidence for a future native reconciliation boundary. /// Absence is no evidence of an unpaid voucher. This does not claim channel settlement. pub(super) fn load_receipt( @@ -408,8 +444,8 @@ impl WitnessSecretStore { ) -> Result, WitnessStoreError> { let mut state = self.state.lock().map_err(|_| WitnessStoreError::Uncertain)?; ensure_certain(&state)?; - let record = self.load_locked(&mut state, binding)?; - let receipts = self.load_receipt_locked(&mut state, binding, &record)?; + let (record, receipts) = self.load_with_receipts_locked(&mut state, binding)?; + let receipts = receipts.ok_or(WitnessStoreError::Unreserved)?; receipts .get(witness, slot)? .map(|receipt| { diff --git a/src/ffor/witness_store/receipt.rs b/src/ffor/witness_store/receipt.rs index 81efddb058..196c94dcad 100644 --- a/src/ffor/witness_store/receipt.rs +++ b/src/ffor/witness_store/receipt.rs @@ -4,7 +4,10 @@ use bitcoin::secp256k1::PublicKey; use lightning_ffor::witness::{EncryptedRecord, CIPHERTEXT_LEN, RECORD_HEADER_LEN}; use zeroize::Zeroizing; -use super::{hash, StoredWitnessEpoch, WitnessStorageBinding, WitnessStoreError, MAX_WITNESSES}; +use super::{ + hash, StoredWitnessEpoch, WitnessKeyUseError, WitnessStorageBinding, WitnessStoreError, + MAX_WITNESSES, +}; pub(super) const NAMESPACE: &str = "ffor_witness_receipts"; pub(super) const MAX_RECEIPT_RECORD_BYTES: usize = 1024 * 1024; @@ -23,6 +26,14 @@ pub(crate) enum ReceiptRetention { AlreadyStored, } +/// All valid page evidence is durable. Rejected means at least one candidate was invalid or +/// differed from the already retained valid core. Neither variant conveys payment status. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ReceiptPageRetention { + Retained, + Rejected, +} + struct WitnessEntries { witness: PublicKey, manifest_digest: [u8; 32], @@ -156,6 +167,19 @@ impl ReceiptBook { self.witnesses.iter().all(|witness| witness.slots.iter().all(Option::is_none)) } + pub(super) fn insert_authenticated( + &mut self, secrets: &StoredWitnessEpoch, witness: PublicKey, record: &EncryptedRecord, + ) -> Result { + secrets.decrypt_record(witness, record.clone()).map_err(|error| match error { + WitnessKeyUseError::Record(_) | WitnessKeyUseError::Decryption(_) => { + WitnessStoreError::InvalidReceipt + }, + WitnessKeyUseError::UnknownWitness => WitnessStoreError::Binding, + _ => WitnessStoreError::Corrupt, + })?; + self.insert(witness, record) + } + pub(super) fn insert( &mut self, witness: PublicKey, record: &EncryptedRecord, ) -> Result { From 5c8e172bfccd47509962ef6434eba0e145512292 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 21 Sep 2026 02:49:56 -0400 Subject: [PATCH 18/23] feat: persist offline receive intent and recover witness receipts --- Cargo.lock | 30 +- Cargo.toml | 26 +- FFOR.md | 72 ++- src/ffor/mod.rs | 3 + src/ffor/request_store.rs | 258 ++++++++++ src/ffor/request_store/envelope.rs | 93 ++++ src/ffor/request_store/fixtures/README.md | 14 + .../request_store/fixtures/empty-manager.bin | Bin 0 -> 3699 bytes .../request_store/fixtures/empty-monitor.bin | Bin 0 -> 5121 bytes src/ffor/request_store/fixtures/fixture.txt | 22 + .../fixtures/pending-manager.bin | Bin 0 -> 4615 bytes .../fixtures/pending-monitor.bin | Bin 0 -> 5121 bytes src/ffor/request_store/native.rs | 110 ++++ src/ffor/request_store/record.rs | 285 +++++++++++ src/ffor/request_store/tests.rs | 482 ++++++++++++++++++ src/ffor/witness_owner.rs | 7 +- src/ffor/witness_owner/fetch.rs | 2 +- src/ffor/witness_owner/fetch/tests.rs | 4 +- src/ffor/witness_owner/recovery.rs | 51 ++ src/ffor/witness_owner/recovery/tests.rs | 204 ++++++++ src/ffor/witness_owner/tests.rs | 7 +- 21 files changed, 1630 insertions(+), 40 deletions(-) create mode 100644 src/ffor/request_store.rs create mode 100644 src/ffor/request_store/envelope.rs create mode 100644 src/ffor/request_store/fixtures/README.md create mode 100644 src/ffor/request_store/fixtures/empty-manager.bin create mode 100644 src/ffor/request_store/fixtures/empty-monitor.bin create mode 100644 src/ffor/request_store/fixtures/fixture.txt create mode 100644 src/ffor/request_store/fixtures/pending-manager.bin create mode 100644 src/ffor/request_store/fixtures/pending-monitor.bin create mode 100644 src/ffor/request_store/native.rs create mode 100644 src/ffor/request_store/record.rs create mode 100644 src/ffor/request_store/tests.rs create mode 100644 src/ffor/witness_owner/recovery.rs create mode 100644 src/ffor/witness_owner/recovery/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 6e43047760..e9d8fccb95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1995,7 +1995,7 @@ dependencies = [ [[package]] name = "lightning" version = "0.2.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" +source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" dependencies = [ "bech32", "bitcoin", @@ -2007,7 +2007,7 @@ dependencies = [ "lightning-macros 0.2.1", "lightning-types 0.3.5", "musig2", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac)", "regex", ] @@ -2027,7 +2027,7 @@ dependencies = [ [[package]] name = "lightning-background-processor" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" +source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" dependencies = [ "bitcoin", "bitcoin-io", @@ -2035,7 +2035,7 @@ dependencies = [ "lightning 0.2.5", "lightning-liquidity 0.2.3", "lightning-rapid-gossip-sync 0.2.3", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac)", ] [[package]] @@ -2054,7 +2054,7 @@ dependencies = [ [[package]] name = "lightning-block-sync" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" +source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" dependencies = [ "bitcoin", "chunked_transfer", @@ -2066,7 +2066,7 @@ dependencies = [ [[package]] name = "lightning-ffor" version = "0.1.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" +source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" dependencies = [ "bitcoin", ] @@ -2086,7 +2086,7 @@ dependencies = [ [[package]] name = "lightning-invoice" version = "0.34.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" +source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" dependencies = [ "bech32", "bitcoin", @@ -2112,7 +2112,7 @@ dependencies = [ [[package]] name = "lightning-liquidity" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" +source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" dependencies = [ "bitcoin", "chrono", @@ -2138,7 +2138,7 @@ dependencies = [ [[package]] name = "lightning-macros" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" +source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" dependencies = [ "proc-macro2", "quote", @@ -2159,7 +2159,7 @@ dependencies = [ [[package]] name = "lightning-net-tokio" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" +source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2180,7 +2180,7 @@ dependencies = [ [[package]] name = "lightning-persister" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" +source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2203,7 +2203,7 @@ dependencies = [ [[package]] name = "lightning-rapid-gossip-sync" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" +source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" dependencies = [ "bitcoin", "bitcoin-io", @@ -2228,7 +2228,7 @@ dependencies = [ [[package]] name = "lightning-transaction-sync" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" +source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" dependencies = [ "bitcoin", "electrum-client 0.24.1", @@ -2250,7 +2250,7 @@ dependencies = [ [[package]] name = "lightning-types" version = "0.3.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" +source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" dependencies = [ "bitcoin", ] @@ -2677,7 +2677,7 @@ dependencies = [ [[package]] name = "possiblyrandom" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=7ed8161b7e3ac375b639de5e4d55542cf936af17#7ed8161b7e3ac375b639de5e4d55542cf936af17" +source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" dependencies = [ "getrandom 0.2.17", ] diff --git a/Cargo.toml b/Cargo.toml index 088e1b79f4..950091b714 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,18 +34,18 @@ default = [] [dependencies] # Pin runtime dependencies directly so downstream consumers use the same native engine. -lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17" } -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17", features = ["std"] } -lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17" } -lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17" } -lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17" } -lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17" } -lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17", features = ["std"] } -lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17" } +lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac" } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac", features = ["std"] } +lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac" } +lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac" } +lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac" } +lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac" } +lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac", features = ["std"] } +lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac" } bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} @@ -81,7 +81,7 @@ prost = { version = "0.11.6", default-features = false} winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "7ed8161b7e3ac375b639de5e4d55542cf936af17", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac", features = ["std", "_test_utils"] } proptest = "1.0.0" ring = "0.17.14" regex = "1.5.6" diff --git a/FFOR.md b/FFOR.md index 91e06d4f19..4562452b4c 100644 --- a/FFOR.md +++ b/FFOR.md @@ -176,9 +176,8 @@ identity, 256-bit nonce and signature entropy, including retries after a lost re or restart. The witness still enforces replay refusal. Decryption authenticates the retained witness and manifest before native AEAD and body checks. These helpers do not correlate a response connection, change a channel or credit a payment. Receipt -retention preserves authenticated evidence for later native reconciliation. Witness -transport orchestration is described below; native monitor reconciliation remains -separate work. +retention preserves authenticated evidence for native reconciliation. Witness +transport and the private original-monitor recovery caller are described below. Run `cargo test --lib ffor_witness` for exact retries and restart, failed writes with readable bytes, corruption, wrong seeds and bindings, key separation, capacity, @@ -194,8 +193,8 @@ interoperability. ## Private witness owner -The concrete `ffor::witness_owner` composes the actual ChannelManager, protected -store and authenticated transport. Exclusive access serializes bounded transient +The concrete `ffor::witness_owner` composes the actual ChannelManager, ChainMonitor, +protected store and authenticated transport. Exclusive access serializes bounded transient work, with a combined 64-request and 8 MiB encoded-payload reservation across provisioning and fetches. Each fetch reserves its complete response, request and bounded pagination history before release. Fixed metadata has a separate count @@ -231,8 +230,8 @@ from local storage corruption. The owner rejects those candidates while retainin later valid evidence from the same page. Once all such evidence is durable, it reports a rejected page and permits a fresh traversal from slot zero. Storage uncertainty or local corruption keeps the page and its cursor intact. A completed -or empty traversal does not establish that any slot is unpaid, and this owner -emits no payment credit or native claim. Each authenticated page retains all valid +or empty traversal does not establish that any slot is unpaid. Fetching emits no +payment credit or native claim. Each authenticated page retains all valid cores with one fixed-book write, and an exact retry performs no additional write. This avoids revalidating and rewriting the complete book for every candidate. Large-book recovery performance still needs measurement before production scheduling. @@ -249,6 +248,65 @@ Fixture provenance is in `src/ffor/witness_owner/fixtures/README.md`; test-only witness encryption uses ring against the retained public epoch key and never exports a protected private key. No builder or scheduler enables this owner yet. +The separate receipt-recovery call first rejoins immutable native witness +registration and confirmed protected evidence. It captures an opaque snapshot from +the actual ChainMonitor, drops that monitor guard and asks the native manager to +import the authenticated preimage. Native rechecks the original funding output, +current monitor counter and exact retained voucher under its channel locks. +Historical recovery remains possible after deadline, disconnection and channel +removal. An absent receipt means only that this store has no evidence for that slot. +Missing or uncertain sidecar storage cannot reach the monitor. + +A submitted monitor update is reported as pending, even if Node's synchronous +MonitorUpdatingPersister has already completed its write. Normal monitor-event +processing and a fresh retry observe completion. Neither observation credits a +payment or authorizes an invoice. Tests use the actual Node persister and restore +its durable monitor updates; they never manufacture a monitor-completion signal. +They cover live and archive-only recovery, idempotence after restart, and uncertain +receipt writes before native import. Delayed and failed native monitor writes are +covered in the pinned channel engine. Authoritative settlement outcomes and the +production recovery scheduler remain unfinished. + +## Private durable receive requests + +The private `ffor::request_store` retains the original client request ID, fixed +positive amount, exact description bytes, selected channel and settlement peer, +and every native preparation parameter before allocating an epoch. It captures +the actual Node network, identity and manager. Local request IDs derive from the +chain, node identity and length-framed client ID; amount and description are not +part of that lookup key, so changed retry arguments are refused. + +Requests use a separate wallet-seed-derived wrapping key and storage namespace. +Authenticated envelopes bind the actual node, chain, schema and storage key. One +exclusive owner serializes admission and native binding. A failed or reopened +write requires a successful byte-identical confirmation before use, and uncertain +recovery accepts only its exact ciphertext or retained predecessor. Deletion or +replacement cannot reconstruct missing intent from a native selector. As with the +witness store, valid-backup rollback and independent concurrent owners are outside +the storage contract. + +Lookup precedes fresh liquidity selection. A native request without its application +record refuses recovery rather than inventing a description or allocating again. +Preparation uses the exact stored native parameters and a genuine retained-peer +connection, then rejoins the same application record before persisting the returned +opaque selector. Selector lookup alone does not certify intent or readiness. A +bound record with missing native history also refuses replacement. Detaching a +caller leaves its durable intent recoverable and does not imply cancellation. + +The initial one-slot schema reserves 4 KiB per request, including future native +binding, with at most 64 records and 256 KiB total. Unbound intents count toward +that limit; there is no eviction. This version has no invoice bytes, readiness flag, +cancellation outcome or completion ledger. Native epoch reuse and historical +retention policy are still required for repeated receives on one channel. + +Tests restore genuine empty and pending native channel fixtures through NodeBuilder. +They cover exact retries, native intent mismatches, wrong or stale connections, +missing application or native history, visible failed writes and restart, exact +binding recovery, identity substitution, corruption, replacement and quota refusal. +The bounded record codec also has arbitrary-byte property checks. Fixture provenance +is in `src/ffor/request_store/fixtures/README.md`. No production caller constructs +this owner yet. + ## Required runtime integration - Bind signed setup to the actual local identity, peer, chain and channel limits. diff --git a/src/ffor/mod.rs b/src/ffor/mod.rs index 9ec4e63ccc..d420fe0382 100644 --- a/src/ffor/mod.rs +++ b/src/ffor/mod.rs @@ -9,3 +9,6 @@ mod witness_store; #[allow(dead_code)] mod witness_owner; + +#[allow(dead_code)] +mod request_store; diff --git a/src/ffor/request_store.rs b/src/ffor/request_store.rs new file mode 100644 index 0000000000..54e78189fe --- /dev/null +++ b/src/ffor/request_store.rs @@ -0,0 +1,258 @@ +//! Durable application intent, without invoice, activation or payment authority. +//! +//! One exclusive owner retains exact arguments before native preparation. Native alone owns the +//! epoch and lifecycle. No builder constructs this store yet. The 64-record bound matches the +//! current native archive and includes unbound intents; neither this store nor native currently +//! retires historical requests. Each intent reserves 4 KiB, for a maximum of 256 KiB. This schema +//! contains no invoice bytes, readiness flag, cancellation outcome or payment-completion ledger. +//! Repeated receives on the same channel still require native epoch reuse and retention policy. +//! +//! KVStore success is the durability contract. A failed write blocks this owner until its exact +//! ciphertext is successfully rewritten. Reopened records require the same confirmation before +//! use. AEAD detects corruption and identity substitution, but not rollback of valid backups. +//! Multiple owners or external namespace writers are unsupported. Missing retained data is never +//! reconstructed from native selectors, which do not contain the application's description. +//! Exact intent comparison preserves client-ID and description bytes without normalization. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use bitcoin::hashes::{sha256, Hash, HashEngine}; +use bitcoin::secp256k1::PublicKey; +use lightning::ln::ffor::FFORReceiverError; +use lightning::util::persist::KVStoreSync; + +use crate::types::{ChannelManager, DynStore}; + +mod envelope; +mod native; +mod record; +use envelope::EnvelopeKey; +pub(super) use record::{RequestIntent, RequestPlan, StoredRequest}; + +const NAMESPACE: &str = "ffor_requests"; +const VERSION: u16 = 1; +const MAX_REQUESTS: usize = 64; +// Charged in full at creation, including room for a native selector and AEAD metadata. This +// schema contains no invoice bytes; a future invoice-bearing schema must explicitly upgrade. +const MAX_RECORD_BYTES: usize = 4096; +const MAX_STORE_BYTES: usize = MAX_REQUESTS * MAX_RECORD_BYTES; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum RequestStoreError { + InvalidIntent, + Conflict, + Missing, + MissingNative, + Corrupt, + Identity, + Capacity, + Entropy, + Storage, + Uncertain, + Native(FFORReceiverError), +} + +struct PendingWrite { + key: String, + bytes: Vec, + previous: Option>, +} + +/// Exclusive storage owner. Mutable methods serialize admission, recovery and binding. +pub(super) struct RequestStore { + manager: Arc, + storage: Arc, + envelope: EnvelopeKey, + chain: [u8; 32], + node: PublicKey, + records: BTreeMap>, + confirmed: BTreeSet, + uncertain: Option, +} + +impl RequestStore { + fn open_bound( + seed: &[u8; 64], chain: [u8; 32], node: PublicKey, manager: Arc, + storage: Arc, + ) -> Result { + let envelope = EnvelopeKey::derive(seed, chain, node); + let keys = + KVStoreSync::list(&*storage, NAMESPACE, "").map_err(|_| RequestStoreError::Storage)?; + if keys.len() > MAX_REQUESTS { + return Err(RequestStoreError::Capacity); + } + let mut records = BTreeMap::new(); + for key in keys { + if key.len() != 64 + || !key.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(RequestStoreError::Corrupt); + } + let bytes = read(&*storage, &key)?; + let record = envelope.open(&key, &bytes)?; + if hex(&record.local_request_id()) != key + || record.local_request_id() != local_id(chain, node, record.intent().client_id()) + || record.plan().settlement == node + || records.insert(key, bytes).is_some() + { + return Err(RequestStoreError::Corrupt); + } + } + Ok(Self { + manager, + storage, + envelope, + chain, + node, + records, + confirmed: BTreeSet::new(), + uncertain: None, + }) + } + + pub(super) fn local_request_id(&self, client_id: &str) -> Result<[u8; 32], RequestStoreError> { + record::validate_client_id(client_id)?; + Ok(local_id(self.chain, self.node, client_id)) + } + + /// Call before selecting new liquidity. A native allocation with missing application data + /// refuses here, before the caller can interpret absence as a new request. + pub(super) fn lookup( + &mut self, client_id: &str, + ) -> Result, RequestStoreError> { + self.ensure_certain()?; + let local_request_id = self.local_request_id(client_id)?; + let key = hex(&local_request_id); + if self.records.contains_key(&key) { + return self.load_key(&key).map(Some); + } + match read(&*self.storage, &key) { + Err(RequestStoreError::Missing) => { + if self + .manager + .find_ffor_receiver_request(local_request_id) + .map_err(RequestStoreError::Native)? + .is_some() + { + Err(RequestStoreError::Missing) + } else { + Ok(None) + } + }, + Ok(_) => Err(RequestStoreError::Conflict), + Err(error) => Err(error), + } + } + + /// Restored records are authenticated and confirmed individually before being returned. + pub(super) fn list(&mut self) -> Result, RequestStoreError> { + self.ensure_certain()?; + let keys: Vec<_> = self.records.keys().cloned().collect(); + keys.iter().map(|key| self.load_key(key)).collect() + } + + fn load_key(&mut self, key: &str) -> Result { + let expected = self.records.get(key).ok_or(RequestStoreError::Missing)?; + let actual = read(&*self.storage, key)?; + if &actual != expected { + return Err(RequestStoreError::Conflict); + } + let record = self.envelope.open(key, &actual)?; + if hex(&record.local_request_id()) != key + || record.local_request_id() + != local_id(self.chain, self.node, record.intent().client_id()) + || record.plan().settlement == self.node + { + return Err(RequestStoreError::Corrupt); + } + if !self.confirmed.contains(key) { + self.confirm_write(key.to_owned(), actual)?; + } + Ok(record) + } + + fn write_record(&mut self, record: &StoredRequest) -> Result<(), RequestStoreError> { + let key = hex(&record.local_request_id()); + let bytes = self.envelope.seal(&key, record)?; + if !self.records.contains_key(&key) + && (self.records.len() + 1) + .checked_mul(MAX_RECORD_BYTES) + .filter(|bytes| *bytes <= MAX_STORE_BYTES) + .is_none() + { + return Err(RequestStoreError::Capacity); + } + self.confirm_write(key, bytes) + } + + fn confirm_write(&mut self, key: String, bytes: Vec) -> Result<(), RequestStoreError> { + let previous = self + .uncertain + .as_ref() + .and_then(|pending| pending.previous.clone()) + .or_else(|| self.records.get(&key).cloned()); + self.uncertain = Some(PendingWrite { key: key.clone(), bytes: bytes.clone(), previous }); + KVStoreSync::write(&*self.storage, NAMESPACE, "", &key, bytes.clone()) + .map_err(|_| RequestStoreError::Storage)?; + self.records.insert(key.clone(), bytes); + self.confirmed.insert(key); + self.uncertain = None; + Ok(()) + } + + /// Visibility is insufficient: only a successful exact retry resolves uncertain durability. + pub(super) fn recover_write(&mut self) -> Result<(), RequestStoreError> { + let Some(pending) = &self.uncertain else { + return Ok(()); + }; + match read(&*self.storage, &pending.key) { + Ok(bytes) if bytes == pending.bytes || pending.previous.as_ref() == Some(&bytes) => {}, + Ok(_) => return Err(RequestStoreError::Conflict), + Err(RequestStoreError::Missing) if pending.previous.is_none() => {}, + Err(error) => return Err(error), + } + self.confirm_write(pending.key.clone(), pending.bytes.clone()) + } + + fn ensure_certain(&self) -> Result<(), RequestStoreError> { + if self.uncertain.is_some() { + Err(RequestStoreError::Uncertain) + } else { + Ok(()) + } + } +} + +fn read(storage: &DynStore, key: &str) -> Result, RequestStoreError> { + KVStoreSync::read(storage, NAMESPACE, "", key).map_err(|error| { + if error.kind() == lightning::io::ErrorKind::NotFound { + RequestStoreError::Missing + } else { + RequestStoreError::Storage + } + }) +} + +fn local_id(chain: [u8; 32], node: PublicKey, client_id: &str) -> [u8; 32] { + let mut engine = sha256::Hash::engine(); + engine.input(b"ldk-node/ffor/request-id/v1"); + engine.input(&chain); + engine.input(&node.serialize()); + engine.input(&(client_id.len() as u16).to_be_bytes()); + engine.input(client_id.as_bytes()); + sha256::Hash::from_engine(engine).to_byte_array() +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8] = b"0123456789abcdef"; + let mut result = String::with_capacity(bytes.len() * 2); + for byte in bytes { + result.push(DIGITS[(byte >> 4) as usize] as char); + result.push(DIGITS[(byte & 15) as usize] as char); + } + result +} + +#[cfg(test)] +mod tests; diff --git a/src/ffor/request_store/envelope.rs b/src/ffor/request_store/envelope.rs new file mode 100644 index 0000000000..b959f9c3df --- /dev/null +++ b/src/ffor/request_store/envelope.rs @@ -0,0 +1,93 @@ +use bitcoin::hashes::{sha256, Hash, HashEngine, Hmac, HmacEngine}; +use bitcoin::secp256k1::PublicKey; +use prost::Message; +use rand::rngs::OsRng; +use rand::TryRngCore; +use vss_client::types::Storable; +use vss_client::util::storable_builder::{EntropySource, StorableBuilder}; +use zeroize::Zeroizing; + +use super::{RequestStoreError, StoredRequest, MAX_RECORD_BYTES, VERSION}; + +pub(super) struct EnvelopeKey { + key: Zeroizing<[u8; 32]>, + identity: Vec, +} + +impl EnvelopeKey { + pub(super) fn derive(seed: &[u8; 64], chain: [u8; 32], node: PublicKey) -> Self { + let mut extract = HmacEngine::::new(b"ldk-node/ffor/request-storage/v1"); + extract.input(seed); + let prk = Zeroizing::new(Hmac::from_engine(extract).to_byte_array()); + let mut expand = HmacEngine::::new(prk.as_ref()); + expand.input(b"wrapping-key"); + expand.input(&[1]); + Self { + key: Zeroizing::new(Hmac::from_engine(expand).to_byte_array()), + identity: [chain.as_slice(), &node.serialize()].concat(), + } + } + + pub(super) fn seal( + &self, key: &str, record: &StoredRequest, + ) -> Result, RequestStoreError> { + let mut nonce = [0; 12]; + OsRng.try_fill_bytes(&mut nonce).map_err(|_| RequestStoreError::Entropy)?; + let plaintext = record.encode(); + // Owned parsing buffers are zeroized. StorableBuilder's internal plaintext allocation + // is not zeroized by that dependency; this makes no complete-erasure guarantee. + let storable = StorableBuilder::new(Nonce(nonce)).build( + plaintext.to_vec(), + i64::from(VERSION), + &self.key, + &self.aad(key), + ); + let bytes = storable.encode_to_vec(); + check_size(&bytes)?; + Ok(bytes) + } + + pub(super) fn open(&self, key: &str, bytes: &[u8]) -> Result { + check_size(bytes)?; + let sealed = Storable::decode(bytes).map_err(|_| RequestStoreError::Corrupt)?; + if sealed.encode_to_vec() != bytes + || sealed.encryption_metadata.as_ref().map(|metadata| metadata.cipher_format.as_str()) + != Some("ChaCha20Poly1305") + { + return Err(RequestStoreError::Corrupt); + } + let (plaintext, version) = StorableBuilder::new(Nonce([0; 12])) + .deconstruct(sealed, &self.key, &self.aad(key)) + .map_err(|_| RequestStoreError::Corrupt)?; + let plaintext = Zeroizing::new(plaintext); + if version != i64::from(VERSION) { + return Err(RequestStoreError::Corrupt); + } + StoredRequest::decode(&plaintext) + } + + fn aad(&self, key: &str) -> Vec { + [ + b"ldk-node/ffor/request-envelope/v1".as_slice(), + &VERSION.to_be_bytes(), + &self.identity, + key.as_bytes(), + ] + .concat() + } +} + +fn check_size(bytes: &[u8]) -> Result<(), RequestStoreError> { + if bytes.is_empty() || bytes.len() > MAX_RECORD_BYTES { + Err(RequestStoreError::Capacity) + } else { + Ok(()) + } +} + +struct Nonce([u8; 12]); +impl EntropySource for Nonce { + fn fill_bytes(&self, buffer: &mut [u8]) { + buffer.copy_from_slice(&self.0); + } +} diff --git a/src/ffor/request_store/fixtures/README.md b/src/ffor/request_store/fixtures/README.md new file mode 100644 index 0000000000..093bc15032 --- /dev/null +++ b/src/ffor/request_store/fixtures/README.md @@ -0,0 +1,14 @@ +These are public deterministic test fixtures, not wallet backups. + +The native `ffor_export_node_request_fixture` test generated them in a real funded Testnet +channel using wallet seed `[91; 64]`, with the receiver signer seed derived through Bitcoin +BIP32 master private-key derivation, matching `NodeBuilder`. `fixture.txt` records the exact +identity, channel and setup parameters. The exporter is published in native revision +`0def88713b26264ab50c2451ba97a17839f331ac`, whose source base was `7ed8161b7`. Its output directory is +selected only with `FFOR_NODE_REQUEST_FIXTURE_DIR`; ordinary tests write no fixture files. + +The empty pair precedes any FFOR admission. The pending pair retains an actual signed native +Init and interception gate, before any Init leaves. `request-fixture` maps to the listed +local request ID through the Node domain-separated request-ID derivation. The Node tests +restore both pairs through the actual `NodeBuilder`, and use the public native exact-intent +retry to obtain opaque selectors. No fixture constructs a readiness or payment capability. diff --git a/src/ffor/request_store/fixtures/empty-manager.bin b/src/ffor/request_store/fixtures/empty-manager.bin new file mode 100644 index 0000000000000000000000000000000000000000..d3ab4c16a00f4fb81b13f9c88de063ca3debaf30 GIT binary patch literal 3699 zcmZQ%boQ*j{zGkQA;*`+{5Q`XUbm}qxO|FZ2#?pYr5w{GFNu96veZR|XfoSx_ zsd{s+Btf!+#L?hoFImB}Ku^C7Tg z*jTq>s4AIm3qa42sNejkPxPc5k_P;+p8})EhT`KMenN)1_>tr*?YI zm+*Oq)tMBT*f`&Q>E7^syU9gU9*<>_)(^aO1FL(pzfU{1jN{*R-`gyTOv_FvCVdM& zmE*_x>bG`B-WgNn9Xl9a6jq*^Q+R>FX^sY)BJ<+LFPAPv-Q`mCkrtKz#29F@RL;zZ z$0Rdfb;b<4chx^R6qyB|m0nPo9#6jpgOlOiT-kv}nf-5SPTm#2GQ{LQAx}t#rKSRW|7v87+K!B?C#4^z1F3aowexBsGd*UbH!FEA`>{FobNwU zc4g@=ncKYFe$sb=^NL&nDVMmN1Q&JWAD-cMUC&hS;sulT*P zZ~eX3@1y5m=k@l@WaI!UXMVx1M7X)m&AV|-xRfd%(dDyQgujla$=++oc!+0)A6p_M!% zqpl+JqClaMg>VpD+7L%sXd3SNe5k_AY%zruL`i6Lph9mopVV zoFaW==Y;8|>x5O7?Y1;qzB*q>bFQi}BZDa;gS{ftl3D9-UM_q#(Tj1W|A_^Sbz5$3 zj__Y+Y|-HK^@{R~^hPu{!nlkCTXhT^4IDE%xVi!rVR~RR$m>|V&#<472^jJ~%&N$= zYF_4+uBlUZOlMp0#4GJ+Q2wui&T7rJM~~_ZdC&7l9cNc~%6o#t^VXiZ=AX+qGi=v% zcQl&hd-Y=4;hUHD)|UO%y~)YIt?()$;Pn-$pI*NUlp23rS!uw>$1d8lsBzQz8}l`f zU%Zmeub_8bfA^$g(^h&o=c>Lbdn_2F^;+c31&O`e!)&j~ml{11RwnFG22oaEGGJl= E04Tz8xBvhE literal 0 HcmV?d00001 diff --git a/src/ffor/request_store/fixtures/empty-monitor.bin b/src/ffor/request_store/fixtures/empty-monitor.bin new file mode 100644 index 0000000000000000000000000000000000000000..567667d409a8d7cda8883e9422c6f68069c34107 GIT binary patch literal 5121 zcmZQ%WPpMfyZ1C*vS1Kn5aDLO9WNR7vD;ZsZql6J|F#_NSD!7d z7g_o-!pGBOX=%+OV>csXbCwB9lkDvSK$_=1Y)hZM zKY6bY_Y^_yE3%ijZedHAoe^1lg@gU?JfI;!=jkwVshsn_KjpO<_jeo9u7k2U@l(p5 z?wz#saF6`nhc=THtt%A0RwcNf==#~I`Hg2y+vfVb)4TpGtC=q%sgu@Wwj1s)F9t>C zn@rnxY<6}iyR=E*rO@#-SC;O=N1anwT~c3?x-8~wV;PeoGvmvp`jc!#D;@87l}&oa z_&p+xGxN^Z)wk0d>;AQzdGwxziD3eRdypdYA-NWrJ=`9WwQM|v55*3){^B~+xc$Pa zNR_XqqAupAtQDEx8f$In?cQ?p#5K{`sW)!?ei;7krc2pOPwn)aFX8hJs|T$EJC3n! z!GADd(u~u6Ja6-zLmm1(htlp>G_BZn>9E#<_L*O1XIibbe$)r|3r@xaoH8&8c-nvo z;Gjp zbMD*qi4PhWwmO?ws3x`j2n$AIgS%e)|;UAmRmub;nC&Zfxp z`To(|^aD!U-)1+lURY2*>14#;&6+2Q8MVcK)!1zF`^}-qd{XB|w%*3izSqj0-V(9B z-hOK9%bE=vtS|KynA-o6FJa_TWLh$7{msjT&n9{?&h$UAps{Yt&CL=1>x?ZLoW5RB zev#hD!vb{pUp@v%y9h#}`ya+%#d?#af5yiYi?y>{&X->ne_BY+nj-OwMnwCHL zZVOb$1yc*98Hs3nf|Nqacp_S%jEuP2*ngR<8U8YU_{;c~0T>w&pD+TOkWBEXVS%!q zZD0fYnq$Ly2ErjUs3P<)Bimm_W`_GrpfDpUZ1?Pb(ZJ#btwVtUtnT$GHdOCIm_VCF z(Dd6`KGVbairZ_TFkb)`s3J6~+HdwgO2?b**BayZT0@#@w&rfli&jjMvT zee-kT-J9(&2NKIbQ_oqxcsxs~roz2+sdMT7iup^Yo;rGOyW^pk-DeqN%>z3&JcBDe zHp`~ljLke_QEa(sQQv%X-*VT;_6Byf)!Q2$3Eg}&!QxVr0u#Xu$HTzKz|ZU;z`*37 zs!{Po>{Fk;sOOhAb7EPPX5V?X;l(#4rHcHTSN!Y1~W}Mg}&J>;5t>X8@%Lg1reQMS@)}7Dc9IClr&u1)s|C<9zj7 zyCd(6sq&5;3@-{RPt7U3z~D4TgH4fnapRXu7ozTRsrpEZ%70=EG+8QVX2fHXnXfuy zhTXgBpTI0G_^kAT!Yn^a6UDYycW1?Xj)AYay>t=c=ErgIeAz7$`F&c zF~*l>X=!JRT(`G0Y!&`owc1JGdC^Ru+X;2>fNm$$!2`LSLy2%I=3?Lm#WXJ@`vN<0 zpx7b;{AEdCW?*GtXJp`1WU8Me+WKh?Pffw&M1i$${`Oz=?wYxO^96=Q&41RI_MhwE zW#9*9DnV0Xx`uxtmOtA7q31XVD-iO9I3t53(0pkkyeo^Z6nnM-R*KDWKrY1yb!dU{ z&V4+4Vd-|A2aUx}H)GWNq~y-`-ECf>CV8`TRa@TeNio29Uly>RdAjTe@tkT$C3j1{ z@8Yd?-B;Q-vD`VM|A@VNdk`?*|10lMwVu@GQTRa9>VK(SdBgmBFSgp=+@z!=aeva4 zAbk!+rWR4%@Chv)&(dBL6=rGVZ7bM(A!py~&!3*GHRWS`6Bo@jSW7WNBQ!{**u*c3 zmfBe`_cE;8F+*zY!*(%m@4bqVd1+n~Exk`oTFe6}#RxSy;H8-SU&d9V<|vmd4iI z8MYW$I3T7mV;)F8p2&lE`~+f5M9>#DqFa)exxxQP^)kv)ElTT$E#H+B0*{6GMzs=E zrP)~CmGNA-J7+v%7bKDqSrYDM7Lvc>luxI1y+PwHujf_nIkZok_?BC2^}B!`(50x} z@t?l-Ws0k%5wDkR9k-3|xEO#jSsR^<*kS>G@7MkJk%CQ?0+bVfhVN>2*IRq1m*1>a zJ=7BYqd9mpeVdcBs>xdY+Sq#m#5jWrFMhROOTIo;y*<$5&JMn0)6rKj> zO96`Ow`H6^kWwyeXrmq5)Y0*_d)=_mEjrU1tl|amny?&f604~R8 z6HweK&S0~^uZwKD%#b$Y6=d1v8_ly(vkQ$d&B*xVS=?(Piqq=dE}&;EpKpim?(520 zKj5?Bq~Kirs_eX4uJ*SvF>we2Qt=QFD!!N3USpK>!6nL)>0c4l*xhIb{4x(A8pOVY8N6Em1VBZ(ajgAkF~D`EyAa;MSJ;0 z&1QL<3fBM3iNxB}ZAX4ZrL&bN3Iq{=OH$~57wG)k%E0zo> zIA_u+rBs>GDgfdSoCC< z{ZhlV+FuiNZ^zeXzHhE&e;8#LrtFhGB1}2 zKyKo@66T>>-03peUmxz!6jfE?qt2s4+qs3cNtaEg$(8gsS4BboP1^5-JC!%dz!ZOz zTqvR`UKH7w#8bRXa-oQ(cu{0y5>N3m$%P{N-|-^Fm4Sqqa z^@Ua5iNwLB-m$}~UcXYMy#TwNjzQaSqiKfc}2gyVn4OW%{dnzfqZ?koQ3a(*c)i9J|)_sR25 zPqwRjE(>pfGLud~WEKV|w=kIHRGG&FTsR#M?4%Ix*cagXo2E#S&XRUfu{Syg0Qww?_^ey zcp89a_GR&#z1;e)RqCVuI#F-_nXS} ze?tcJ6J|F#_NSD!7d z7g_o-!pGBOX=%+OV>csXbCwB9lkDvSK$_=1Y)hZM zKY6bY_Y^_yE3%ijZedHAoe^1lg@gU?JfI;!=jkwVshsn_KjpO<_jeo9u7k2U@l(p5 z?wz#saF6`nhc=THtt%A0RwcNf==#~I`Hg2y+vfVb)4TpGtC=q%sgu@Wwj1s)F9t>C zn@rnxY<6}iyR=E*rO@#-SC;O=N1anwT~c3?x-8~wV;PeoGvmvp`jc!#D;@87l}&oa z_&p+xGxN^Z)wk0d>;AQzdGwxziD3eRdypdYA-NWrJ=`9WwQM|v55*3){^B~+xc$Pa zNR_XqqAupAtQDEx8f$In?cQ?p#5K{`sW)!?ei;7krc2pOPwn)aFX8hJs|T$EJC3n! z!GADd(u~u6Ja6-zLmm1(htlp>G_BZn>9E#<_L*O1XIibbe$)r|3r@xaoH8&8c-nvo z;Gjp zbMD*qi4PhWwmO?ws3x`j2n$AIgS%e)|;UAmRmub;nC&Zfxp z`To(|^aD!U-)1+lURY2*>14#;&6+2Q8MVcK)!1zF`^}-qd{XB|w%*3izSqj0-V(9B z-hOK9%bE=vtS|KynA-o6FJa_TWLh$7{msjT&n9{?&h$UAps{Yt&CL=1>x?ZLoW5RB zev#hD!vb{pUp@v%y9h#}`ya+%#d?#af5yiYi?y>{&X->ne_BY+nj-OwMnwCHL zZVOb$1yc*98Hs3nf|Nqacp_S%jEuP2*ngR<8U8YU_{;c~0T>w&pD+TOkWBEXVS%!q zZD0fYnq$Ly2ErjUs3P<)Bimm_W`_GrpfDpUZ1?Pb(ZJ#btwVtUtnT$GHdOCIm_VCF z(Dd6`KGVbairZ_TFkb)`s3J6~+HdwgO2?b**BayZT0@#@w&rfli&jjMvT zee-kT-J9(&2NKIbQ_oqxcsxs~roz2+sdMT7iup^Yo;rGOyW^pk-DeqN%>z3&JcBDe zHp`~ljLke_QEa(sQQv%X-*VT;_6Byf)!Q2$3Eg}&!QxVr0u#Xu$HTzKz|ZU;z`*37 zs!{Po>{Fk;sOOhAb7EPPX5V?X;l(#4rHcHTSN!Y1~W}Mg}&J>;5t>X8@%Lg1reQMS@)}7Dc9IClr&u1)s|C<9zj7 zyCd(6sq&5;3@-{RPt7U3z~D4TgH4fnapRXu7ozTRsrpEZ%70=EG+8QVX2fHXnXfuy zhTXgBpTI0G_^kAT!Yn^a6UDYycW1?Xj)AYay>t=c=ErgIeAz7$`F&c zF~*l>X=!JRT(`G0Y!&`owc1JGdC^Ru+X;2>fNm$$!2`LSLy2%I=3?Lm#WXJ@`vN<0 zpx7b;{AEdCW?*GtXJp`1WU8Me+WKh?Pffw&M1i$${`Oz=?wYxO^96=Q&41RI_MhwE zW#9*9DnV0Xx`uxtmOtA7q31XVD-iO9I3t53(0pkkyeo^Z6nnM-R*KDWKrY1yb!dU{ z&V4+4Vd-|A2aUx}H)GWNq~y-`-ECf>CV8`TRa@TeNio29Uly>RdAjTe@tkT$C3j1{ z@8Yd?-B;Q-vD`VM|A@VNdk`?*|10lMwVu@GQTRa9>VK(SdBgmBFSgp=+@z!=aeva4 zAbk!+rWR4%@Chv)&(dBL6=rGVZ7bM(A!py~&!3*GHRWS`6Bo@jSW7WNBQ!{**u*c3 zmfBe`_cE;8F+*zY!*(%m@4bqVd1+n~Exk`oTFe6}#RxSy;H8-SU&d9V<, + ) -> Result { + Self::open_bound( + seed, + ChainHash::using_genesis_block(node.config.network).to_bytes(), + node.node_id(), + Arc::clone(&node.channel_manager), + storage, + ) + } + + /// Persist immutable arguments before native preparation. Existing native allocation without + /// its application record is a refusal, not permission to reconstruct a missing description. + pub(in crate::ffor) fn begin( + &mut self, intent: RequestIntent, plan: RequestPlan, + ) -> Result { + let previous = self.lookup(intent.client_id())?; + let candidate = StoredRequest::new(intent, plan)?; + if candidate.local_request_id() != self.local_request_id(candidate.intent().client_id())? + || candidate.plan().settlement == self.node + { + return Err(RequestStoreError::InvalidIntent); + } + if let Some(previous) = previous { + if !previous.same_request(&candidate) { + return Err(RequestStoreError::Conflict); + } + return Ok(previous); + } + if self + .manager + .find_ffor_receiver_request(candidate.local_request_id()) + .map_err(RequestStoreError::Native)? + .is_some() + { + return Err(RequestStoreError::Missing); + } + self.write_record(&candidate)?; + Ok(candidate) + } + + /// Retry native preparation with the exact stored terms on a genuine current S connection. + /// Native revalidates intent even when recovering an earlier allocation. No Init is released. + /// The caller must not advance native until this binding write succeeds. A coroutine detaching + /// does not cancel native work; its durable intent remains recoverable through this same method. + pub(in crate::ffor) fn prepare_native( + &mut self, client_id: &str, connection: &FFORPeerConnection, + ) -> Result { + let before = self.lookup(client_id)?.ok_or(RequestStoreError::Missing)?; + if connection.peer_node_id() != before.plan().settlement { + return Err(RequestStoreError::Identity); + } + let found = self + .manager + .find_ffor_receiver_request(before.local_request_id()) + .map_err(RequestStoreError::Native)?; + if let Some(retained) = before.selector() { + let actual = found.as_ref().ok_or(RequestStoreError::MissingNative)?; + if !retained.matches(actual) { + return Err(RequestStoreError::Conflict); + } + } + if found.as_ref().is_some_and(|id| id.channel_id() != before.plan().channel) { + return Err(RequestStoreError::Conflict); + } + // There is no store mutex or I/O under native locks. &mut self excludes another store + // operation, and the re-read below also catches unsupported external namespace replacement. + let actual = self + .manager + .prepare_ffor_receiver( + &before.plan().channel, + connection, + before.plan().parameters.clone(), + ) + .map_err(RequestStoreError::Native)?; + if found.is_some_and(|previous| previous != actual) + || self + .manager + .find_ffor_receiver_request(before.local_request_id()) + .map_err(RequestStoreError::Native)? + != Some(actual) + { + return Err(RequestStoreError::Conflict); + } + let mut current = self.lookup(client_id)?.ok_or(RequestStoreError::Missing)?; + if current.encode() != before.encode() { + return Err(RequestStoreError::Conflict); + } + if current.bind(&actual)? { + self.write_record(¤t)?; + } + Ok(actual) + } +} diff --git a/src/ffor/request_store/record.rs b/src/ffor/request_store/record.rs new file mode 100644 index 0000000000..5525da8f33 --- /dev/null +++ b/src/ffor/request_store/record.rs @@ -0,0 +1,285 @@ +use std::fmt; + +use bitcoin::secp256k1::PublicKey; +use lightning::ln::ffor::{FFORReceiverId, FFORReceiverParameters}; +use lightning::ln::types::ChannelId; +use lightning_ffor::amounts::FeePolicy; +use lightning_invoice::Description; +use zeroize::Zeroizing; + +use super::{RequestStoreError, VERSION}; + +const MAX_CLIENT_ID_BYTES: usize = 128; + +#[derive(Clone, PartialEq, Eq)] +pub(in crate::ffor) struct RequestIntent { + client_id: String, + amount_msat: u64, + description: String, +} + +impl fmt::Debug for RequestIntent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RequestIntent") + .field("amount_msat", &self.amount_msat) + .field("description_bytes", &self.description.len()) + .finish_non_exhaustive() + } +} + +impl RequestIntent { + pub(in crate::ffor) fn new( + client_id: String, amount_msat: u64, description: String, + ) -> Result { + validate_client_id(&client_id)?; + if amount_msat == 0 || Description::new(description.clone()).is_err() { + return Err(RequestStoreError::InvalidIntent); + } + Ok(Self { client_id, amount_msat, description }) + } + pub(in crate::ffor) fn client_id(&self) -> &str { + &self.client_id + } + pub(in crate::ffor) fn amount_msat(&self) -> u64 { + self.amount_msat + } + pub(in crate::ffor) fn description(&self) -> &str { + &self.description + } +} + +pub(super) fn validate_client_id(id: &str) -> Result<(), RequestStoreError> { + if id.is_empty() || id.len() > MAX_CLIENT_ID_BYTES || id.trim().is_empty() { + Err(RequestStoreError::InvalidIntent) + } else { + Ok(()) + } +} + +/// Exact local selection. It is not proof that this channel can admit the request. +#[derive(Clone, Debug)] +pub(in crate::ffor) struct RequestPlan { + pub(in crate::ffor) channel: ChannelId, + pub(in crate::ffor) settlement: PublicKey, + pub(in crate::ffor) parameters: FFORReceiverParameters, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::ffor) struct RetainedSelector { + pub(in crate::ffor) channel: ChannelId, + pub(in crate::ffor) epoch: [u8; 32], +} + +impl RetainedSelector { + pub(super) fn from_native(id: &FFORReceiverId) -> Self { + Self { channel: id.channel_id(), epoch: id.epoch_id() } + } + pub(super) fn matches(&self, id: &FFORReceiverId) -> bool { + *self == Self::from_native(id) + } +} + +/// Immutable application data plus an optional historical selector, never a native capability. +#[derive(Clone, Debug)] +pub(in crate::ffor) struct StoredRequest { + intent: RequestIntent, + plan: RequestPlan, + selector: Option, +} + +impl StoredRequest { + pub(super) fn new(intent: RequestIntent, plan: RequestPlan) -> Result { + let record = Self { intent, plan, selector: None }; + record.validate()?; + Ok(record) + } + pub(in crate::ffor) fn intent(&self) -> &RequestIntent { + &self.intent + } + pub(in crate::ffor) fn plan(&self) -> &RequestPlan { + &self.plan + } + pub(in crate::ffor) fn selector(&self) -> Option { + self.selector + } + pub(in crate::ffor) fn local_request_id(&self) -> [u8; 32] { + self.plan.parameters.local_request_id + } + + pub(super) fn same_request(&self, other: &Self) -> bool { + let mut left = self.clone(); + let mut right = other.clone(); + left.selector = None; + right.selector = None; + left.encode() == right.encode() + } + + pub(super) fn bind(&mut self, id: &FFORReceiverId) -> Result { + if id.channel_id() != self.plan.channel { + return Err(RequestStoreError::Conflict); + } + let selector = RetainedSelector::from_native(id); + match self.selector { + Some(existing) if existing != selector => Err(RequestStoreError::Conflict), + Some(_) => Ok(false), + None => { + self.selector = Some(selector); + Ok(true) + }, + } + } + + fn validate(&self) -> Result<(), RequestStoreError> { + let p = &self.plan.parameters; + if p.amounts_msat.as_slice() != [self.intent.amount_msat] + || p.minimum_payment_msat == 0 + || p.minimum_payment_msat > self.intent.amount_msat + || p.settlement_deadline == 0 + || p.claim_margin_blocks == 0 + || p.voucher_expiry >= 500_000_000 + || p.settlement_deadline + .checked_add(p.claim_margin_blocks) + .is_none_or(|height| height > p.voucher_expiry) + || self.selector.is_some_and(|selector| selector.channel != self.plan.channel) + { + return Err(RequestStoreError::InvalidIntent); + } + FeePolicy { + base_msat: p.fee_base_msat, + proportional_millionths: p.fee_proportional_millionths, + } + .gross_msat(self.intent.amount_msat) + .map_err(|_| RequestStoreError::InvalidIntent)?; + if let Some(witnesses) = &p.witness_peers { + if witnesses.is_empty() + || witnesses.len() > 4 + || witnesses.iter().enumerate().any(|(i, key)| witnesses[..i].contains(key)) + { + return Err(RequestStoreError::InvalidIntent); + } + } + Ok(()) + } + + pub(super) fn encode(&self) -> Zeroizing> { + let mut out = Zeroizing::new(Vec::new()); + out.extend_from_slice(&VERSION.to_be_bytes()); + string(&mut out, &self.intent.client_id); + out.extend_from_slice(&self.intent.amount_msat.to_be_bytes()); + string(&mut out, &self.intent.description); + out.extend_from_slice(&self.plan.channel.0); + out.extend_from_slice(&self.plan.settlement.serialize()); + let p = &self.plan.parameters; + out.extend_from_slice(&p.local_request_id); + out.extend_from_slice(&p.minimum_payment_msat.to_be_bytes()); + for value in [ + p.settlement_deadline, + p.voucher_expiry, + p.fee_base_msat, + p.fee_proportional_millionths, + p.claim_margin_blocks, + ] { + out.extend_from_slice(&value.to_be_bytes()); + } + match &p.witness_peers { + None => out.push(0), + Some(peers) => { + out.push(peers.len() as u8); + for key in peers { + out.extend_from_slice(&key.serialize()); + } + }, + } + out.push(u8::from(p.hash_chain)); + match self.selector { + None => out.push(0), + Some(selector) => { + out.push(1); + out.extend_from_slice(&selector.epoch); + }, + } + out + } + + pub(super) fn decode(bytes: &[u8]) -> Result { + let mut reader = Reader(bytes); + if u16::from_be_bytes(reader.array()?) != VERSION { + return Err(RequestStoreError::Corrupt); + } + let client_id = reader.string(MAX_CLIENT_ID_BYTES)?; + let amount = u64::from_be_bytes(reader.array()?); + let intent = RequestIntent::new(client_id, amount, reader.string(639)?)?; + let channel = ChannelId(reader.array()?); + let settlement = reader.key()?; + let mut parameters = FFORReceiverParameters { + local_request_id: reader.array()?, + amounts_msat: vec![amount], + minimum_payment_msat: u64::from_be_bytes(reader.array()?), + settlement_deadline: reader.u32()?, + voucher_expiry: reader.u32()?, + fee_base_msat: reader.u32()?, + fee_proportional_millionths: reader.u32()?, + claim_margin_blocks: reader.u32()?, + witness_peers: None, + hash_chain: false, + }; + match reader.byte()? { + 0 => {}, + count @ 1..=4 => { + parameters.witness_peers = + Some((0..count).map(|_| reader.key()).collect::>()?) + }, + _ => return Err(RequestStoreError::Corrupt), + } + parameters.hash_chain = match reader.byte()? { + 0 => false, + 1 => true, + _ => return Err(RequestStoreError::Corrupt), + }; + let selector = match reader.byte()? { + 0 => None, + 1 => Some(RetainedSelector { channel, epoch: reader.array()? }), + _ => return Err(RequestStoreError::Corrupt), + }; + if !reader.0.is_empty() { + return Err(RequestStoreError::Corrupt); + } + let record = + Self { intent, plan: RequestPlan { channel, settlement, parameters }, selector }; + record.validate()?; + Ok(record) + } +} + +fn string(out: &mut Vec, value: &str) { + out.extend_from_slice(&(value.len() as u16).to_be_bytes()); + out.extend_from_slice(value.as_bytes()); +} + +struct Reader<'a>(&'a [u8]); +impl<'a> Reader<'a> { + fn take(&mut self, count: usize) -> Result<&'a [u8], RequestStoreError> { + let value = self.0.get(..count).ok_or(RequestStoreError::Corrupt)?; + self.0 = &self.0[count..]; + Ok(value) + } + fn array(&mut self) -> Result<[u8; N], RequestStoreError> { + self.take(N)?.try_into().map_err(|_| RequestStoreError::Corrupt) + } + fn byte(&mut self) -> Result { + Ok(self.array::<1>()?[0]) + } + fn u32(&mut self) -> Result { + Ok(u32::from_be_bytes(self.array()?)) + } + fn key(&mut self) -> Result { + PublicKey::from_slice(self.take(33)?).map_err(|_| RequestStoreError::Corrupt) + } + fn string(&mut self, maximum: usize) -> Result { + let len = usize::from(u16::from_be_bytes(self.array()?)); + if len > maximum { + return Err(RequestStoreError::Capacity); + } + String::from_utf8(self.take(len)?.to_vec()).map_err(|_| RequestStoreError::Corrupt) + } +} diff --git a/src/ffor/request_store/tests.rs b/src/ffor/request_store/tests.rs new file mode 100644 index 0000000000..0eced21aac --- /dev/null +++ b/src/ffor/request_store/tests.rs @@ -0,0 +1,482 @@ +use std::str::FromStr; +use std::sync::atomic::Ordering; + +use bitcoin::hex::FromHex; +use bitcoin::secp256k1::{Secp256k1, SecretKey}; +use bitcoin::Network; +use lightning::ln::ffor::FFORReceiverParameters; +use lightning::ln::msgs::{BaseMessageHandler, Init}; +use lightning::ln::types::ChannelId; +use lightning::util::persist::{ + CHANNEL_MANAGER_PERSISTENCE_KEY, CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, CHANNEL_MONITOR_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MONITOR_PERSISTENCE_SECONDARY_NAMESPACE, +}; +use lightning_types::features::InitFeatures; +use proptest::prelude::*; + +use super::*; +use crate::builder::NodeBuilder; +use crate::ffor::witness_store::tests::TestStore; +use crate::{Config, Node}; + +const SEED: [u8; 64] = [91; 64]; +const CLIENT: &str = "request-fixture"; + +fn field(name: &str) -> &'static str { + include_str!("fixtures/fixture.txt") + .lines() + .find_map(|line| line.strip_prefix(&format!("{name}="))) + .unwrap() +} + +fn install_fixture(storage: &TestStore, pending: bool) { + let manager = if pending { + include_bytes!("fixtures/pending-manager.bin").as_slice() + } else { + include_bytes!("fixtures/empty-manager.bin").as_slice() + }; + let monitor = if pending { + include_bytes!("fixtures/pending-monitor.bin").as_slice() + } else { + include_bytes!("fixtures/empty-monitor.bin").as_slice() + }; + KVStoreSync::write( + storage, + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_KEY, + manager.to_vec(), + ) + .unwrap(); + KVStoreSync::write( + storage, + CHANNEL_MONITOR_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MONITOR_PERSISTENCE_SECONDARY_NAMESPACE, + &format!("{}_{}", field("funding_txid"), field("funding_vout")), + monitor.to_vec(), + ) + .unwrap(); +} + +fn restore_node(storage: Arc) -> Node { + let mut builder = + NodeBuilder::from_config(Config { network: Network::Testnet, ..Config::default() }); + builder.set_entropy_seed_bytes(SEED); + builder.set_log_facade_logger(); + builder.build_with_store(storage).unwrap() +} + +fn open(storage: Arc, node: &Node) -> RequestStore { + RequestStore::open(&SEED, node, storage).unwrap() +} + +fn fixture() -> (Arc, Node, RequestStore) { + let storage = TestStore::new(); + install_fixture(&storage, false); + let node = restore_node(Arc::clone(&storage)); + let store = open(Arc::clone(&storage), &node); + (storage, node, store) +} + +fn intent(client: &str) -> RequestIntent { + RequestIntent::new(client.to_owned(), 2_000_000, "Exact receipt note 🔐".to_owned()).unwrap() +} + +fn plan(store: &RequestStore, client: &str) -> RequestPlan { + RequestPlan { + channel: ChannelId(<[u8; 32]>::from_hex(field("channel_id")).unwrap()), + settlement: PublicKey::from_str(field("settlement")).unwrap(), + parameters: FFORReceiverParameters { + local_request_id: store.local_request_id(client).unwrap(), + amounts_msat: vec![2_000_000], + minimum_payment_msat: 2_000_000, + settlement_deadline: 110, + voucher_expiry: 154, + fee_base_msat: 0, + fee_proportional_millionths: 0, + claim_margin_blocks: 20, + witness_peers: None, + hash_chain: false, + }, + } +} + +fn connect(node: &Node, peer: PublicKey) -> lightning::ln::ffor::FFORPeerConnection { + let mut features = InitFeatures::empty(); + features.set_static_remote_key_optional(); + node.channel_manager + .peer_connected( + peer, + &Init { features, networks: None, remote_network_address: None }, + false, + ) + .unwrap(); + node.channel_manager.ffor_peer_connection(&peer).unwrap() +} + +fn raw(storage: &TestStore, store: &RequestStore, client: &str) -> Vec { + KVStoreSync::read(storage, NAMESPACE, "", &hex(&store.local_request_id(client).unwrap())) + .unwrap() +} + +fn pending() -> (Arc, Node, RequestStore) { + let (storage, original, mut store) = fixture(); + store.begin(intent(CLIENT), plan(&store, CLIENT)).unwrap(); + drop(store); + drop(original); + install_fixture(&storage, true); + let node = restore_node(Arc::clone(&storage)); + let store = open(Arc::clone(&storage), &node); + (storage, node, store) +} + +#[test] +fn ffor_request_store_retries_exact_intent_and_redacts_description() { + let (storage, node, mut store) = fixture(); + assert_eq!(hex(&store.local_request_id(CLIENT).unwrap()), field("local_request_id")); + let created = store.begin(intent(CLIENT), plan(&store, CLIENT)).unwrap(); + let bytes = raw(&storage, &store, CLIENT); + let before = storage.writes.load(Ordering::SeqCst); + let repeated = store.begin(intent(CLIENT), plan(&store, CLIENT)).unwrap(); + assert_eq!(created.encode(), repeated.encode()); + assert_eq!(storage.writes.load(Ordering::SeqCst), before); + assert!(!format!("{created:?}").contains("Exact receipt note")); + assert!(!bytes.windows(5).any(|window| window == b"Exact")); + for changed in [ + RequestIntent::new(CLIENT.into(), 2_000_001, created.intent().description().into()) + .unwrap(), + RequestIntent::new(CLIENT.into(), 2_000_000, "changed".into()).unwrap(), + ] { + let mut selected = plan(&store, CLIENT); + selected.parameters.amounts_msat = vec![changed.amount_msat()]; + assert!(matches!(store.begin(changed, selected), Err(RequestStoreError::Conflict))); + } + let mut changed = plan(&store, CLIENT); + changed.parameters.settlement_deadline += 1; + assert!(matches!(store.begin(intent(CLIENT), changed), Err(RequestStoreError::Conflict))); + assert_eq!(raw(&storage, &store, CLIENT), bytes); + drop(store); + let mut restored = open(Arc::clone(&storage), &node); + assert_eq!(restored.lookup(CLIENT).unwrap().unwrap().encode(), created.encode()); + assert_eq!(raw(&storage, &restored, CLIENT), bytes); +} + +#[test] +fn ffor_request_store_real_native_recovery_binds_only_exact_retained_intent() { + let (storage, node, mut store) = pending(); + assert!(store.lookup(CLIENT).unwrap().unwrap().selector().is_none()); + let connection = connect(&node, plan(&store, CLIENT).settlement); + let actual = store.prepare_native(CLIENT, &connection).unwrap(); + assert_eq!(hex(&actual.epoch_id()), field("epoch_id")); + assert_eq!( + node.channel_manager + .find_ffor_receiver_request(store.local_request_id(CLIENT).unwrap()) + .unwrap(), + Some(actual) + ); + let saved = raw(&storage, &store, CLIENT); + assert_eq!(store.prepare_native(CLIENT, &connection).unwrap(), actual); + assert_eq!(saved, raw(&storage, &store, CLIENT)); + assert!(store.lookup(CLIENT).unwrap().unwrap().selector().unwrap().matches(&actual)); + assert!(node.list_payments().is_empty()); +} + +#[test] +fn ffor_request_store_native_selector_does_not_certify_different_parameters() { + let (storage, first, mut store) = fixture(); + let mut selected = plan(&store, CLIENT); + selected.parameters.settlement_deadline += 1; + store.begin(intent(CLIENT), selected).unwrap(); + drop(store); + drop(first); + install_fixture(&storage, true); + let node = restore_node(Arc::clone(&storage)); + let mut store = open(storage, &node); + assert!(node + .channel_manager + .find_ffor_receiver_request(store.local_request_id(CLIENT).unwrap()) + .unwrap() + .is_some()); + let connection = connect(&node, plan(&store, CLIENT).settlement); + assert_eq!( + store.prepare_native(CLIENT, &connection), + Err(RequestStoreError::Native(FFORReceiverError::AlreadyRegistered)) + ); + assert!(store.lookup(CLIENT).unwrap().unwrap().selector().is_none()); +} + +#[test] +fn ffor_request_store_missing_application_or_native_history_refuses_reconstruction() { + let storage = TestStore::new(); + install_fixture(&storage, true); + let node = restore_node(Arc::clone(&storage)); + let mut store = open(Arc::clone(&storage), &node); + assert!(matches!(store.lookup(CLIENT), Err(RequestStoreError::Missing))); + assert!(matches!( + store.begin(intent(CLIENT), plan(&store, CLIENT)), + Err(RequestStoreError::Missing) + )); + assert!(KVStoreSync::list(&*storage, NAMESPACE, "").unwrap().is_empty()); + drop(store); + drop(node); + let (storage, original, mut store) = pending(); + let connection = connect(&original, plan(&store, CLIENT).settlement); + store.prepare_native(CLIENT, &connection).unwrap(); + drop(store); + drop(original); + install_fixture(&storage, false); + let node = restore_node(Arc::clone(&storage)); + let mut store = open(storage, &node); + let connection = connect(&node, plan(&store, CLIENT).settlement); + assert_eq!(store.prepare_native(CLIENT, &connection), Err(RequestStoreError::MissingNative)); +} + +#[test] +fn ffor_request_store_rejects_wrong_peer_and_stale_native_connection_before_binding() { + let (_, node, mut store) = pending(); + let wrong_peer = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[13; 32]).unwrap()); + let wrong = connect(&node, wrong_peer); + assert_eq!(store.prepare_native(CLIENT, &wrong), Err(RequestStoreError::Identity)); + let peer = plan(&store, CLIENT).settlement; + let stale = connect(&node, peer); + node.channel_manager.peer_disconnected(peer); + let fresh = connect(&node, peer); + assert!(matches!(store.prepare_native(CLIENT, &stale), Err(RequestStoreError::Native(_)))); + assert!(store.prepare_native(CLIENT, &fresh).is_ok()); +} + +#[test] +fn ffor_request_store_creation_failures_rewrite_exact_candidate_and_block_other_admission() { + for failure in [1, 2] { + let (storage, _, mut store) = fixture(); + storage.write_failure.store(failure, Ordering::SeqCst); + assert!(matches!( + store.begin(intent(CLIENT), plan(&store, CLIENT)), + Err(RequestStoreError::Storage) + )); + let candidate = store.uncertain.as_ref().unwrap().bytes.clone(); + assert!(matches!(store.lookup(CLIENT), Err(RequestStoreError::Uncertain))); + assert!(matches!( + store.begin(intent("other"), plan(&store, "other")), + Err(RequestStoreError::Uncertain) + )); + storage.write_failure.store(2, Ordering::SeqCst); + assert_eq!(store.recover_write(), Err(RequestStoreError::Storage)); + assert_eq!(store.uncertain.as_ref().unwrap().bytes, candidate); + store.recover_write().unwrap(); + assert_eq!(raw(&storage, &store, CLIENT), candidate); + assert!(store.lookup(CLIENT).unwrap().is_some()); + } +} + +#[test] +fn ffor_request_store_binding_failures_recover_existing_native_epoch_without_resigning() { + for failure in [1, 2] { + let (storage, node, mut store) = pending(); + store.lookup(CLIENT).unwrap(); + let connection = connect(&node, plan(&store, CLIENT).settlement); + storage.write_failure.store(failure, Ordering::SeqCst); + assert_eq!(store.prepare_native(CLIENT, &connection), Err(RequestStoreError::Storage)); + let candidate = store.uncertain.as_ref().unwrap().bytes.clone(); + assert_eq!(store.prepare_native(CLIENT, &connection), Err(RequestStoreError::Uncertain)); + store.recover_write().unwrap(); + let recovered = store.prepare_native(CLIENT, &connection).unwrap(); + assert_eq!(hex(&recovered.epoch_id()), field("epoch_id")); + assert_eq!(raw(&storage, &store, CLIENT), candidate); + } +} + +#[test] +fn ffor_request_store_visible_failed_write_and_reopen_require_successful_confirmation() { + let (storage, node, mut store) = fixture(); + storage.write_failure.store(2, Ordering::SeqCst); + assert!(matches!( + store.begin(intent(CLIENT), plan(&store, CLIENT)), + Err(RequestStoreError::Storage) + )); + let bytes = raw(&storage, &store, CLIENT); + drop(store); + for _ in 0..2 { + let mut reopened = open(Arc::clone(&storage), &node); + storage.write_failure.store(2, Ordering::SeqCst); + assert!(matches!(reopened.lookup(CLIENT), Err(RequestStoreError::Storage))); + assert_eq!(raw(&storage, &reopened, CLIENT), bytes); + } + let mut reopened = open(Arc::clone(&storage), &node); + assert!(reopened.lookup(CLIENT).unwrap().is_some()); + assert_eq!(raw(&storage, &reopened, CLIENT), bytes); +} + +#[test] +fn ffor_request_store_visible_binding_failure_survives_owner_restart_without_new_epoch() { + let (storage, node, mut store) = pending(); + store.lookup(CLIENT).unwrap(); + let connection = connect(&node, plan(&store, CLIENT).settlement); + storage.write_failure.store(2, Ordering::SeqCst); + assert_eq!(store.prepare_native(CLIENT, &connection), Err(RequestStoreError::Storage)); + let candidate = raw(&storage, &store, CLIENT); + drop(store); + let mut reopened = open(Arc::clone(&storage), &node); + storage.write_failure.store(2, Ordering::SeqCst); + assert_eq!(reopened.prepare_native(CLIENT, &connection), Err(RequestStoreError::Storage)); + reopened.recover_write().unwrap(); + let id = reopened.prepare_native(CLIENT, &connection).unwrap(); + assert_eq!(hex(&id.epoch_id()), field("epoch_id")); + assert_eq!(raw(&storage, &reopened, CLIENT), candidate); +} + +#[test] +fn ffor_request_store_missing_or_replaced_binding_predecessor_is_never_overwritten() { + for replacement in [None, Some(vec![4, 5, 6])] { + let (storage, node, mut store) = pending(); + store.lookup(CLIENT).unwrap(); + let connection = connect(&node, plan(&store, CLIENT).settlement); + storage.write_failure.store(1, Ordering::SeqCst); + assert_eq!(store.prepare_native(CLIENT, &connection), Err(RequestStoreError::Storage)); + let key = hex(&store.local_request_id(CLIENT).unwrap()); + if let Some(bytes) = &replacement { + KVStoreSync::write(&*storage, NAMESPACE, "", &key, bytes.clone()).unwrap(); + } else { + KVStoreSync::remove(&*storage, NAMESPACE, "", &key, false).unwrap(); + } + let before = storage.writes.load(Ordering::SeqCst); + assert_eq!( + store.recover_write(), + Err(if replacement.is_some() { + RequestStoreError::Conflict + } else { + RequestStoreError::Missing + }) + ); + assert_eq!(storage.writes.load(Ordering::SeqCst), before); + assert!(store.uncertain.is_some()); + } +} + +#[test] +fn ffor_request_store_identity_key_and_parameter_substitution_are_authenticated() { + let (_, _, store) = fixture(); + let record = StoredRequest::new(intent(CLIENT), plan(&store, CLIENT)).unwrap(); + let key = hex(&record.local_request_id()); + let bytes = store.envelope.seal(&key, &record).unwrap(); + let different_node = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[14; 32]).unwrap()); + for envelope in [ + EnvelopeKey::derive(&SEED, [0; 32], store.node), + EnvelopeKey::derive(&SEED, store.chain, different_node), + ] { + assert!(envelope.open(&key, &bytes).is_err()); + } + assert!(store.envelope.open(&"0".repeat(64), &bytes).is_err()); + assert_ne!( + local_id(store.chain, store.node, CLIENT), + local_id(store.chain, store.node, "request-fixture ") + ); + assert_ne!(local_id(store.chain, store.node, CLIENT), local_id([0; 32], store.node, CLIENT)); + assert_ne!( + local_id(store.chain, store.node, CLIENT), + local_id(store.chain, different_node, CLIENT) + ); + let mut changed = plan(&store, CLIENT); + changed.parameters.local_request_id = [1; 32]; + let changed = StoredRequest::new(intent(CLIENT), changed).unwrap(); + let storage = TestStore::new(); + KVStoreSync::write( + &*storage, + NAMESPACE, + "", + &key, + store.envelope.seal(&key, &changed).unwrap(), + ) + .unwrap(); + assert!(matches!( + RequestStore::open_bound( + &SEED, + store.chain, + store.node, + Arc::clone(&store.manager), + storage + ), + Err(RequestStoreError::Corrupt) + )); +} + +#[test] +fn ffor_request_store_corruption_replacement_deletion_and_wrong_seed_fail_closed() { + let (storage, node, mut store) = fixture(); + store.begin(intent(CLIENT), plan(&store, CLIENT)).unwrap(); + let key = hex(&store.local_request_id(CLIENT).unwrap()); + let good = raw(&storage, &store, CLIENT); + assert!(matches!( + RequestStore::open(&[1; 64], &node, storage.clone()), + Err(RequestStoreError::Corrupt) + )); + let mut bad = good.clone(); + let last = bad.len() - 1; + bad[last] ^= 1; + KVStoreSync::write(&*storage, NAMESPACE, "", &key, bad).unwrap(); + assert!(matches!(store.lookup(CLIENT), Err(RequestStoreError::Conflict))); + assert!(RequestStore::open(&SEED, &node, storage.clone()).is_err()); + KVStoreSync::remove(&*storage, NAMESPACE, "", &key, false).unwrap(); + assert!(matches!(store.lookup(CLIENT), Err(RequestStoreError::Missing))); + assert!(matches!( + store.begin(intent(CLIENT), plan(&store, CLIENT)), + Err(RequestStoreError::Missing) + )); + KVStoreSync::write(&*storage, NAMESPACE, "", &key, good).unwrap(); + assert!(store.lookup(CLIENT).unwrap().is_some()); +} + +#[test] +fn ffor_request_store_capacity_and_external_new_key_preserve_all_existing_bytes() { + let (storage, _, mut store) = fixture(); + for index in 0..MAX_REQUESTS { + let client = format!("request-{index}"); + store.begin(intent(&client), plan(&store, &client)).unwrap(); + } + assert!(matches!( + store.begin(intent(CLIENT), plan(&store, CLIENT)), + Err(RequestStoreError::Capacity) + )); + assert_eq!(store.list().unwrap().len(), MAX_REQUESTS); + let foreign = hex(&store.local_request_id("external").unwrap()); + KVStoreSync::write(&*storage, NAMESPACE, "", &foreign, vec![7]).unwrap(); + assert!(matches!( + store.begin(intent("external"), plan(&store, "external")), + Err(RequestStoreError::Conflict) + )); + assert_eq!(KVStoreSync::read(&*storage, NAMESPACE, "", &foreign).unwrap(), vec![7]); +} + +#[test] +fn ffor_request_store_codec_rejects_trailing_truncated_and_invalid_parameters() { + let (_, _, store) = fixture(); + let value = StoredRequest::new(intent(CLIENT), plan(&store, CLIENT)).unwrap(); + let bytes = value.encode(); + assert_eq!(StoredRequest::decode(&bytes).unwrap().encode(), bytes); + for end in 0..bytes.len() { + assert!(StoredRequest::decode(&bytes[..end]).is_err()); + } + let mut extra = bytes.to_vec(); + extra.push(0); + assert!(StoredRequest::decode(&extra).is_err()); + let mut future = bytes.to_vec(); + future[1] = 2; + assert!(StoredRequest::decode(&future).is_err()); + let mut invalid = plan(&store, CLIENT); + invalid.parameters.amounts_msat.push(1); + assert!(StoredRequest::new(intent(CLIENT), invalid).is_err()); + assert!(RequestIntent::new("id".into(), 0, "".into()).is_err()); + assert!(RequestIntent::new("id".into(), 1, "a".repeat(640)).is_err()); + assert!(RequestIntent::new(" ".into(), 1, "".into()).is_err()); + assert!(RequestIntent::new("a".repeat(129), 1, "".into()).is_err()); +} + +proptest! { + #[test] + fn ffor_request_store_bounded_decoder_never_accepts_noncanonical_bytes(bytes in prop::collection::vec(any::(), 0..4097)) { + if let Ok(record) = StoredRequest::decode(&bytes) { prop_assert_eq!(&*record.encode(), &bytes); } + } +} diff --git a/src/ffor/witness_owner.rs b/src/ffor/witness_owner.rs index e4a46c7496..2dcd840cf3 100644 --- a/src/ffor/witness_owner.rs +++ b/src/ffor/witness_owner.rs @@ -14,13 +14,14 @@ use super::witness_store::{ WitnessKeyUseError, WitnessSecretStore, WitnessStorageBinding, WitnessStoreError, }; use crate::message_handler::ffor::{FforReceiverTransport, ReceivedFforMessage}; -use crate::types::ChannelManager; +use crate::types::{ChainMonitor, ChannelManager}; use fetch::FetchRequests; use pending::PendingProvisions; mod fetch; mod pending; mod provisioning; +mod recovery; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum WitnessOwnerError { @@ -45,6 +46,7 @@ pub(crate) enum AcknowledgementProgress { pub(crate) struct WitnessOwner { manager: Arc, + monitor: Arc, store: Arc, transport: Arc, pending: PendingProvisions, @@ -53,11 +55,12 @@ pub(crate) struct WitnessOwner { impl WitnessOwner { pub(crate) fn new( - manager: Arc, store: Arc, + manager: Arc, monitor: Arc, store: Arc, transport: Arc, ) -> Self { Self { manager, + monitor, store, transport, pending: PendingProvisions::default(), diff --git a/src/ffor/witness_owner/fetch.rs b/src/ffor/witness_owner/fetch.rs index d274c9c803..1a9da7a808 100644 --- a/src/ffor/witness_owner/fetch.rs +++ b/src/ffor/witness_owner/fetch.rs @@ -317,4 +317,4 @@ impl WitnessOwner { } #[cfg(test)] -mod tests; +pub(super) mod tests; diff --git a/src/ffor/witness_owner/fetch/tests.rs b/src/ffor/witness_owner/fetch/tests.rs index 07eedb35bc..277b5231b6 100644 --- a/src/ffor/witness_owner/fetch/tests.rs +++ b/src/ffor/witness_owner/fetch/tests.rs @@ -64,7 +64,9 @@ fn response( .unwrap() } -fn record(h: &Harness, slot: u16, invalid: bool, identity: u8) -> EncryptedRecord { +pub(in crate::ffor::witness_owner) fn record( + h: &Harness, slot: u16, invalid: bool, identity: u8, +) -> EncryptedRecord { let manifest: SignedManifest = h.owner.retained_manifests(&h.context).unwrap().remove(0).1; let params = manifest.unsigned().parameters(); let voucher = &h.context.setup().vouchers()[usize::from(slot) - 1]; diff --git a/src/ffor/witness_owner/recovery.rs b/src/ffor/witness_owner/recovery.rs new file mode 100644 index 0000000000..48c9d2e296 --- /dev/null +++ b/src/ffor/witness_owner/recovery.rs @@ -0,0 +1,51 @@ +//! Durable encrypted evidence enters only the concrete original native monitor. + +use bitcoin::secp256k1::PublicKey; +use lightning::ln::ffor::{ + FFORCommitmentError, FFORReceiverRecoveryContext, FFORWitnessReceiptProgress, +}; + +use super::{WitnessOwner, WitnessOwnerError}; +use crate::ffor::witness_store::WitnessStorageBinding; + +impl WitnessOwner { + /// Protect one retained receipt with the stock monitor. Absence means only that this store has + /// no authenticated evidence for the selected witness and slot, never that the voucher is unpaid. + /// Even MonitorPersisted grants neither settlement credit nor permission to issue an invoice. + pub(crate) fn recover_receipt( + &mut self, context: &FFORReceiverRecoveryContext, witness: PublicKey, slot: u16, + ) -> Result, WitnessOwnerError> { + // Rejoin immutable native registration before touching protected evidence. Historical + // recovery does not require an Active channel, an unexpired deadline or a connected peer. + if !self.retained_manifests(context)?.iter().any(|(selected, _)| *selected == witness) { + return Err(WitnessOwnerError::UnknownWitness); + } + let binding = WitnessStorageBinding::from_native_context(context) + .map_err(WitnessOwnerError::Storage)?; + let receipt = match self + .store + .load_receipt(&binding, witness, slot) + .map_err(WitnessOwnerError::Storage)? + { + Some(receipt) => receipt, + None => return Ok(None), + }; + let snapshot = { + let monitor = self.monitor.get_monitor(context.channel_id()).map_err(|_| { + WitnessOwnerError::Native(FFORCommitmentError::MonitorMismatch.into()) + })?; + monitor + .ffor_witness_receipt_snapshot(&receipt) + .map_err(|error| WitnessOwnerError::Native(error.into()))? + }; + // Native may synchronously call Watch. Holding the monitor guard here would deadlock; + // the opaque snapshot and native counter/funding checks close the intervening state race. + self.manager + .import_ffor_receiver_witness_receipt(context, &receipt, &snapshot) + .map(Some) + .map_err(WitnessOwnerError::Native) + } +} + +#[cfg(test)] +mod tests; diff --git a/src/ffor/witness_owner/recovery/tests.rs b/src/ffor/witness_owner/recovery/tests.rs new file mode 100644 index 0000000000..120febb5d0 --- /dev/null +++ b/src/ffor/witness_owner/recovery/tests.rs @@ -0,0 +1,204 @@ +use std::sync::atomic::Ordering; +use std::sync::Arc; + +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use lightning::events::Event; +use lightning::ln::ffor::FFORWitnessReceiptProgress; + +use super::*; +use crate::ffor::witness_owner::fetch::tests::record; +use crate::ffor::witness_owner::tests::Harness; +use crate::ffor::witness_store::WitnessStoreError; + +fn monitor_counter(h: &Harness) -> u64 { + h.node.chain_monitor.get_monitor(h.context.channel_id()).unwrap().get_latest_update_id() +} + +fn process_without_credit(h: &Harness) { + let events = h.node.channel_manager.get_and_clear_pending_events(); + assert!(!events.iter().any(|event| matches!( + event, + Event::PaymentClaimed { .. } | Event::PaymentClaimable { .. } + ))); + assert!(h.node.list_payments().is_empty()); +} + +fn retain(h: &Harness, slot: u16) { + let binding = WitnessStorageBinding::from_native_context(&h.context).unwrap(); + h.owner + .store + .retain_receipt(&binding, h.policies[0].witness, &record(h, slot, false, slot as u8)) + .unwrap(); +} + +#[test] +fn ffor_witness_recovery_missing_evidence_and_wrong_witness_do_not_touch_monitor() { + let mut h = Harness::new(); + h.register_and_persist(); + let before = monitor_counter(&h); + let writes = h.storage.writes.load(Ordering::SeqCst); + assert_eq!(h.owner.recover_receipt(&h.context, h.policies[0].witness, 1), Ok(None)); + let other = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[81; 32]).unwrap()); + assert_eq!( + h.owner.recover_receipt(&h.context, other, 1), + Err(WitnessOwnerError::UnknownWitness) + ); + assert_eq!(monitor_counter(&h), before); + assert_eq!(h.storage.writes.load(Ordering::SeqCst), writes); + process_without_credit(&h); +} + +#[test] +fn ffor_witness_recovery_real_monitor_persists_once_and_survives_node_restore() { + let mut h = Harness::new(); + h.register_and_persist(); + retain(&h, 1); + let before = monitor_counter(&h); + let pending = h.owner.recover_receipt(&h.context, h.policies[0].witness, 1).unwrap(); + let update_id = match pending { + Some(FFORWitnessReceiptProgress::PendingMonitor { monitor_update_id }) => monitor_update_id, + other => panic!("expected monitor submission, got {other:?}"), + }; + assert!(update_id > before); + assert_eq!(monitor_counter(&h), update_id); + // Node's actual MonitorUpdatingPersister completes only after the KVStore write succeeds. + // Normal native event processing observes that completion; the test never fabricates an ACK. + process_without_credit(&h); + let persisted = + Some(FFORWitnessReceiptProgress::MonitorPersisted { monitor_update_id: update_id }); + let writes = h.storage.writes.load(Ordering::SeqCst); + assert_eq!(h.owner.recover_receipt(&h.context, h.policies[0].witness, 1), Ok(persisted)); + assert_eq!(h.storage.writes.load(Ordering::SeqCst), writes); + h.persist_manager(); + let storage = Arc::clone(&h.storage); + drop(h); + let mut restored = Harness::from_store(storage); + assert_eq!( + restored.owner.recover_receipt(&restored.context, restored.policies[0].witness, 1), + Ok(persisted) + ); + assert_eq!(monitor_counter(&restored), update_id); + process_without_credit(&restored); +} + +#[test] +fn ffor_witness_recovery_uncertain_receipt_write_cannot_reach_native_monitor() { + for failure in [1, 2] { + let mut h = Harness::new(); + h.register_and_persist(); + let binding = WitnessStorageBinding::from_native_context(&h.context).unwrap(); + let receipt = record(&h, 1, false, 1); + let before = monitor_counter(&h); + h.storage.write_failure.store(failure, Ordering::SeqCst); + assert_eq!( + h.owner.store.retain_receipt(&binding, h.policies[0].witness, &receipt), + Err(WitnessStoreError::Storage) + ); + assert_eq!( + h.owner.recover_receipt(&h.context, h.policies[0].witness, 1), + Err(WitnessOwnerError::Storage(WitnessStoreError::Uncertain)) + ); + assert_eq!(monitor_counter(&h), before); + h.owner.recover_storage().unwrap(); + assert!(matches!( + h.owner.recover_receipt(&h.context, h.policies[0].witness, 1), + Ok(Some(FFORWitnessReceiptProgress::PendingMonitor { .. })) + )); + process_without_credit(&h); + } +} + +#[test] +fn ffor_witness_recovery_restored_visible_receipt_waits_for_successful_confirmation() { + for confirmation_failure in [1, 2] { + let mut h = Harness::new(); + h.register_and_persist(); + let binding = WitnessStorageBinding::from_native_context(&h.context).unwrap(); + let witness = h.policies[0].witness; + let receipt = record(&h, 1, false, 1); + let before = monitor_counter(&h); + // The ciphertext becomes readable, but the original receipt write was not confirmed. + h.storage.write_failure.store(2, Ordering::SeqCst); + assert_eq!( + h.owner.store.retain_receipt(&binding, witness, &receipt), + Err(WitnessStoreError::Storage) + ); + let storage = Arc::clone(&h.storage); + drop(h); + let mut restored = Harness::from_store(storage); + assert_eq!(monitor_counter(&restored), before); + // Confirming restored secrets is the first write; confirming the receipt envelope is + // the second. Only the latter fails, preserving the exact readable encrypted candidate. + let writes = restored.storage.writes.load(Ordering::SeqCst); + restored.storage.fail_at.store(writes + 2, Ordering::SeqCst); + restored.storage.write_failure.store(confirmation_failure, Ordering::SeqCst); + assert_eq!( + restored.owner.recover_receipt(&restored.context, witness, 1), + Err(WitnessOwnerError::Storage(WitnessStoreError::Storage)) + ); + assert_eq!(restored.storage.writes.load(Ordering::SeqCst), writes + 2); + assert_eq!(monitor_counter(&restored), before); + assert_eq!( + restored.owner.recover_receipt(&restored.context, witness, 1), + Err(WitnessOwnerError::Storage(WitnessStoreError::Uncertain)) + ); + restored.owner.recover_storage().unwrap(); + let pending = restored.owner.recover_receipt(&restored.context, witness, 1).unwrap(); + let update_id = match pending { + Some(FFORWitnessReceiptProgress::PendingMonitor { monitor_update_id }) => { + monitor_update_id + }, + other => panic!("expected native monitor submission, got {other:?}"), + }; + assert!(update_id > before); + process_without_credit(&restored); + assert_eq!( + restored.owner.recover_receipt(&restored.context, witness, 1), + Ok(Some(FFORWitnessReceiptProgress::MonitorPersisted { monitor_update_id: update_id })) + ); + process_without_credit(&restored); + } +} + +#[test] +fn ffor_witness_recovery_archive_only_receipt_uses_durable_original_monitor_after_restart() { + let mut h = Harness::new(); + h.register_and_persist(); + retain(&h, 2); + h.node + .channel_manager + .force_close_broadcasting_latest_txn( + &h.context.channel_id(), + &h.context.settlement_node_id(), + "historical witness recovery fixture".to_owned(), + ) + .unwrap(); + process_without_credit(&h); + assert!(h.node.list_channels().is_empty()); + h.persist_manager(); + let storage = Arc::clone(&h.storage); + drop(h); + let mut restored = Harness::from_store(storage); + let before = monitor_counter(&restored); + let witness = restored.policies[0].witness; + let pending = restored.owner.recover_receipt(&restored.context, witness, 2).unwrap(); + let update_id = match pending { + Some(FFORWitnessReceiptProgress::PendingMonitor { monitor_update_id }) => monitor_update_id, + other => panic!("expected historical monitor submission, got {other:?}"), + }; + assert!(update_id > before); + process_without_credit(&restored); + let persisted = + Some(FFORWitnessReceiptProgress::MonitorPersisted { monitor_update_id: update_id }); + assert_eq!(restored.owner.recover_receipt(&restored.context, witness, 2), Ok(persisted)); + restored.persist_manager(); + let storage = Arc::clone(&restored.storage); + drop(restored); + let mut second_restore = Harness::from_store(storage); + assert_eq!( + second_restore.owner.recover_receipt(&second_restore.context, witness, 2), + Ok(persisted) + ); + process_without_credit(&second_restore); +} diff --git a/src/ffor/witness_owner/tests.rs b/src/ffor/witness_owner/tests.rs index 0bc0325a26..57372bfbf2 100644 --- a/src/ffor/witness_owner/tests.rs +++ b/src/ffor/witness_owner/tests.rs @@ -91,7 +91,12 @@ impl Harness { NodeCustomMessageHandler::new_ignoring().with_ffor_receiver(Arc::clone(&transport)); let backing: Arc = storage.clone(); let store = Arc::new(WitnessSecretStore::open(&SEED, backing).unwrap()); - let owner = WitnessOwner::new(Arc::clone(&node.channel_manager), store, transport); + let owner = WitnessOwner::new( + Arc::clone(&node.channel_manager), + Arc::clone(&node.chain_monitor), + store, + transport, + ); let expiry = context.setup().terms().voucher_expiry; let policies = vec![WitnessPolicy { witness: key(80), From cdd0c3f7c06a00a0e0211b8a941663304c65cc61 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 21 Sep 2026 03:16:17 -0400 Subject: [PATCH 19/23] feat: recover exact offline receive intent without a peer connection --- Cargo.lock | 30 ++++++++-------- Cargo.toml | 26 +++++++------- FFOR.md | 13 ++++--- src/ffor/request_store/native.rs | 61 ++++++++++++++++++++++---------- src/ffor/request_store/tests.rs | 59 ++++++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e9d8fccb95..fa3ccb7497 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1995,7 +1995,7 @@ dependencies = [ [[package]] name = "lightning" version = "0.2.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" +source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" dependencies = [ "bech32", "bitcoin", @@ -2007,7 +2007,7 @@ dependencies = [ "lightning-macros 0.2.1", "lightning-types 0.3.5", "musig2", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16)", "regex", ] @@ -2027,7 +2027,7 @@ dependencies = [ [[package]] name = "lightning-background-processor" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" +source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" dependencies = [ "bitcoin", "bitcoin-io", @@ -2035,7 +2035,7 @@ dependencies = [ "lightning 0.2.5", "lightning-liquidity 0.2.3", "lightning-rapid-gossip-sync 0.2.3", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16)", ] [[package]] @@ -2054,7 +2054,7 @@ dependencies = [ [[package]] name = "lightning-block-sync" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" +source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" dependencies = [ "bitcoin", "chunked_transfer", @@ -2066,7 +2066,7 @@ dependencies = [ [[package]] name = "lightning-ffor" version = "0.1.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" +source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" dependencies = [ "bitcoin", ] @@ -2086,7 +2086,7 @@ dependencies = [ [[package]] name = "lightning-invoice" version = "0.34.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" +source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" dependencies = [ "bech32", "bitcoin", @@ -2112,7 +2112,7 @@ dependencies = [ [[package]] name = "lightning-liquidity" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" +source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" dependencies = [ "bitcoin", "chrono", @@ -2138,7 +2138,7 @@ dependencies = [ [[package]] name = "lightning-macros" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" +source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" dependencies = [ "proc-macro2", "quote", @@ -2159,7 +2159,7 @@ dependencies = [ [[package]] name = "lightning-net-tokio" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" +source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2180,7 +2180,7 @@ dependencies = [ [[package]] name = "lightning-persister" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" +source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2203,7 +2203,7 @@ dependencies = [ [[package]] name = "lightning-rapid-gossip-sync" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" +source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" dependencies = [ "bitcoin", "bitcoin-io", @@ -2228,7 +2228,7 @@ dependencies = [ [[package]] name = "lightning-transaction-sync" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" +source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" dependencies = [ "bitcoin", "electrum-client 0.24.1", @@ -2250,7 +2250,7 @@ dependencies = [ [[package]] name = "lightning-types" version = "0.3.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" +source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" dependencies = [ "bitcoin", ] @@ -2677,7 +2677,7 @@ dependencies = [ [[package]] name = "possiblyrandom" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=0def88713b26264ab50c2451ba97a17839f331ac#0def88713b26264ab50c2451ba97a17839f331ac" +source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" dependencies = [ "getrandom 0.2.17", ] diff --git a/Cargo.toml b/Cargo.toml index 950091b714..53528a0e25 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,18 +34,18 @@ default = [] [dependencies] # Pin runtime dependencies directly so downstream consumers use the same native engine. -lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac" } -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac", features = ["std"] } -lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac" } -lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac" } -lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac" } -lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac" } -lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac", features = ["std"] } -lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac" } +lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16" } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16", features = ["std"] } +lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16" } +lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16" } +lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16" } +lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16" } +lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16", features = ["std"] } +lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16" } bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} @@ -81,7 +81,7 @@ prost = { version = "0.11.6", default-features = false} winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "0def88713b26264ab50c2451ba97a17839f331ac", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16", features = ["std", "_test_utils"] } proptest = "1.0.0" ring = "0.17.14" regex = "1.5.6" diff --git a/FFOR.md b/FFOR.md index 4562452b4c..79a427ed6f 100644 --- a/FFOR.md +++ b/FFOR.md @@ -289,9 +289,13 @@ Lookup precedes fresh liquidity selection. A native request without its applicat record refuses recovery rather than inventing a description or allocating again. Preparation uses the exact stored native parameters and a genuine retained-peer connection, then rejoins the same application record before persisting the returned -opaque selector. Selector lookup alone does not certify intent or readiness. A -bound record with missing native history also refuses replacement. Detaching a -caller leaves its durable intent recoverable and does not imply cancellation. +opaque selector. The separate historical recovery call checks every stored parameter +against native history without requiring a connection or mutating the native manager. +It can complete a lost selector-binding write using the same protected record and +exact-write recovery rules. Selector lookup alone does not certify intent or +readiness. A bound record with missing native history refuses replacement; absence +for an unbound intent does not itself authorize new allocation. Detaching a caller +leaves its durable intent recoverable and does not imply cancellation. The initial one-slot schema reserves 4 KiB per request, including future native binding, with at most 64 records and 256 KiB total. Unbound intents count toward @@ -302,7 +306,8 @@ retention policy are still required for repeated receives on one channel. Tests restore genuine empty and pending native channel fixtures through NodeBuilder. They cover exact retries, native intent mismatches, wrong or stale connections, missing application or native history, visible failed writes and restart, exact -binding recovery, identity substitution, corruption, replacement and quota refusal. +binding recovery, disconnected exact-history recovery without native mutation, +identity substitution, corruption, replacement and quota refusal. The bounded record codec also has arbitrary-byte property checks. Fixture provenance is in `src/ffor/request_store/fixtures/README.md`. No production caller constructs this owner yet. diff --git a/src/ffor/request_store/native.rs b/src/ffor/request_store/native.rs index 3c3f49d943..f70093b8e8 100644 --- a/src/ffor/request_store/native.rs +++ b/src/ffor/request_store/native.rs @@ -66,19 +66,7 @@ impl RequestStore { if connection.peer_node_id() != before.plan().settlement { return Err(RequestStoreError::Identity); } - let found = self - .manager - .find_ffor_receiver_request(before.local_request_id()) - .map_err(RequestStoreError::Native)?; - if let Some(retained) = before.selector() { - let actual = found.as_ref().ok_or(RequestStoreError::MissingNative)?; - if !retained.matches(actual) { - return Err(RequestStoreError::Conflict); - } - } - if found.as_ref().is_some_and(|id| id.channel_id() != before.plan().channel) { - return Err(RequestStoreError::Conflict); - } + let found = self.validate_native(&before)?; // There is no store mutex or I/O under native locks. &mut self excludes another store // operation, and the re-read below also catches unsupported external namespace replacement. let actual = self @@ -90,14 +78,51 @@ impl RequestStore { ) .map_err(RequestStoreError::Native)?; if found.is_some_and(|previous| previous != actual) - || self - .manager - .find_ffor_receiver_request(before.local_request_id()) - .map_err(RequestStoreError::Native)? - != Some(actual) + || self.validate_native(&before)? != Some(actual) { return Err(RequestStoreError::Conflict); } + self.rejoin_and_bind(client_id, &before, actual) + } + + /// Rejoin the exact retained native intent without a live peer or another preparation attempt. + /// This may complete a lost selector-binding write, but releases no Init or invoice and grants + /// no lifecycle authority. None means no native history and no stored selector were found; + /// absence alone is not permission to allocate a replacement. + pub(in crate::ffor) fn recover_native( + &mut self, client_id: &str, + ) -> Result, RequestStoreError> { + let before = self.lookup(client_id)?.ok_or(RequestStoreError::Missing)?; + let Some(actual) = self.validate_native(&before)? else { + return Ok(None); + }; + self.rejoin_and_bind(client_id, &before, actual).map(Some) + } + + fn validate_native( + &self, before: &StoredRequest, + ) -> Result, RequestStoreError> { + let found = self + .manager + .validate_ffor_receiver_request_intent( + before.local_request_id(), + &before.plan().channel, + &before.plan().settlement, + &before.plan().parameters, + ) + .map_err(RequestStoreError::Native)?; + if let Some(retained) = before.selector() { + let actual = found.as_ref().ok_or(RequestStoreError::MissingNative)?; + if !retained.matches(actual) { + return Err(RequestStoreError::Conflict); + } + } + Ok(found) + } + + fn rejoin_and_bind( + &mut self, client_id: &str, before: &StoredRequest, actual: FFORReceiverId, + ) -> Result { let mut current = self.lookup(client_id)?.ok_or(RequestStoreError::Missing)?; if current.encode() != before.encode() { return Err(RequestStoreError::Conflict); diff --git a/src/ffor/request_store/tests.rs b/src/ffor/request_store/tests.rs index 0eced21aac..011d281d2c 100644 --- a/src/ffor/request_store/tests.rs +++ b/src/ffor/request_store/tests.rs @@ -12,6 +12,7 @@ use lightning::util::persist::{ CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, CHANNEL_MONITOR_PERSISTENCE_PRIMARY_NAMESPACE, CHANNEL_MONITOR_PERSISTENCE_SECONDARY_NAMESPACE, }; +use lightning::util::ser::Writeable; use lightning_types::features::InitFeatures; use proptest::prelude::*; @@ -182,6 +183,58 @@ fn ffor_request_store_real_native_recovery_binds_only_exact_retained_intent() { assert!(node.list_payments().is_empty()); } +#[test] +fn ffor_request_store_disconnected_recovery_binds_lost_response_without_native_mutation() { + let (storage, node, mut store) = pending(); + let peer = plan(&store, CLIENT).settlement; + assert!(node.channel_manager.ffor_peer_connection(&peer).is_err()); + assert!(store.lookup(CLIENT).unwrap().unwrap().selector().is_none()); + let before = node.channel_manager.encode(); + let id = store.recover_native(CLIENT).unwrap().unwrap(); + assert_eq!(node.channel_manager.encode(), before); + assert!(store.lookup(CLIENT).unwrap().unwrap().selector().unwrap().matches(&id)); + let writes = storage.writes.load(Ordering::SeqCst); + assert_eq!(store.recover_native(CLIENT), Ok(Some(id))); + assert_eq!(storage.writes.load(Ordering::SeqCst), writes); + drop(store); + drop(node); + let restored = restore_node(Arc::clone(&storage)); + let mut reopened = open(storage, &restored); + assert!(restored.channel_manager.ffor_peer_connection(&peer).is_err()); + assert_eq!(reopened.recover_native(CLIENT), Ok(Some(id))); +} + +#[test] +fn ffor_request_store_disconnected_recovery_does_not_allocate_an_unbound_intent() { + let (storage, node, mut store) = fixture(); + store.begin(intent(CLIENT), plan(&store, CLIENT)).unwrap(); + let before = node.channel_manager.encode(); + let writes = storage.writes.load(Ordering::SeqCst); + assert_eq!(store.recover_native(CLIENT), Ok(None)); + assert_eq!(storage.writes.load(Ordering::SeqCst), writes); + assert_eq!(node.channel_manager.encode(), before); + assert!(store.lookup(CLIENT).unwrap().unwrap().selector().is_none()); +} + +#[test] +fn ffor_request_store_disconnected_binding_failure_keeps_exact_candidate_until_recovery() { + for failure in [1, 2] { + let (storage, node, mut store) = pending(); + store.lookup(CLIENT).unwrap(); + let before = node.channel_manager.encode(); + storage.write_failure.store(failure, Ordering::SeqCst); + assert_eq!(store.recover_native(CLIENT), Err(RequestStoreError::Storage)); + assert_eq!(store.recover_native(CLIENT), Err(RequestStoreError::Uncertain)); + assert_eq!(node.channel_manager.encode(), before); + store.recover_write().unwrap(); + let id = store.recover_native(CLIENT).unwrap().unwrap(); + assert_eq!(node.channel_manager.encode(), before); + drop(store); + let mut reopened = open(storage, &node); + assert_eq!(reopened.recover_native(CLIENT), Ok(Some(id))); + } +} + #[test] fn ffor_request_store_native_selector_does_not_certify_different_parameters() { let (storage, first, mut store) = fixture(); @@ -198,6 +251,10 @@ fn ffor_request_store_native_selector_does_not_certify_different_parameters() { .find_ffor_receiver_request(store.local_request_id(CLIENT).unwrap()) .unwrap() .is_some()); + assert_eq!( + store.recover_native(CLIENT), + Err(RequestStoreError::Native(FFORReceiverError::AlreadyRegistered)) + ); let connection = connect(&node, plan(&store, CLIENT).settlement); assert_eq!( store.prepare_native(CLIENT, &connection), @@ -213,6 +270,7 @@ fn ffor_request_store_missing_application_or_native_history_refuses_reconstructi let node = restore_node(Arc::clone(&storage)); let mut store = open(Arc::clone(&storage), &node); assert!(matches!(store.lookup(CLIENT), Err(RequestStoreError::Missing))); + assert_eq!(store.recover_native(CLIENT), Err(RequestStoreError::Missing)); assert!(matches!( store.begin(intent(CLIENT), plan(&store, CLIENT)), Err(RequestStoreError::Missing) @@ -228,6 +286,7 @@ fn ffor_request_store_missing_application_or_native_history_refuses_reconstructi install_fixture(&storage, false); let node = restore_node(Arc::clone(&storage)); let mut store = open(storage, &node); + assert_eq!(store.recover_native(CLIENT), Err(RequestStoreError::MissingNative)); let connection = connect(&node, plan(&store, CLIENT).settlement); assert_eq!(store.prepare_native(CLIENT, &connection), Err(RequestStoreError::MissingNative)); } From f580691ff1899136d2382735e6a73f970280b663 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 21 Sep 2026 03:36:07 -0400 Subject: [PATCH 20/23] feat: join protected witness acknowledgements to native persistence --- Cargo.lock | 30 +-- Cargo.toml | 26 +-- FFOR.md | 28 ++- src/ffor/witness_owner.rs | 35 +++- src/ffor/witness_owner/fetch/tests.rs | 7 +- src/ffor/witness_owner/pending.rs | 76 +++++++- src/ffor/witness_owner/provisioning.rs | 98 ++++++++-- src/ffor/witness_owner/tests.rs | 42 +++- src/ffor/witness_owner/tests/native_ack.rs | 215 +++++++++++++++++++++ src/ffor/witness_store/record.rs | 8 + src/ffor/witness_store/tests.rs | 8 +- src/message_handler/ffor.rs | 16 +- 12 files changed, 513 insertions(+), 76 deletions(-) create mode 100644 src/ffor/witness_owner/tests/native_ack.rs diff --git a/Cargo.lock b/Cargo.lock index fa3ccb7497..4d3252a29f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1995,7 +1995,7 @@ dependencies = [ [[package]] name = "lightning" version = "0.2.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" +source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" dependencies = [ "bech32", "bitcoin", @@ -2007,7 +2007,7 @@ dependencies = [ "lightning-macros 0.2.1", "lightning-types 0.3.5", "musig2", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562)", "regex", ] @@ -2027,7 +2027,7 @@ dependencies = [ [[package]] name = "lightning-background-processor" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" +source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" dependencies = [ "bitcoin", "bitcoin-io", @@ -2035,7 +2035,7 @@ dependencies = [ "lightning 0.2.5", "lightning-liquidity 0.2.3", "lightning-rapid-gossip-sync 0.2.3", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562)", ] [[package]] @@ -2054,7 +2054,7 @@ dependencies = [ [[package]] name = "lightning-block-sync" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" +source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" dependencies = [ "bitcoin", "chunked_transfer", @@ -2066,7 +2066,7 @@ dependencies = [ [[package]] name = "lightning-ffor" version = "0.1.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" +source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" dependencies = [ "bitcoin", ] @@ -2086,7 +2086,7 @@ dependencies = [ [[package]] name = "lightning-invoice" version = "0.34.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" +source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" dependencies = [ "bech32", "bitcoin", @@ -2112,7 +2112,7 @@ dependencies = [ [[package]] name = "lightning-liquidity" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" +source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" dependencies = [ "bitcoin", "chrono", @@ -2138,7 +2138,7 @@ dependencies = [ [[package]] name = "lightning-macros" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" +source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" dependencies = [ "proc-macro2", "quote", @@ -2159,7 +2159,7 @@ dependencies = [ [[package]] name = "lightning-net-tokio" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" +source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2180,7 +2180,7 @@ dependencies = [ [[package]] name = "lightning-persister" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" +source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2203,7 +2203,7 @@ dependencies = [ [[package]] name = "lightning-rapid-gossip-sync" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" +source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" dependencies = [ "bitcoin", "bitcoin-io", @@ -2228,7 +2228,7 @@ dependencies = [ [[package]] name = "lightning-transaction-sync" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" +source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" dependencies = [ "bitcoin", "electrum-client 0.24.1", @@ -2250,7 +2250,7 @@ dependencies = [ [[package]] name = "lightning-types" version = "0.3.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" +source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" dependencies = [ "bitcoin", ] @@ -2677,7 +2677,7 @@ dependencies = [ [[package]] name = "possiblyrandom" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=40d105c175e5580a605bfbb5334a8995ade9ae16#40d105c175e5580a605bfbb5334a8995ade9ae16" +source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" dependencies = [ "getrandom 0.2.17", ] diff --git a/Cargo.toml b/Cargo.toml index 53528a0e25..74bf7dc9db 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,18 +34,18 @@ default = [] [dependencies] # Pin runtime dependencies directly so downstream consumers use the same native engine. -lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16" } -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16", features = ["std"] } -lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16" } -lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16" } -lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16" } -lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16" } -lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16", features = ["std"] } -lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16" } +lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562" } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562", features = ["std"] } +lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562" } +lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562" } +lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562" } +lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562" } +lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562", features = ["std"] } +lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562" } bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} @@ -81,7 +81,7 @@ prost = { version = "0.11.6", default-features = false} winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "40d105c175e5580a605bfbb5334a8995ade9ae16", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562", features = ["std", "_test_utils"] } proptest = "1.0.0" ring = "0.17.14" regex = "1.5.6" diff --git a/FFOR.md b/FFOR.md index 79a427ed6f..b816a097d7 100644 --- a/FFOR.md +++ b/FFOR.md @@ -209,13 +209,27 @@ persistence under its transition locks; its callback only checks the witness's original transport token and enqueues. No storage or network operation holds those native locks. The settlement peer need not be connected for witness provisioning. -The owner stages exact response correlation before queue acceptance. Backpressure -keeps the same unsent request. Accepted requests are not resent after transport -drain; explicit timeout retry generates a fresh request ID with the same manifest. -Acknowledgements must match the actual witness, original connection and exact -pending request. The owner rejoins retained native identity and sidecar manifests -before storing a historical promise, then removes correlation only after a -successful write. Such a promise is not invoice authority. +The owner stages exact response correlation in both Node and native state before queue +acceptance. A sealed pair binds the native witness generation to the actual transport +token. Native marks an attempt sent only after the typed enqueue succeeds. Backpressure +keeps the same unsent request; accepted requests are not resent after transport drain. +Explicit timeout retry uses a fresh request ID with the same manifest. A candidate +correlation table preserves the original pending request if native staging refuses, +including when its independent 64-attempt/tombstone budget is full. + +Acknowledgements must match the actual witness, original connection and exact sent +request. The owner rejoins native history and protected manifests, confirms its sidecar +write, then retains the promise natively on that same authenticated generation. Pending +native writes keep correlation intact. A correctly correlated refusal retires the attempt +without creating a promise. Disconnect between the two successful writes retains the +first sidecar promise and requires a fresh native attempt after reconnect. + +Each layer preserves its own first valid promise. After a crash, their request IDs and +adequate retention values may differ. Recovery joins their exact epoch, manifest and +witness, confirms protected storage and waits for the latest native persistence barrier. +It never overwrites either promise to match transport IDs. The reported progress concerns +persistence only; it grants no invoice or payment authority. Restored native records need +a fresh successful manager write even when both historical promises are present. Historical fetches remain available after channel removal and admission expiry. Each traversal and retry uses fresh signed request identity and nonce. Every page diff --git a/src/ffor/witness_owner.rs b/src/ffor/witness_owner.rs index 2dcd840cf3..37cbfc5cb4 100644 --- a/src/ffor/witness_owner.rs +++ b/src/ffor/witness_owner.rs @@ -7,16 +7,16 @@ use std::sync::Arc; +use fetch::FetchRequests; use lightning::ln::ffor::FFORReceiverError; -use lightning_ffor::witness::{Acknowledgement, WitnessError}; +use lightning_ffor::witness::{Acknowledgement, AcknowledgementResult, WitnessError}; +use pending::PendingProvisions; use super::witness_store::{ WitnessKeyUseError, WitnessSecretStore, WitnessStorageBinding, WitnessStoreError, }; use crate::message_handler::ffor::{FforReceiverTransport, ReceivedFforMessage}; use crate::types::{ChainMonitor, ChannelManager}; -use fetch::FetchRequests; -use pending::PendingProvisions; mod fetch; mod pending; @@ -38,10 +38,12 @@ pub(crate) enum WitnessOwnerError { Unregistered, } -/// Storage completion only. This is deliberately not a provisioning or invoice readiness value. +/// Persistence observations only, never invoice readiness or current receiving authority. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum AcknowledgementProgress { + AwaitingPersistence, Retained, + Refused, } pub(crate) struct WitnessOwner { @@ -79,7 +81,21 @@ impl WitnessOwner { } let ack = Acknowledgement::decode(message.frame().wire()).map_err(WitnessOwnerError::Protocol)?; - let (epoch, checked) = self.pending.check(message, &ack)?; + let (epoch, native) = self.pending.native_for(message, &ack)?; + if matches!(ack.result(), AcknowledgementResult::Refused(_)) { + return match self + .manager + .retain_ffor_receiver_witness_ack(native.connection.native(), &ack) + { + Err(FFORReceiverError::InvalidWitnessRegistration) => { + self.pending.complete(ack.request_id()); + Ok(AcknowledgementProgress::Refused) + }, + Err(error) => Err(WitnessOwnerError::Native(error)), + Ok(_) => Err(WitnessOwnerError::Conflict), + }; + } + let (_, checked) = self.pending.check(message, &ack)?; let context = self .manager .ffor_receiver_recovery_context(&epoch.channel, epoch.epoch) @@ -91,6 +107,15 @@ impl WitnessOwner { let binding = WitnessStorageBinding::from_native_context(&context) .map_err(WitnessOwnerError::Storage)?; self.store.acknowledge(&binding, &checked).map_err(WitnessOwnerError::Storage)?; + // The protected store lock is released before native checks its genuine W generation. + // If disconnect intervenes, keep the first stored promise and recover with a fresh attempt. + let requirement = self + .manager + .retain_ffor_receiver_witness_ack(native.connection.native(), &ack) + .map_err(WitnessOwnerError::Native)?; + if !self.manager.is_ffor_state_persisted(&requirement) { + return Ok(AcknowledgementProgress::AwaitingPersistence); + } self.pending.complete(ack.request_id()); Ok(AcknowledgementProgress::Retained) } diff --git a/src/ffor/witness_owner/fetch/tests.rs b/src/ffor/witness_owner/fetch/tests.rs index 277b5231b6..f05c231179 100644 --- a/src/ffor/witness_owner/fetch/tests.rs +++ b/src/ffor/witness_owner/fetch/tests.rs @@ -1,7 +1,8 @@ use std::sync::atomic::Ordering; use bitcoin::hashes::{sha256, Hash, HashEngine, Hmac, HmacEngine}; -use bitcoin::secp256k1::{ecdh::SharedSecret, Message, Secp256k1, SecretKey}; +use bitcoin::secp256k1::ecdh::SharedSecret; +use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning::ln::wire::CustomMessageReader; use lightning_ffor::witness::{EncryptedRecord, FetchResult, RecordHeader, SignedManifest}; @@ -154,7 +155,7 @@ fn ffor_witness_fetch_real_retry_correlates_identity_and_empty_response_grants_n let wrong = receive(&h, public(81), &response(&second, vec![], None)); assert!(matches!(h.owner.accept_fetch_page(&wrong), Err(WitnessOwnerError::Protocol(_)))); let stale = receive(&h, witness, &response(&second, vec![], None)); - h.handler.peer_disconnected(witness); + h.disconnect(witness); h.connect(witness); assert_eq!(h.owner.accept_fetch_page(&stale), Err(WitnessOwnerError::StaleConnection)); assert_eq!(h.owner.fetch(&h.context, witness), Ok(FetchProgress::Queued)); @@ -207,7 +208,7 @@ fn ffor_witness_fetch_real_storage_failure_keeps_page_through_disconnect_and_ret h.owner.accept_fetch_page(&page), Err(WitnessOwnerError::Storage(WitnessStoreError::Storage)) ); - h.handler.peer_disconnected(witness); + h.disconnect(witness); h.owner.synchronize_connections(); assert_eq!(h.owner.fetches.usage().0, 1); assert_eq!( diff --git a/src/ffor/witness_owner/pending.rs b/src/ffor/witness_owner/pending.rs index 8aa952e7b5..a954e8a68e 100644 --- a/src/ffor/witness_owner/pending.rs +++ b/src/ffor/witness_owner/pending.rs @@ -2,16 +2,19 @@ use std::sync::Arc; +use lightning::ln::ffor::FFORWitnessProvisionAttempt; use lightning::ln::types::ChannelId; use lightning_ffor::witness::{ Acknowledgement, CheckedAcknowledgement, PendingProvision, Provision, SignedManifest, WitnessConnection, }; -use rand::{rngs::OsRng, TryRngCore}; - -use crate::message_handler::ffor::{ConnectionToken, FforReceiverTransport, ReceivedFforMessage}; +use rand::rngs::OsRng; +use rand::TryRngCore; use super::WitnessOwnerError; +use crate::message_handler::ffor::{ + ConnectionToken, FforReceiverTransport, NativeConnection, ReceivedFforMessage, +}; const MAX_PENDING: usize = 64; const MAX_PENDING_BYTES: usize = 8 * 1024 * 1024; @@ -24,13 +27,24 @@ pub(super) struct Epoch { pub(super) context_digest: [u8; 32], } +#[derive(Clone)] struct Entry { epoch: Epoch, request: Arc>, encoded_bytes: usize, queued: bool, + native: Option, +} + +#[derive(Clone)] +pub(super) struct NativeAttempt { + pub(super) connection: NativeConnection, + pub(super) attempt: FFORWitnessProvisionAttempt, } +// Cloning this bounded table shares all retained wire allocations. The owner stages changes in +// a candidate table and commits it only after native admission succeeds, preserving its predecessor. +#[derive(Clone)] pub(super) struct PendingProvisions { entries: Vec, encoded_bytes: usize, @@ -151,8 +165,13 @@ impl PendingProvisions { return Err(WitnessOwnerError::Capacity); } let request = Arc::new(PendingProvision::new(provision, source)); - let entry = - Entry { epoch, request: Arc::clone(&request), encoded_bytes: bytes, queued: false }; + let entry = Entry { + epoch, + request: Arc::clone(&request), + encoded_bytes: bytes, + queued: false, + native: None, + }; if let Some(index) = previous { self.entries[index] = entry; } else { @@ -162,6 +181,45 @@ impl PendingProvisions { Ok(request) } + pub(super) fn bind_native( + &mut self, request: &Arc>, native: NativeAttempt, + ) { + let entry = self + .entries + .iter_mut() + .find(|entry| Arc::ptr_eq(&entry.request, request)) + .expect("candidate retained until native admission completes"); + entry.native = Some(native); + } + + pub(super) fn native_attempt( + &self, request: &Arc>, + ) -> Option<&NativeAttempt> { + self.entries + .iter() + .find(|entry| Arc::ptr_eq(&entry.request, request)) + .and_then(|entry| entry.native.as_ref()) + } + + pub(super) fn native_for( + &self, message: &ReceivedFforMessage, ack: &Acknowledgement, + ) -> Result<(Epoch, NativeAttempt), WitnessOwnerError> { + let entry = self + .entries + .iter() + .find(|entry| { + entry.queued && entry.request.provision().request_id() == ack.request_id() + }) + .ok_or(WitnessOwnerError::UnknownRequest)?; + let native = entry.native.as_ref().ok_or(WitnessOwnerError::StaleConnection)?; + if entry.request.connection().node_id != message.peer() + || native.connection.transport() != message.connection() + { + return Err(WitnessOwnerError::StaleConnection); + } + Ok((entry.epoch, native.clone())) + } + pub(super) fn is_queued(&self, request: &Arc>) -> bool { self.entries.iter().any(|entry| Arc::ptr_eq(&entry.request, request) && entry.queued) } @@ -205,6 +263,14 @@ impl PendingProvisions { Ok((entry.epoch, checked)) } + pub(super) fn complete_witness( + &mut self, epoch: Epoch, witness: bitcoin::secp256k1::PublicKey, + ) { + self.entries + .retain(|entry| entry.epoch != epoch || entry.request.connection().node_id != witness); + self.encoded_bytes = self.entries.iter().map(|entry| entry.encoded_bytes).sum(); + } + pub(super) fn complete(&mut self, request_id: [u8; 16]) { if let Some(index) = self .entries diff --git a/src/ffor/witness_owner/provisioning.rs b/src/ffor/witness_owner/provisioning.rs index c433b065d2..010de64d00 100644 --- a/src/ffor/witness_owner/provisioning.rs +++ b/src/ffor/witness_owner/provisioning.rs @@ -8,7 +8,7 @@ use bitcoin::secp256k1::PublicKey; use lightning::ln::ffor::{FFORReceiverRecoveryContext, FFORReceiverWitnessRegistration}; use lightning_ffor::witness::{SignedManifest, WitnessConnection}; -use super::pending::Epoch; +use super::pending::{Epoch, NativeAttempt}; use super::{WitnessOwner, WitnessOwnerError}; use crate::ffor::witness_store::{WitnessPolicy, WitnessStorageBinding}; use crate::message_handler::ffor::OutboundError; @@ -27,6 +27,8 @@ pub(crate) enum ProvisioningProgress { Backpressured, Queued, AwaitingAcknowledgement, + /// Both historical promises are retained and the current native requirement is complete. + AcknowledgementRetained, } enum Attempt { @@ -96,6 +98,13 @@ impl WitnessOwner { if !self.manager.is_ffor_state_persisted(&requirement) { return Ok(ProvisioningProgress::AwaitingPersistence); } + let manifest = manifests + .into_iter() + .find_map(|(selected, manifest)| (selected == witness).then_some(manifest)) + .ok_or(WitnessOwnerError::UnknownWitness)?; + let acknowledged = self.acknowledgement_retained(context, witness, &manifest)?; + // Storage and historical ACK reads may race a newer native promise. Capture after the + // join so its latest durability barrier defines the observation point for this result. let active = self .manager .capture_ffor_receiver_active_context( @@ -104,45 +113,71 @@ impl WitnessOwner { context.epoch_id(), ) .map_err(WitnessOwnerError::Native)?; - let manifest = manifests - .into_iter() - .find_map(|(selected, manifest)| (selected == witness).then_some(manifest)) - .ok_or(WitnessOwnerError::UnknownWitness)?; + if acknowledged { + self.pending.complete_witness( + Epoch { + channel: context.channel_id(), + epoch: context.epoch_id(), + context_digest: context.context_digest(), + }, + witness, + ); + return Ok(ProvisioningProgress::AcknowledgementRetained); + } let connection = - self.transport.connection(witness).ok_or(WitnessOwnerError::StaleConnection)?; + self.transport.native_connection(witness).ok_or(WitnessOwnerError::StaleConnection)?; let epoch = Epoch { channel: context.channel_id(), epoch: context.epoch_id(), context_digest: context.context_digest(), }; - let source = WitnessConnection { node_id: witness, identity: connection.clone() }; + let source = + WitnessConnection { node_id: witness, identity: connection.transport().clone() }; let (other_count, other_bytes) = self.fetches.usage(); self.pending.set_other_usage(other_count, other_bytes)?; let fetches = &self.fetches; + // Preserve the previous request and accounting if native staging refuses the replacement. + let mut candidate = self.pending.clone(); let pending = match attempt { - Attempt::Advance => self - .pending + Attempt::Advance => candidate .stage_excluding(epoch, source, manifest, |id| fetches.contains_request_id(id))?, - Attempt::Retry => self - .pending + Attempt::Retry => candidate .restart_excluding(epoch, source, manifest, |id| fetches.contains_request_id(id))?, }; - if self.pending.is_queued(&pending) { + if candidate.is_queued(&pending) { return Ok(ProvisioningProgress::AwaitingAcknowledgement); } + let native_attempt = match candidate.native_attempt(&pending) { + Some(retained) => retained.attempt.clone(), + None => self + .manager + .stage_ffor_receiver_witness_provision( + context, + connection.native(), + pending.provision(), + ) + .map_err(WitnessOwnerError::Native)?, + }; + candidate.bind_native( + &pending, + NativeAttempt { connection: connection.clone(), attempt: native_attempt.clone() }, + ); + self.pending = candidate; let transport = Arc::clone(&self.transport); let mut refusal = None; let queued = self .manager - .release_ffor_receiver_witness_provision( + .release_ffor_receiver_witness_attempt( &active, - &witness, + &native_attempt, pending.provision(), |provision| { // This is the sole nested native -> transport lock acquisition. No owner/store reentry. - transport.enqueue_provision(witness, &connection, provision).map_err(|error| { - refusal = Some(error); - }) + transport.enqueue_provision(witness, connection.transport(), provision).map_err( + |error| { + refusal = Some(error); + }, + ) }, ) .map_err(WitnessOwnerError::Native)?; @@ -161,6 +196,35 @@ impl WitnessOwner { } } + fn acknowledgement_retained( + &self, context: &FFORReceiverRecoveryContext, witness: PublicKey, manifest: &SignedManifest, + ) -> Result { + let binding = WitnessStorageBinding::from_native_context(context) + .map_err(WitnessOwnerError::Storage)?; + let stored = self.store.load(&binding).map_err(WitnessOwnerError::Storage)?; + let required = manifest.unsigned().parameters().retention_until; + if stored.acknowledgement_retention(witness).filter(|until| *until >= required).is_none() { + return Ok(false); + } + let native = self + .manager + .ffor_receiver_witness_acknowledgements(context) + .map_err(WitnessOwnerError::Native)?; + let Some(native) = native else { + return Ok(false); + }; + if native.context_digest() != context.context_digest() { + return Err(WitnessOwnerError::Conflict); + } + let digest = sha256::Hash::hash(&manifest.encode()).to_byte_array(); + // Independent first acknowledgements may have different request IDs after a crash. + Ok(native.acknowledgements().iter().any(|ack| { + ack.witness_node_id() == witness + && ack.manifest_digest() == digest + && ack.retention_until() >= required + })) + } + pub(super) fn retained_manifests( &self, context: &FFORReceiverRecoveryContext, ) -> Result { diff --git a/src/ffor/witness_owner/tests.rs b/src/ffor/witness_owner/tests.rs index 57372bfbf2..1430e26a3a 100644 --- a/src/ffor/witness_owner/tests.rs +++ b/src/ffor/witness_owner/tests.rs @@ -6,7 +6,7 @@ use std::sync::atomic::Ordering; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::Network; use lightning::ln::ffor::FFORReceiverRecoveryContext; -use lightning::ln::msgs::Init; +use lightning::ln::msgs::{BaseMessageHandler, Init}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning::ln::wire::CustomMessageReader; use lightning::util::persist::{ @@ -23,6 +23,7 @@ use super::*; use crate::builder::NodeBuilder; use crate::ffor::witness_store::tests::TestStore; use crate::ffor::witness_store::WitnessPolicy; +use crate::message_handler::ffor::FforSetupAdapter; use crate::message_handler::{NodeCustomMessage, NodeCustomMessageHandler}; use crate::types::DynStore; use crate::{Config, Node}; @@ -87,8 +88,12 @@ impl Harness { node.channel_manager.list_ffor_receiver_recovery_contexts().unwrap().remove(0); assert_eq!(context.settlement_node_id(), PublicKey::from_str(field("settlement")).unwrap()); let transport = Arc::new(FforReceiverTransport::default()); - let handler = - NodeCustomMessageHandler::new_ignoring().with_ffor_receiver(Arc::clone(&transport)); + let adapter = Arc::new(FforSetupAdapter::new( + Arc::clone(&node.channel_manager), + Arc::clone(&node.chain_monitor), + Arc::clone(&transport), + )); + let handler = NodeCustomMessageHandler::new_ignoring().with_ffor_setup(adapter); let backing: Arc = storage.clone(); let store = Arc::new(WitnessSecretStore::open(&SEED, backing).unwrap()); let owner = WitnessOwner::new( @@ -120,11 +125,18 @@ impl Harness { } pub(super) fn connect(&self, peer: PublicKey) { - let init = - Init { features: InitFeatures::empty(), networks: None, remote_network_address: None }; + let mut features = InitFeatures::empty(); + features.set_static_remote_key_optional(); + let init = Init { features, networks: None, remote_network_address: None }; + self.node.channel_manager.peer_connected(peer, &init, false).unwrap(); self.handler.peer_connected(peer, &init, false).unwrap(); } + pub(super) fn disconnect(&self, peer: PublicKey) { + self.node.channel_manager.peer_disconnected(peer); + self.handler.peer_disconnected(peer); + } + pub(super) fn register_and_persist(&mut self) { self.persist_manager(); assert_eq!( @@ -224,6 +236,8 @@ fn ffor_witness_owner_real_restore_registration_and_queue_require_durability() { assert_eq!(h.owner.acknowledge(&old), Err(WitnessOwnerError::UnknownRequest)); let current = h.receive(w, &h.accepted(&second)); let before = h.secret_bytes(); + assert_eq!(h.owner.acknowledge(¤t), Ok(AcknowledgementProgress::AwaitingPersistence)); + h.persist_manager(); assert_eq!(h.owner.acknowledge(¤t), Ok(AcknowledgementProgress::Retained)); assert_ne!(before, h.secret_bytes()); assert_eq!(h.owner.pending.usage(), (0, 0)); @@ -240,9 +254,9 @@ fn ffor_witness_owner_real_ack_correlates_connection_and_retains_failed_write() let request = h.outgoing().remove(0); let ack = h.accepted(&request); let wrong = h.receive(key(81), &ack); - assert!(matches!(h.owner.acknowledge(&wrong), Err(WitnessOwnerError::Protocol(_)))); + assert!(matches!(h.owner.acknowledge(&wrong), Err(WitnessOwnerError::StaleConnection))); let stale = h.receive(w, &ack); - h.handler.peer_disconnected(w); + h.disconnect(w); h.connect(w); assert_eq!(h.owner.acknowledge(&stale), Err(WitnessOwnerError::StaleConnection)); assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::Queued)); @@ -255,10 +269,20 @@ fn ffor_witness_owner_real_ack_correlates_connection_and_retains_failed_write() h.owner.acknowledge(&input), Err(WitnessOwnerError::Storage(WitnessStoreError::Storage)) ); + assert!(h + .node + .channel_manager + .ffor_receiver_witness_acknowledgements(&h.context) + .unwrap() + .unwrap() + .acknowledgements() + .is_empty()); let visible_candidate = h.secret_bytes(); assert_ne!(before, visible_candidate); assert_eq!(h.owner.pending.usage().0, 1); h.owner.recover_storage().unwrap(); + assert_eq!(h.owner.acknowledge(&input), Ok(AcknowledgementProgress::AwaitingPersistence)); + h.persist_manager(); assert_eq!(h.owner.acknowledge(&input), Ok(AcknowledgementProgress::Retained)); assert_eq!(visible_candidate, h.secret_bytes()); assert_eq!(h.owner.pending.usage(), (0, 0)); @@ -337,6 +361,8 @@ fn ffor_witness_owner_real_channel_removal_refuses_release_but_retains_historica assert!(h.outgoing().is_empty()); assert_eq!(h.owner.retained_manifests(&h.context).unwrap()[0].1, *provision.manifest()); // This is a historical storage promise. It never restores the removed channel's authority. + assert_eq!(h.owner.acknowledge(&response), Ok(AcknowledgementProgress::AwaitingPersistence)); + h.persist_manager(); assert_eq!(h.owner.acknowledge(&response), Ok(AcknowledgementProgress::Retained)); assert!(h.node.channel_manager.list_channels().is_empty()); } @@ -453,3 +479,5 @@ fn ffor_witness_owner_real_fetch_admission_counts_pending_provisions() { matches!(&messages[0].1, NodeCustomMessage::Ffor(frame) if frame.wire()[..2] == 55059u16.to_be_bytes()) ); } + +mod native_ack; diff --git a/src/ffor/witness_owner/tests/native_ack.rs b/src/ffor/witness_owner/tests/native_ack.rs new file mode 100644 index 0000000000..3d222d21d1 --- /dev/null +++ b/src/ffor/witness_owner/tests/native_ack.rs @@ -0,0 +1,215 @@ +//! Real native W generations and persistence barriers, with crash cuts between the two stores. + +use super::*; + +fn retain_sidecar_only(h: &mut Harness, request: &Provision) -> Vec { + let w = h.policies[0].witness; + let ack = Acknowledgement::new( + request.request_id(), + AcknowledgementResult::Accepted { + witness: w, + retention_until: request.manifest().unsigned().parameters().retention_until + 1, + }, + ) + .unwrap(); + let message = h.receive(w, &ack); + let (_, checked) = h.owner.pending.check(&message, &ack).unwrap(); + let binding = WitnessStorageBinding::from_native_context(&h.context).unwrap(); + h.owner.store.acknowledge(&binding, &checked).unwrap(); + assert!(h + .node + .channel_manager + .ffor_receiver_witness_acknowledgements(&h.context) + .unwrap() + .unwrap() + .acknowledgements() + .is_empty()); + h.secret_bytes() +} + +#[test] +fn ffor_witness_owner_first_ack_ids_join_after_crash_or_disconnect() { + for restart in [false, true] { + let mut h = Harness::new(); + h.register_and_persist(); + let w = h.policies[0].witness; + h.connect(w); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::Queued)); + let first = h.outgoing().remove(0); + let first_envelope = retain_sidecar_only(&mut h, &first); + if restart { + // The durable manager still has registration but no native ACK; the protected write + // above succeeded. A new owner must confirm it and receive a genuinely fresh W token. + let storage = Arc::clone(&h.storage); + drop(h); + h = Harness::from_store(storage); + h.connect(w); + assert_eq!( + h.owner.provision(&h.context, w), + Ok(ProvisioningProgress::AwaitingPersistence) + ); + h.persist_manager(); + } else { + h.disconnect(w); + h.connect(w); + } + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::Queued)); + let second = h.outgoing().remove(0); + assert_ne!(first.request_id(), second.request_id()); + assert_eq!(first.manifest(), second.manifest()); + let ack = Acknowledgement::new( + second.request_id(), + AcknowledgementResult::Accepted { + witness: w, + retention_until: second.manifest().unsigned().parameters().retention_until + 2, + }, + ) + .unwrap(); + let response = h.receive(w, &ack); + assert_eq!( + h.owner.acknowledge(&response), + Ok(AcknowledgementProgress::AwaitingPersistence) + ); + assert_eq!(h.secret_bytes(), first_envelope, "first sidecar promise must remain immutable"); + let native = h + .node + .channel_manager + .ffor_receiver_witness_acknowledgements(&h.context) + .unwrap() + .unwrap(); + assert_eq!(native.acknowledgements()[0].request_id(), second.request_id()); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::AwaitingPersistence)); + h.persist_manager(); + assert_eq!( + h.owner.provision(&h.context, w), + Ok(ProvisioningProgress::AcknowledgementRetained) + ); + assert_eq!(h.owner.pending.usage(), (0, 0)); + assert!(h.outgoing().is_empty()); + // A fresh restored barrier still applies even when both historical promises exist. + let storage = Arc::clone(&h.storage); + drop(h); + let mut restored = Harness::from_store(storage); + assert_eq!( + restored.owner.provision(&restored.context, w), + Ok(ProvisioningProgress::AwaitingPersistence) + ); + restored.persist_manager(); + assert_eq!( + restored.owner.provision(&restored.context, w), + Ok(ProvisioningProgress::AcknowledgementRetained) + ); + } +} + +#[test] +fn ffor_witness_owner_native_capacity_refusal_preserves_predecessor() { + let mut h = Harness::new(); + h.register_and_persist(); + let w = h.policies[0].witness; + h.connect(w); + let mut previous = None; + for _ in 0..64 { + assert_eq!(h.owner.retry_provision(&h.context, w), Ok(ProvisioningProgress::Queued)); + previous = Some(h.outgoing().remove(0)); + } + let previous = previous.unwrap(); + let usage = h.owner.pending.usage(); + assert!(matches!(h.owner.retry_provision(&h.context, w), Err(WitnessOwnerError::Native(_)))); + assert_eq!(h.owner.pending.usage(), usage); + assert!(h.owner.pending.contains_request_id(previous.request_id())); + assert!(h.outgoing().is_empty()); + let response = h.receive(w, &h.accepted(&previous)); + assert_eq!(h.owner.acknowledge(&response), Ok(AcknowledgementProgress::AwaitingPersistence)); + h.persist_manager(); + assert_eq!(h.owner.acknowledge(&response), Ok(AcknowledgementProgress::Retained)); +} + +#[test] +fn ffor_witness_owner_correlated_refusal_retires_exact_attempt() { + let mut h = Harness::new(); + h.register_and_persist(); + let w = h.policies[0].witness; + h.connect(w); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::Queued)); + let request = h.outgoing().remove(0); + let refused = + Acknowledgement::new(request.request_id(), AcknowledgementResult::Refused(Vec::new())) + .unwrap(); + let response = h.receive(w, &refused); + assert_eq!(h.owner.acknowledge(&response), Ok(AcknowledgementProgress::Refused)); + assert_eq!(h.owner.pending.usage(), (0, 0)); + let connection = h.owner.transport.native_connection(w).unwrap(); + assert!(h + .node + .channel_manager + .retain_ffor_receiver_witness_ack(connection.native(), &h.accepted(&request)) + .is_err()); + assert_eq!(h.owner.retry_provision(&h.context, w), Ok(ProvisioningProgress::Queued)); + assert_ne!(h.outgoing().remove(0).request_id(), request.request_id()); +} + +#[test] +fn ffor_witness_owner_native_disconnect_after_storage_keeps_first_promise() { + let mut h = Harness::new(); + h.register_and_persist(); + let w = h.policies[0].witness; + h.connect(w); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::Queued)); + let request = h.outgoing().remove(0); + let response = h.receive(w, &h.accepted(&request)); + let before = h.secret_bytes(); + // Native disconnect precedes the custom handler callback in PeerManager. The old Node + // token is briefly present, but native must reject its captured generation after storage. + h.node.channel_manager.peer_disconnected(w); + assert!(matches!(h.owner.acknowledge(&response), Err(WitnessOwnerError::Native(_)))); + assert_ne!(before, h.secret_bytes()); + assert!(h + .node + .channel_manager + .ffor_receiver_witness_acknowledgements(&h.context) + .unwrap() + .unwrap() + .acknowledgements() + .is_empty()); + h.handler.peer_disconnected(w); + h.connect(w); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::Queued)); + assert_ne!(h.outgoing().remove(0).request_id(), request.request_id()); +} + +#[test] +fn ffor_witness_owner_join_requires_barrier_after_historical_storage_reads() { + let mut h = Harness::new(); + h.register_and_persist(); + let w = h.policies[0].witness; + h.connect(w); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::Queued)); + let request = h.outgoing().remove(0); + retain_sidecar_only(&mut h, &request); + let manager = Arc::clone(&h.node.channel_manager); + let connection = h.owner.transport.native_connection(w).unwrap(); + let ack = h.accepted(&request); + let mut secret_reads = 0; + let observed = Arc::new(std::sync::Mutex::new(None)); + let requirement = Arc::clone(&observed); + *h.storage.read_hook.lock().unwrap() = Some(Box::new(move |namespace| { + if namespace == "ffor_witness" { + secret_reads += 1; + if secret_reads == 2 { + // Retain through the actual native API during the second protected read, after the + // initial registration barrier was observed but before the historical ACK join. + *requirement.lock().unwrap() = Some( + manager.retain_ffor_receiver_witness_ack(connection.native(), &ack).unwrap(), + ); + } + } + })); + assert!(matches!(h.owner.provision(&h.context, w), Err(WitnessOwnerError::Native(_)))); + *h.storage.read_hook.lock().unwrap() = None; + let requirement = observed.lock().unwrap().take().expect("native ACK was retained during join"); + assert!(!h.node.channel_manager.is_ffor_state_persisted(&requirement)); + assert!(h.outgoing().is_empty()); + h.persist_manager(); + assert_eq!(h.owner.provision(&h.context, w), Ok(ProvisioningProgress::AcknowledgementRetained)); +} diff --git a/src/ffor/witness_store/record.rs b/src/ffor/witness_store/record.rs index 29abc5623f..45877e6b4b 100644 --- a/src/ffor/witness_store/record.rs +++ b/src/ffor/witness_store/record.rs @@ -160,6 +160,14 @@ impl StoredWitnessEpoch { && self.witnesses.iter().all(|entry| entry.acknowledgement.is_some()) } + /// Confirmed historical retention for the immutable manifest, with no current phase authority. + pub(in crate::ffor) fn acknowledgement_retention(&self, witness: PublicKey) -> Option { + self.witnesses + .iter() + .find(|entry| entry.policy.witness == witness) + .and_then(|entry| entry.acknowledgement.map(|ack| ack.retention_until)) + } + /// Retain the first correlated promise for this exact witness and immutable manifest. /// A later reprovision may use a new request ID; it cannot replace the original evidence. pub(super) fn retain_acknowledgement( diff --git a/src/ffor/witness_store/tests.rs b/src/ffor/witness_store/tests.rs index c065015892..b3b3fcb0b4 100644 --- a/src/ffor/witness_store/tests.rs +++ b/src/ffor/witness_store/tests.rs @@ -15,9 +15,10 @@ use lightning_ffor::witness::{ }; use proptest::prelude::*; +use super::*; use crate::io::test_utils::InMemoryStore; -use super::*; +type ReadHook = Box; pub(in crate::ffor) struct TestStore { pub(in crate::ffor) inner: InMemoryStore, @@ -25,6 +26,7 @@ pub(in crate::ffor) struct TestStore { pub(in crate::ffor) write_failure: AtomicUsize, pub(in crate::ffor) fail_at: AtomicUsize, pub(in crate::ffor) read_failure: AtomicBool, + pub(in crate::ffor) read_hook: Mutex>, } impl TestStore { @@ -35,6 +37,7 @@ impl TestStore { write_failure: AtomicUsize::new(0), fail_at: AtomicUsize::new(0), read_failure: AtomicBool::new(false), + read_hook: Mutex::new(None), }) } pub(in crate::ffor) fn bytes(&self, key: &str) -> Vec { @@ -51,6 +54,9 @@ impl KVStoreSync for TestStore { if self.read_failure.load(Ordering::SeqCst) { return Err(io::Error::new(io::ErrorKind::Other, "injected read failure")); } + if let Some(hook) = self.read_hook.lock().unwrap().as_mut() { + hook(primary); + } KVStoreSync::read(&self.inner, primary, secondary, key) } fn write(&self, primary: &str, secondary: &str, key: &str, bytes: Vec) -> io::Result<()> { diff --git a/src/message_handler/ffor.rs b/src/message_handler/ffor.rs index bb44ecf477..08ba09a9ec 100644 --- a/src/message_handler/ffor.rs +++ b/src/message_handler/ffor.rs @@ -11,7 +11,6 @@ //! this transport disabled. pub(super) mod setup; - use std::collections::{HashMap, VecDeque}; use std::fmt; use std::sync::{Arc, Mutex}; @@ -25,6 +24,8 @@ use lightning::util::logger::Level; use lightning::util::ser::{LengthLimitedRead, Writeable, Writer}; use lightning_ffor::wire::{Message, MAX_MESSAGE_LEN}; use lightning_ffor::witness::{Acknowledgement, FetchResponse, Provision, SignedFetch}; +#[cfg(test)] +pub(crate) use setup::FforSetupAdapter; const MAX_PEERS: usize = 64; const MAX_PEER_MESSAGES: usize = 8; @@ -160,11 +161,20 @@ struct PeerState { /// Both observations belong to the same successful PeerManager callback. Native still checks /// its generation under the channel lock, and enqueue separately checks the transport token. #[derive(Clone)] -struct NativeConnection { +pub(crate) struct NativeConnection { native: FFORPeerConnection, transport: ConnectionToken, } +impl NativeConnection { + pub(crate) fn native(&self) -> &FFORPeerConnection { + &self.native + } + pub(crate) fn transport(&self) -> &ConnectionToken { + &self.transport + } +} + #[derive(Default)] struct QueueState { peers: HashMap, @@ -228,7 +238,7 @@ impl FforReceiverTransport { } } - fn native_connection(&self, peer: PublicKey) -> Option { + pub(crate) fn native_connection(&self, peer: PublicKey) -> Option { let state = self.state.lock().unwrap(); let entry = state.peers.get(&peer)?; Some(NativeConnection { From b9f37d1893b8b7aa628a4092dff2b0082c5d56fa Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 21 Sep 2026 09:26:38 -0400 Subject: [PATCH 21/23] feat: retain exact receiver invoices with durable payment confirmation --- Cargo.lock | 48 +- Cargo.toml | 26 +- FFOR.md | 60 +- src/data_store.rs | 7 +- src/data_store/ffor.rs | 137 +++++ src/data_store/ffor/tests.rs | 275 +++++++++ src/ffor/request_store.rs | 112 +++- src/ffor/request_store/envelope.rs | 19 +- .../request_store/fixtures/invoice/README.md | 18 + .../fixtures/invoice/active-manager.bin | Bin 0 -> 7805 bytes .../fixtures/invoice/fixture.txt | 28 + .../fixtures/invoice/issued-manager.bin | Bin 0 -> 8986 bytes .../fixtures/invoice/monitor.bin | Bin 0 -> 6351 bytes .../fixtures/invoice/route-announcement.bin | Bin 0 -> 430 bytes .../fixtures/invoice/route-update.bin | Bin 0 -> 136 bytes .../fixtures/invoice/witness-manifest.bin | Bin 0 -> 365 bytes src/ffor/request_store/invoice.rs | 346 +++++++++++ src/ffor/request_store/native.rs | 8 +- src/ffor/request_store/record.rs | 140 ++++- src/ffor/request_store/record/invoice.rs | 190 ++++++ src/ffor/request_store/tests.rs | 8 +- src/ffor/request_store/tests/invoice.rs | 251 ++++++++ src/ffor/request_store/tests/issuer.rs | 547 ++++++++++++++++++ 23 files changed, 2137 insertions(+), 83 deletions(-) create mode 100644 src/data_store/ffor.rs create mode 100644 src/data_store/ffor/tests.rs create mode 100644 src/ffor/request_store/fixtures/invoice/README.md create mode 100644 src/ffor/request_store/fixtures/invoice/active-manager.bin create mode 100644 src/ffor/request_store/fixtures/invoice/fixture.txt create mode 100644 src/ffor/request_store/fixtures/invoice/issued-manager.bin create mode 100644 src/ffor/request_store/fixtures/invoice/monitor.bin create mode 100644 src/ffor/request_store/fixtures/invoice/route-announcement.bin create mode 100644 src/ffor/request_store/fixtures/invoice/route-update.bin create mode 100644 src/ffor/request_store/fixtures/invoice/witness-manifest.bin create mode 100644 src/ffor/request_store/invoice.rs create mode 100644 src/ffor/request_store/record/invoice.rs create mode 100644 src/ffor/request_store/tests/invoice.rs create mode 100644 src/ffor/request_store/tests/issuer.rs diff --git a/Cargo.lock b/Cargo.lock index 4d3252a29f..0940f953ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -982,7 +982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1616,7 +1616,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -1782,7 +1782,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1995,7 +1995,7 @@ dependencies = [ [[package]] name = "lightning" version = "0.2.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" dependencies = [ "bech32", "bitcoin", @@ -2007,7 +2007,7 @@ dependencies = [ "lightning-macros 0.2.1", "lightning-types 0.3.5", "musig2", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564)", "regex", ] @@ -2027,7 +2027,7 @@ dependencies = [ [[package]] name = "lightning-background-processor" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" dependencies = [ "bitcoin", "bitcoin-io", @@ -2035,7 +2035,7 @@ dependencies = [ "lightning 0.2.5", "lightning-liquidity 0.2.3", "lightning-rapid-gossip-sync 0.2.3", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564)", ] [[package]] @@ -2054,7 +2054,7 @@ dependencies = [ [[package]] name = "lightning-block-sync" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" dependencies = [ "bitcoin", "chunked_transfer", @@ -2066,7 +2066,7 @@ dependencies = [ [[package]] name = "lightning-ffor" version = "0.1.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" dependencies = [ "bitcoin", ] @@ -2086,7 +2086,7 @@ dependencies = [ [[package]] name = "lightning-invoice" version = "0.34.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" dependencies = [ "bech32", "bitcoin", @@ -2112,7 +2112,7 @@ dependencies = [ [[package]] name = "lightning-liquidity" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" dependencies = [ "bitcoin", "chrono", @@ -2138,7 +2138,7 @@ dependencies = [ [[package]] name = "lightning-macros" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" dependencies = [ "proc-macro2", "quote", @@ -2159,7 +2159,7 @@ dependencies = [ [[package]] name = "lightning-net-tokio" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2180,7 +2180,7 @@ dependencies = [ [[package]] name = "lightning-persister" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2203,7 +2203,7 @@ dependencies = [ [[package]] name = "lightning-rapid-gossip-sync" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" dependencies = [ "bitcoin", "bitcoin-io", @@ -2228,7 +2228,7 @@ dependencies = [ [[package]] name = "lightning-transaction-sync" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" dependencies = [ "bitcoin", "electrum-client 0.24.1", @@ -2250,7 +2250,7 @@ dependencies = [ [[package]] name = "lightning-types" version = "0.3.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" dependencies = [ "bitcoin", ] @@ -2677,7 +2677,7 @@ dependencies = [ [[package]] name = "possiblyrandom" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=016778d264edf6c3f7201199cfdc9162fdc29562#016778d264edf6c3f7201199cfdc9162fdc29562" +source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" dependencies = [ "getrandom 0.2.17", ] @@ -2977,7 +2977,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.43", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.20", "tokio", "tracing", @@ -3015,9 +3015,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3332,7 +3332,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3734,7 +3734,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4574,7 +4574,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 74bf7dc9db..5b8fe07cc3 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,18 +34,18 @@ default = [] [dependencies] # Pin runtime dependencies directly so downstream consumers use the same native engine. -lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562" } -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562", features = ["std"] } -lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562" } -lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562" } -lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562" } -lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562" } -lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562", features = ["std"] } -lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562" } +lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564" } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564", features = ["std"] } +lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564" } +lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564" } +lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564" } +lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564" } +lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564", features = ["std"] } +lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564" } bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} @@ -81,7 +81,7 @@ prost = { version = "0.11.6", default-features = false} winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "016778d264edf6c3f7201199cfdc9162fdc29562", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564", features = ["std", "_test_utils"] } proptest = "1.0.0" ring = "0.17.14" regex = "1.5.6" diff --git a/FFOR.md b/FFOR.md index b816a097d7..7cbd3e4f43 100644 --- a/FFOR.md +++ b/FFOR.md @@ -311,20 +311,64 @@ readiness. A bound record with missing native history refuses replacement; absen for an unbound intent does not itself authorize new allocation. Detaching a caller leaves its durable intent recoverable and does not imply cancellation. -The initial one-slot schema reserves 4 KiB per request, including future native -binding, with at most 64 records and 256 KiB total. Unbound intents count toward -that limit; there is no eviction. This version has no invoice bytes, readiness flag, -cancellation outcome or completion ledger. Native epoch reuse and historical -retention policy are still required for repeated receives on one channel. +Legacy version 1 records reserve 4 KiB per request, including native binding, and +remain readable. An explicit version 2 upgrade reserves 8 KiB before any native +invoice issuance, for at most 64 records and 512 KiB total. Unbound intents count +toward that limit; there is no eviction. Version 2 retains the fixed invoice policy, +the exact signed invoice, its native context digest, a fixed payment timestamp and a +monotonic confirmation digest of the exact Pending payment row. These are historical +storage facts, never a readiness flag, cancellation outcome or completion ledger. +Native epoch reuse and historical retention policy are still required for repeated +receives on one channel. + +The issuer adapter joins one request to native invoice assignment in a fixed order: +durable policy reservation, native preparation, native persistence, exact invoice +retention in the protected record, exact inbound Pending payment confirmation through +a successful payment-store write, and the protected confirmation marker. Description, +amount and route terms come from the record and native ownership; the caller supplies +only a policy and public witness-to-settlement route evidence. A different policy is a +conflict, the reserved policy is never replaced, and an existing native assignment is +recovered before any new preparation, so fresh route evidence is accepted on retry. +Every pending native manager or monitor write reports awaiting persistence instead of +progress. No production completer for native persistence tokens exists yet. + +The payment store confirms the exact Pending Bolt11 row against memory and disk and +persists before installing it in memory. A payment confirmation write that fails +ambiguously keeps the exact expected candidate in memory and blocks every owner +operation, including previously minted handles, until the same confirmation succeeds. +Restart drops the candidate; the missing marker then forces the same idempotent +confirmation before a handle exists. A definite refusal (missing, corrupt, conflicting +or terminal payment) clears the candidate and fails closed without touching native. + +A publication handle is opaque, bound to the owner instance and the manager instance, +and exposes no invoice bytes. Minting it re-reads the protected record, rechecks the +exact native bytes, digest, intent and epoch context, and performs another successful +payment write. Release re-reads the record from storage, revalidates the handle, +acquires the payment-store exclusion and only then enters the native monitor guard, +whose innermost callback moves the exact bytes into the caller's slot and performs no +I/O. Repeated release is allowed. Native refusal after expiry, deadline or a moved +monitor tip leaves the record, native assignment and Pending row unchanged. Generic +payment removal now holds its object lock through the disk removal so a deletion +cannot race past a new confirmation. Tests restore genuine empty and pending native channel fixtures through NodeBuilder. They cover exact retries, native intent mismatches, wrong or stale connections, missing application or native history, visible failed writes and restart, exact binding recovery, disconnected exact-history recovery without native mutation, identity substitution, corruption, replacement and quota refusal. -The bounded record codec also has arbitrary-byte property checks. Fixture provenance -is in `src/ffor/request_store/fixtures/README.md`. No production caller constructs -this owner yet. +The bounded record codec also has arbitrary-byte property checks, strict version 2 +framing, monotonic markers and full-capacity envelopes. Issuer tests restore the genuine +public-driver invoice fixture (`src/ffor/request_store/fixtures/invoice/README.md`) +and re-sign fresh route evidence with its public witness seed. They cover live issuance +through native persistence, exact byte and payment retention, repeated release with no +extra writes, failure at every application write with the exact candidate retained and +all handles blocked, restart with a fresh native barrier and obsolete handles refused, +historical recovery of an expired assignment that never publishes, conflicting policy +and stale route refusal without a native slot, missing, corrupt and terminal payment +rows, unbound records, missing native history, a monitor tip ahead of the manager, and +zero storage I/O under publication. Run `cargo test --lib ffor_request` for the +request-store, codec and issuer checks and `cargo test --lib ffor_payment` for the +payment-store checks. No production caller constructs this owner yet. ## Required runtime integration diff --git a/src/data_store.rs b/src/data_store.rs index de73e2c4c1..80278edc54 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -16,6 +16,9 @@ use crate::logger::{log_error, LdkLogger}; use crate::types::DynStore; use crate::Error; +#[allow(dead_code)] +pub(crate) mod ffor; + pub(crate) trait StorableObject: Clone + Readable + Writeable { type Id: StorableObjectId; type Update: StorableObjectUpdate; @@ -97,7 +100,8 @@ where } pub(crate) fn remove(&self, id: &SO::Id) -> Result<(), Error> { - let removed = self.objects.lock().unwrap().remove(id).is_some(); + let mut objects = self.objects.lock().unwrap(); + let removed = objects.remove(id).is_some(); if removed { let store_key = id.encode_to_hex_str(); KVStoreSync::remove( @@ -119,6 +123,7 @@ where Error::PersistenceFailed })?; } + drop(objects); Ok(()) } diff --git a/src/data_store/ffor.rs b/src/data_store/ffor.rs new file mode 100644 index 0000000000..8f5e1af8fa --- /dev/null +++ b/src/data_store/ffor.rs @@ -0,0 +1,137 @@ +//! Private exact Pending payment confirmation and publication exclusion for FFOR. + +use std::ops::Deref; + +use lightning::util::persist::KVStoreSync; +use lightning::util::ser::Writeable; +use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescriptionRef}; + +use super::{DataStore, StorableObjectId}; +use crate::io::{ + PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, +}; +use crate::logger::LdkLogger; +use crate::payment::store::{PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FFORPaymentError { + InvalidPending, + Missing, + Conflict, + Terminal, + Storage, +} + +impl DataStore +where + L::Target: LdkLogger, +{ + /// Serialize against ordinary payment updates and deletion. Visibility alone does not confirm + /// durability: even an unchanged restored row is written successfully before returning. + pub(crate) fn confirm_ffor_pending( + &self, expected: &PaymentDetails, require_existing: bool, + ) -> Result<(), FFORPaymentError> { + validate_pending(expected)?; + if self.primary_namespace != PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE + || self.secondary_namespace != PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE + { + return Err(FFORPaymentError::Conflict); + } + let mut objects = self.objects.lock().unwrap(); + let existing = objects.get(&expected.id); + if let Some(existing) = existing { + check_current(existing, expected)?; + } else if require_existing { + return Err(FFORPaymentError::Missing); + } + let bytes = expected.encode(); + let key = expected.id.encode_to_hex_str(); + match KVStoreSync::read( + &*self.kv_store, + &self.primary_namespace, + &self.secondary_namespace, + &key, + ) { + Ok(actual) if actual == bytes => {}, + Ok(_) => return Err(FFORPaymentError::Conflict), + Err(error) if error.kind() == lightning::io::ErrorKind::NotFound => { + if require_existing || existing.is_some() { + return Err(FFORPaymentError::Missing); + } + }, + Err(_) => return Err(FFORPaymentError::Storage), + } + // Persist first. A failed or ambiguously visible write never installs an in-memory row. + KVStoreSync::write( + &*self.kv_store, + &self.primary_namespace, + &self.secondary_namespace, + &key, + bytes, + ) + .map_err(|_| FFORPaymentError::Storage)?; + objects.insert(expected.id, expected.clone()); + Ok(()) + } + + /// Exclude all ordinary payment mutations through the final native publication call. The + /// caller already confirmed storage and the protected request before entering. Lock order is + /// payment objects, then native; the supplied action must not reenter payment or request storage. + pub(crate) fn with_ffor_pending( + &self, expected: &PaymentDetails, publish: impl FnOnce() -> T, + ) -> Result { + validate_pending(expected)?; + let objects = self.objects.lock().unwrap(); + check_current(objects.get(&expected.id).ok_or(FFORPaymentError::Missing)?, expected)?; + let result = publish(); + drop(objects); + Ok(result) + } +} + +fn check_current( + current: &PaymentDetails, expected: &PaymentDetails, +) -> Result<(), FFORPaymentError> { + if current.status != PaymentStatus::Pending { + return Err(FFORPaymentError::Terminal); + } + if current != expected { + return Err(FFORPaymentError::Conflict); + } + Ok(()) +} + +fn validate_pending(expected: &PaymentDetails) -> Result<(), FFORPaymentError> { + let PaymentKind::Bolt11 { + hash, + preimage: None, + secret: Some(secret), + description: Some(description), + bolt11: Some(wire), + } = &expected.kind + else { + return Err(FFORPaymentError::InvalidPending); + }; + if expected.direction != PaymentDirection::Inbound + || expected.status != PaymentStatus::Pending + || expected.id.0 != hash.0 + || expected.fee_paid_msat.is_some() + || wire.len() > 4096 + { + return Err(FFORPaymentError::InvalidPending); + } + let invoice: Bolt11Invoice = wire.parse().map_err(|_| FFORPaymentError::InvalidPending)?; + if invoice.to_string() != *wire + || invoice.amount_milli_satoshis() != expected.amount_msat + || !expected.amount_msat.is_some_and(|amount| amount > 0) + || invoice.payment_hash().as_ref() != hash.0 + || invoice.payment_secret() != secret + || !matches!(invoice.description(), Bolt11InvoiceDescriptionRef::Direct(value) if value.as_inner().0 == *description) + { + return Err(FFORPaymentError::InvalidPending); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/src/data_store/ffor/tests.rs b/src/data_store/ffor/tests.rs new file mode 100644 index 0000000000..3db88b3903 --- /dev/null +++ b/src/data_store/ffor/tests.rs @@ -0,0 +1,275 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; + +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::secp256k1::{Secp256k1, SecretKey}; +use lightning::io; +use lightning::ln::channelmanager::PaymentId; +use lightning::util::persist::KVStore; +use lightning::util::test_utils::TestLogger; +use lightning_invoice::{Currency, InvoiceBuilder}; +use lightning_types::payment::{PaymentHash, PaymentSecret}; + +use super::*; +use crate::io::test_utils::InMemoryStore; +use crate::payment::store::PaymentDetailsUpdate; +use crate::types::DynStore; + +type RemovalGate = (mpsc::Sender<()>, mpsc::Receiver<()>, bool); + +struct FaultStore { + inner: InMemoryStore, + failure: AtomicUsize, + writes: AtomicUsize, + remove_gate: Mutex>, +} +impl KVStoreSync for FaultStore { + fn read(&self, p: &str, s: &str, k: &str) -> io::Result> { + KVStoreSync::read(&self.inner, p, s, k) + } + fn write(&self, p: &str, s: &str, k: &str, v: Vec) -> io::Result<()> { + self.writes.fetch_add(1, Ordering::SeqCst); + let failure = self.failure.swap(0, Ordering::SeqCst); + if failure != 1 { + KVStoreSync::write(&self.inner, p, s, k, v)?; + } + if failure == 0 { + Ok(()) + } else { + Err(io::Error::new(io::ErrorKind::Other, "injected write")) + } + } + fn remove(&self, p: &str, s: &str, k: &str, lazy: bool) -> io::Result<()> { + if let Some((entered, resume, fail)) = self.remove_gate.lock().unwrap().take() { + entered.send(()).unwrap(); + resume.recv().unwrap(); + if fail { + return Err(io::Error::new(io::ErrorKind::Other, "injected removal")); + } + } + KVStoreSync::remove(&self.inner, p, s, k, lazy) + } + fn list(&self, p: &str, s: &str) -> io::Result> { + KVStoreSync::list(&self.inner, p, s) + } +} +impl KVStore for FaultStore { + fn read( + &self, p: &str, s: &str, k: &str, + ) -> Pin>> + Send>> { + let r = KVStoreSync::read(self, p, s, k); + Box::pin(async move { r }) + } + fn write( + &self, p: &str, s: &str, k: &str, v: Vec, + ) -> Pin> + Send>> { + let r = KVStoreSync::write(self, p, s, k, v); + Box::pin(async move { r }) + } + fn remove( + &self, p: &str, s: &str, k: &str, lazy: bool, + ) -> Pin> + Send>> { + let r = KVStoreSync::remove(self, p, s, k, lazy); + Box::pin(async move { r }) + } + fn list( + &self, p: &str, s: &str, + ) -> Pin>> + Send>> { + let r = KVStoreSync::list(self, p, s); + Box::pin(async move { r }) + } +} + +type Payments = DataStore>; +fn fixture() -> (Arc, Arc, PaymentDetails) { + let storage = Arc::new(FaultStore { + inner: InMemoryStore::new(), + failure: AtomicUsize::new(0), + writes: AtomicUsize::new(0), + remove_gate: Mutex::new(None), + }); + let payments = reopen(Arc::clone(&storage), Vec::new()); + let invoice = InvoiceBuilder::new(Currency::BitcoinTestnet) + .description("receipt".into()) + .duration_since_epoch(std::time::Duration::from_secs(1)) + .amount_milli_satoshis(2000) + .payment_hash(sha256::Hash::hash(&[9; 32])) + .payment_secret(PaymentSecret([1; 32])) + .min_final_cltv_expiry_delta(18) + .build_signed(|m| { + Secp256k1::new().sign_ecdsa_recoverable(m, &SecretKey::from_slice(&[42; 32]).unwrap()) + }) + .unwrap(); + let hash = PaymentHash(invoice.payment_hash().to_byte_array()); + let payment = PaymentDetails { + id: PaymentId(hash.0), + kind: PaymentKind::Bolt11 { + hash, + preimage: None, + secret: Some(*invoice.payment_secret()), + description: Some("receipt".into()), + bolt11: Some(invoice.to_string()), + }, + amount_msat: Some(2000), + fee_paid_msat: None, + direction: PaymentDirection::Inbound, + status: PaymentStatus::Pending, + latest_update_timestamp: 5, + }; + (storage, payments, payment) +} +fn reopen(storage: Arc, objects: Vec) -> Arc { + let store: Arc = storage; + Arc::new(DataStore::new( + objects, + "payments".into(), + "".into(), + store, + Arc::new(TestLogger::new()), + )) +} + +#[test] +fn ffor_pending_confirmation_rewrites_visible_failures_and_restored_rows() { + for failure in [1, 2] { + let (storage, payments, expected) = fixture(); + storage.failure.store(failure, Ordering::SeqCst); + assert_eq!(payments.confirm_ffor_pending(&expected, false), Err(FFORPaymentError::Storage)); + assert!(payments.get(&expected.id).is_none()); + storage.failure.store(2, Ordering::SeqCst); + assert_eq!(payments.confirm_ffor_pending(&expected, false), Err(FFORPaymentError::Storage)); + assert!(payments.get(&expected.id).is_none()); + payments.confirm_ffor_pending(&expected, false).unwrap(); + assert_eq!(payments.get(&expected.id), Some(expected.clone())); + let restored = reopen(Arc::clone(&storage), vec![expected.clone()]); + storage.failure.store(2, Ordering::SeqCst); + assert_eq!(restored.confirm_ffor_pending(&expected, true), Err(FFORPaymentError::Storage)); + let before = storage.writes.load(Ordering::SeqCst); + restored.confirm_ffor_pending(&expected, true).unwrap(); + assert_eq!(storage.writes.load(Ordering::SeqCst), before + 1); + } +} + +#[test] +fn ffor_pending_confirmation_never_recreates_confirmed_or_overwrites_terminal_data() { + let (storage, payments, expected) = fixture(); + assert_eq!(payments.confirm_ffor_pending(&expected, true), Err(FFORPaymentError::Missing)); + payments.confirm_ffor_pending(&expected, false).unwrap(); + let key = expected.id.encode_to_hex_str(); + KVStoreSync::remove(&*storage, "payments", "", &key, false).unwrap(); + assert_eq!(payments.confirm_ffor_pending(&expected, true), Err(FFORPaymentError::Missing)); + KVStoreSync::write(&*storage, "payments", "", &key, expected.encode()).unwrap(); + for status in [PaymentStatus::Failed, PaymentStatus::Succeeded] { + let mut terminal = expected.clone(); + terminal.status = status; + payments.insert(terminal.clone()).unwrap(); + let before = storage.writes.load(Ordering::SeqCst); + assert_eq!( + payments.confirm_ffor_pending(&expected, false), + Err(FFORPaymentError::Terminal) + ); + assert_eq!( + payments.with_ffor_pending(&expected, || panic!("terminal publication")), + Err(FFORPaymentError::Terminal) + ); + assert_eq!(storage.writes.load(Ordering::SeqCst), before); + assert_eq!(payments.get(&expected.id), Some(terminal)); + } +} + +#[test] +fn ffor_pending_publication_excludes_concurrent_terminal_update() { + let (_, payments, expected) = fixture(); + payments.confirm_ffor_pending(&expected, false).unwrap(); + let (started, start) = mpsc::channel(); + let (done, finished) = mpsc::channel(); + let worker = Arc::clone(&payments); + let update = PaymentDetailsUpdate { + status: Some(PaymentStatus::Failed), + ..PaymentDetailsUpdate::new(expected.id) + }; + let join = payments + .with_ffor_pending(&expected, || { + assert!(payments.objects.try_lock().is_err()); + let join = std::thread::spawn(move || { + started.send(()).unwrap(); + worker.update(&update).unwrap(); + done.send(()).unwrap(); + }); + start.recv().unwrap(); + assert!(matches!(finished.try_recv(), Err(mpsc::TryRecvError::Empty))); + join + }) + .unwrap(); + join.join().unwrap(); + finished.recv().unwrap(); + assert_eq!(payments.with_ffor_pending(&expected, || ()), Err(FFORPaymentError::Terminal)); +} + +#[test] +fn ffor_pending_removal_holds_exclusion_through_success_or_storage_failure() { + for fail in [false, true] { + let (storage, payments, expected) = fixture(); + payments.confirm_ffor_pending(&expected, false).unwrap(); + let (entered, started) = mpsc::channel(); + let (release, resume) = mpsc::channel(); + *storage.remove_gate.lock().unwrap() = Some((entered, resume, fail)); + let worker = Arc::clone(&payments); + let id = expected.id; + let removal = std::thread::spawn(move || worker.remove(&id)); + started.recv().unwrap(); + assert!( + payments.objects.try_lock().is_err(), + "disk deletion must share the publication exclusion" + ); + release.send(()).unwrap(); + assert_eq!(removal.join().unwrap().is_err(), fail); + assert_eq!(payments.confirm_ffor_pending(&expected, true), Err(FFORPaymentError::Missing)); + assert_eq!(payments.with_ffor_pending(&expected, || ()), Err(FFORPaymentError::Missing)); + } +} + +#[test] +fn ffor_pending_conflicting_disk_and_invalid_candidate_never_write() { + let (storage, payments, expected) = fixture(); + let mut invalid = expected.clone(); + invalid.amount_msat = Some(1); + assert_eq!( + payments.confirm_ffor_pending(&invalid, false), + Err(FFORPaymentError::InvalidPending) + ); + KVStoreSync::write(&*storage, "payments", "", &expected.id.encode_to_hex_str(), vec![42]) + .unwrap(); + let writes = storage.writes.load(Ordering::SeqCst); + assert_eq!(payments.confirm_ffor_pending(&expected, false), Err(FFORPaymentError::Conflict)); + assert_eq!(storage.writes.load(Ordering::SeqCst), writes); +} + +#[test] +fn ffor_pending_failed_terminal_update_cannot_be_reconfirmed_or_published() { + for status in [PaymentStatus::Failed, PaymentStatus::Succeeded] { + for failure in [1, 2] { + let (storage, payments, expected) = fixture(); + payments.confirm_ffor_pending(&expected, false).unwrap(); + storage.failure.store(failure, Ordering::SeqCst); + let update = PaymentDetailsUpdate { + status: Some(status), + ..PaymentDetailsUpdate::new(expected.id) + }; + assert!(payments.update(&update).is_err()); + assert_eq!(payments.get(&expected.id).unwrap().status, status); + let writes = storage.writes.load(Ordering::SeqCst); + assert_eq!( + payments.confirm_ffor_pending(&expected, true), + Err(FFORPaymentError::Terminal) + ); + assert_eq!( + payments.with_ffor_pending(&expected, || ()), + Err(FFORPaymentError::Terminal) + ); + assert_eq!(storage.writes.load(Ordering::SeqCst), writes); + } + } +} diff --git a/src/ffor/request_store.rs b/src/ffor/request_store.rs index 54e78189fe..4e80ed0095 100644 --- a/src/ffor/request_store.rs +++ b/src/ffor/request_store.rs @@ -3,8 +3,9 @@ //! One exclusive owner retains exact arguments before native preparation. Native alone owns the //! epoch and lifecycle. No builder constructs this store yet. The 64-record bound matches the //! current native archive and includes unbound intents; neither this store nor native currently -//! retires historical requests. Each intent reserves 4 KiB, for a maximum of 256 KiB. This schema -//! contains no invoice bytes, readiness flag, cancellation outcome or payment-completion ledger. +//! retires historical requests. Legacy intents reserve 4 KiB; an explicit v2 upgrade reserves 8 KiB before invoice issuance, +//! for at most 512 KiB. Exact invoice and Pending payment confirmation are historical storage facts, +//! never a readiness flag, cancellation outcome or payment-completion ledger. //! Repeated receives on the same channel still require native epoch reuse and retention policy. //! //! KVStore success is the durability contract. A failed write blocks this owner until its exact @@ -13,8 +14,15 @@ //! Multiple owners or external namespace writers are unsupported. Missing retained data is never //! reconstructed from native selectors, which do not contain the application's description. //! Exact intent comparison preserves client-ID and description bytes without normalization. +//! +//! The issuer adapter in `invoice` joins native invoice assignment to this record and to the exact +//! Pending payment row. An ambiguous payment write is retained in memory as the exact expected +//! candidate and blocks every owner operation, including previously minted handles, until the same +//! confirmation succeeds. Restart drops the candidate; the missing protected confirmation marker +//! then forces the same idempotent confirmation before any handle exists. use std::collections::{BTreeMap, BTreeSet}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use bitcoin::hashes::{sha256, Hash, HashEngine}; @@ -22,20 +30,25 @@ use bitcoin::secp256k1::PublicKey; use lightning::ln::ffor::FFORReceiverError; use lightning::util::persist::KVStoreSync; -use crate::types::{ChannelManager, DynStore}; +use crate::data_store::ffor::FFORPaymentError; +use crate::payment::store::PaymentDetails; +use crate::types::{ChannelManager, DynStore, PaymentStore}; mod envelope; +mod invoice; mod native; mod record; use envelope::EnvelopeKey; pub(super) use record::{RequestIntent, RequestPlan, StoredRequest}; const NAMESPACE: &str = "ffor_requests"; -const VERSION: u16 = 1; +const LEGACY_VERSION: u16 = 1; +const VERSION: u16 = 2; const MAX_REQUESTS: usize = 64; -// Charged in full at creation, including room for a native selector and AEAD metadata. This -// schema contains no invoice bytes; a future invoice-bearing schema must explicitly upgrade. -const MAX_RECORD_BYTES: usize = 4096; +// Charged in full by authenticated record version, including selector and AEAD metadata. +// Version2 reserves invoice and payment-confirmation space before native issuance. +const LEGACY_RECORD_BYTES: usize = 4096; +const MAX_RECORD_BYTES: usize = 8192; const MAX_STORE_BYTES: usize = MAX_REQUESTS * MAX_RECORD_BYTES; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -50,7 +63,9 @@ pub(super) enum RequestStoreError { Entropy, Storage, Uncertain, + Unreserved, Native(FFORReceiverError), + Payment(FFORPaymentError), } struct PendingWrite { @@ -59,9 +74,17 @@ struct PendingWrite { previous: Option>, } +/// Exact Pending payment whose confirmation write returned an ambiguous storage failure. +struct PaymentCandidate { + expected: PaymentDetails, +} + +static INSTANCES: AtomicU64 = AtomicU64::new(1); + /// Exclusive storage owner. Mutable methods serialize admission, recovery and binding. pub(super) struct RequestStore { manager: Arc, + payments: Arc, storage: Arc, envelope: EnvelopeKey, chain: [u8; 32], @@ -69,12 +92,14 @@ pub(super) struct RequestStore { records: BTreeMap>, confirmed: BTreeSet, uncertain: Option, + uncertain_payment: Option, + instance: u64, } impl RequestStore { fn open_bound( seed: &[u8; 64], chain: [u8; 32], node: PublicKey, manager: Arc, - storage: Arc, + payments: Arc, storage: Arc, ) -> Result { let envelope = EnvelopeKey::derive(seed, chain, node); let keys = @@ -91,6 +116,7 @@ impl RequestStore { } let bytes = read(&*storage, &key)?; let record = envelope.open(&key, &bytes)?; + record.validate_identity(chain, node)?; if hex(&record.local_request_id()) != key || record.local_request_id() != local_id(chain, node, record.intent().client_id()) || record.plan().settlement == node @@ -101,6 +127,7 @@ impl RequestStore { } Ok(Self { manager, + payments, storage, envelope, chain, @@ -108,6 +135,8 @@ impl RequestStore { records, confirmed: BTreeSet::new(), uncertain: None, + uncertain_payment: None, + instance: INSTANCES.fetch_add(1, Ordering::SeqCst), }) } @@ -159,6 +188,7 @@ impl RequestStore { return Err(RequestStoreError::Conflict); } let record = self.envelope.open(key, &actual)?; + record.validate_identity(self.chain, self.node)?; if hex(&record.local_request_id()) != key || record.local_request_id() != local_id(self.chain, self.node, record.intent().client_id()) @@ -174,10 +204,12 @@ impl RequestStore { fn write_record(&mut self, record: &StoredRequest) -> Result<(), RequestStoreError> { let key = hex(&record.local_request_id()); + record.validate_identity(self.chain, self.node)?; let bytes = self.envelope.seal(&key, record)?; - if !self.records.contains_key(&key) - && (self.records.len() + 1) - .checked_mul(MAX_RECORD_BYTES) + if (!self.records.contains_key(&key) && self.records.len() >= MAX_REQUESTS) + || self + .reserved_bytes_without(&key)? + .checked_add(record.reserved_bytes()) .filter(|bytes| *bytes <= MAX_STORE_BYTES) .is_none() { @@ -186,6 +218,17 @@ impl RequestStore { self.confirm_write(key, bytes) } + fn reserved_bytes_without(&self, excluded: &str) -> Result { + self.records.iter().filter(|(key, _)| key.as_str() != excluded).try_fold( + 0usize, + |total, (key, bytes)| { + total + .checked_add(self.envelope.open(key, bytes)?.reserved_bytes()) + .ok_or(RequestStoreError::Capacity) + }, + ) + } + fn confirm_write(&mut self, key: String, bytes: Vec) -> Result<(), RequestStoreError> { let previous = self .uncertain @@ -202,21 +245,48 @@ impl RequestStore { } /// Visibility is insufficient: only a successful exact retry resolves uncertain durability. + /// The same applies to an ambiguous exact Pending payment confirmation. pub(super) fn recover_write(&mut self) -> Result<(), RequestStoreError> { - let Some(pending) = &self.uncertain else { - return Ok(()); - }; - match read(&*self.storage, &pending.key) { - Ok(bytes) if bytes == pending.bytes || pending.previous.as_ref() == Some(&bytes) => {}, - Ok(_) => return Err(RequestStoreError::Conflict), - Err(RequestStoreError::Missing) if pending.previous.is_none() => {}, - Err(error) => return Err(error), + if let Some(pending) = &self.uncertain { + match read(&*self.storage, &pending.key) { + Ok(bytes) + if bytes == pending.bytes || pending.previous.as_ref() == Some(&bytes) => {}, + Ok(_) => return Err(RequestStoreError::Conflict), + Err(RequestStoreError::Missing) if pending.previous.is_none() => {}, + Err(error) => return Err(error), + } + self.confirm_write(pending.key.clone(), pending.bytes.clone())?; + } + if let Some(candidate) = &self.uncertain_payment { + let expected = candidate.expected.clone(); + self.confirm_payment_candidate(&expected, false)?; + } + Ok(()) + } + + /// The candidate is installed before the write and cleared only by a definite outcome. An + /// ambiguous storage failure keeps it, blocking this owner until an exact retry succeeds. + fn confirm_payment_candidate( + &mut self, expected: &PaymentDetails, require_existing: bool, + ) -> Result<(), RequestStoreError> { + self.uncertain_payment = Some(PaymentCandidate { expected: expected.clone() }); + match self.payments.confirm_ffor_pending(expected, require_existing) { + Ok(()) => { + self.uncertain_payment = None; + Ok(()) + }, + Err(FFORPaymentError::Storage) => { + Err(RequestStoreError::Payment(FFORPaymentError::Storage)) + }, + Err(error) => { + self.uncertain_payment = None; + Err(RequestStoreError::Payment(error)) + }, } - self.confirm_write(pending.key.clone(), pending.bytes.clone()) } fn ensure_certain(&self) -> Result<(), RequestStoreError> { - if self.uncertain.is_some() { + if self.uncertain.is_some() || self.uncertain_payment.is_some() { Err(RequestStoreError::Uncertain) } else { Ok(()) diff --git a/src/ffor/request_store/envelope.rs b/src/ffor/request_store/envelope.rs index b959f9c3df..d493bed804 100644 --- a/src/ffor/request_store/envelope.rs +++ b/src/ffor/request_store/envelope.rs @@ -7,7 +7,7 @@ use vss_client::types::Storable; use vss_client::util::storable_builder::{EntropySource, StorableBuilder}; use zeroize::Zeroizing; -use super::{RequestStoreError, StoredRequest, MAX_RECORD_BYTES, VERSION}; +use super::{RequestStoreError, StoredRequest, LEGACY_VERSION, MAX_RECORD_BYTES, VERSION}; pub(super) struct EnvelopeKey { key: Zeroizing<[u8; 32]>, @@ -38,12 +38,15 @@ impl EnvelopeKey { // is not zeroized by that dependency; this makes no complete-erasure guarantee. let storable = StorableBuilder::new(Nonce(nonce)).build( plaintext.to_vec(), - i64::from(VERSION), + i64::from(record.version()), &self.key, &self.aad(key), ); let bytes = storable.encode_to_vec(); check_size(&bytes)?; + if bytes.len() > record.reserved_bytes() { + return Err(RequestStoreError::Capacity); + } Ok(bytes) } @@ -60,16 +63,22 @@ impl EnvelopeKey { .deconstruct(sealed, &self.key, &self.aad(key)) .map_err(|_| RequestStoreError::Corrupt)?; let plaintext = Zeroizing::new(plaintext); - if version != i64::from(VERSION) { + if version != i64::from(LEGACY_VERSION) && version != i64::from(VERSION) { + return Err(RequestStoreError::Corrupt); + } + let record = StoredRequest::decode(&plaintext)?; + if i64::from(record.version()) != version || bytes.len() > record.reserved_bytes() { return Err(RequestStoreError::Corrupt); } - StoredRequest::decode(&plaintext) + Ok(record) } + // Envelope framing and key purpose remain v1. The authenticated encrypted record version + // changes independently; a v1 reader refuses v2 records after authentication. fn aad(&self, key: &str) -> Vec { [ b"ldk-node/ffor/request-envelope/v1".as_slice(), - &VERSION.to_be_bytes(), + &LEGACY_VERSION.to_be_bytes(), &self.identity, key.as_bytes(), ] diff --git a/src/ffor/request_store/fixtures/invoice/README.md b/src/ffor/request_store/fixtures/invoice/README.md new file mode 100644 index 0000000000..0845887a8b --- /dev/null +++ b/src/ffor/request_store/fixtures/invoice/README.md @@ -0,0 +1,18 @@ +These are public deterministic test fixtures, not wallet backups. + +The native `ffor_invoice_public_driver_one_slot_fixture_preserves_native_request` test generated +them through the real public receiver driver in a funded Testnet channel using wallet seed +`[91; 64]`, matching `NodeBuilder`, with a third node acting as the registered witness. The exporter +is published in native revision `a00512b79f33e2db9b1bea691ab3637122c41564`, whose source base was +`016778d`. Its output directory is selected only with `FFOR_NODE_INVOICE_FIXTURE_DIR`; ordinary +tests write no fixture files. + +`active-manager.bin` and `monitor.bin` capture the receiver after activation, witness +registration and every durable witness acknowledgement, before any invoice exists. +`issued-manager.bin` captures the same receiver after one native invoice assignment; that historical +invoice expires one hour after export and its stored route update ages past native freshness, so +it exercises historical recovery and refusal only. `route-announcement.bin` and +`route-update.bin` are the signed public witness-to-settlement gossip used at export; live +issuance tests re-sign a fresh update with `witness_node_seed`. `witness-manifest.bin` records the +selected witness manifest for provenance. `fixture.txt` lists the exact identity, channel, setup and +intent parameters. No fixture constructs a readiness or payment capability. diff --git a/src/ffor/request_store/fixtures/invoice/active-manager.bin b/src/ffor/request_store/fixtures/invoice/active-manager.bin new file mode 100644 index 0000000000000000000000000000000000000000..1f639f055df86ced7ffba8439cf59419a50bb39e GIT binary patch literal 7805 zcmeHMc|4R`AAg=_#+Vt#GIo__s6^QfT145hg;JJ^Vj@eniY$d2rQDD(6p}1u?Zu6z z)QyXhY$HobQ3=r|TRgna48^_g``)>qPwzkPeI1|k{C>~OZ_ekO@9%fc@B2GIP{%O1 zZG2U{H}=a_-j4dp0=hqq6xPp^4-Tl{fZ2x6;pjj|Cw`)80t0U2f_WK9cJ>#_O}OItxmuPneks9FFCDZw+=AtVQxUsg zS+?M*G?oMa2=bEk$~&YheK6Dfs`XQ)YVn3((+_n0G@;#1@$w!jqR#kR;4hS}hGiHzo8J2x%>ep6UdA}j=vnN9}-^2Z&@{oJGPM@C(a zB4KHQ;T}CI#WAur%Lw)|D^V=t1&`m4vfzt_D$|Gy=`cVJJ#j5wN4m?57+`9p|QCMJZ;zN7m7MJgkoV36O=eWeJ4*}#oL zfQ%Yak-c#)vZUidtI(wb?d_A@R-+xdzDb5_oZY`zrB+HqWC(*B`Vx8LZK-M#g?nq3 z&4!o8au$K-+{O~Bv#`@`rk!Xql=Xn@FuJ4GeH(6Ia!rIs9Yw0F4CwRruTA!D1hyov z#*k50L%%dP+CImxG+83F{4;2wnkk{C%&qF;xiT?v)BC^)EE&b$d$f^saoajo@~MI6 z7xxTX+g)xfsNRv9Q5iw}ho4^{1OYK<5Wtk)oSl1xMA*_hTwBGCW4OJ$h3|)d#aD!u zHs;u@7@-L1s@HBn=I&L5Z~R(&hsQSe6T!(Jl$~pIx2$vzNP%n|O??QG_b)dVFD68$I@(Thg$bIXECH3AH`% z>&DwkJ}f7k7%?;rKe+x^^OYOU`wF#81dOVDyw(E+#|nji&f^a9dbOV~e-MstGCrGh zuecFNKRK00iLQ?jWA2sO(RSV*dKvog4f4p*i0hSM_oPIsy3C&9UkoT;YW>aXr(?9h z#;V|w*r;DVi`Xw!yZ^XNa&8aJ%tqXwJQoJ;pP2nFz!&Z25m>};9*c$AA}$NjnAaC^ zna5(Gwus9@H0Je1T;{P@sQu620%O&H5Wf;~UZ}`5?aC?%aq zuYrs=r#5qK;ZKk7tW4Bzlc&fx_4bY$*ur7dV;ZFy>gs&Wj1n8?hgaM2y+B!lt@eBU zM_Y8Zc#6k_ii+Nyv};1e?~A3KPPMeKo1Xg6- zUZ<9w>q(SJS*buqiOtlOj|ZN3f+KWF?N?WuG`|=h7>q8{`>mGu=C(2CBoz>#fPfYm zm2M$>IDEM2Wk;4;g!UwrfT$baW}z z61no~ErZ-rX2dE({l*zV+_v;whoLG+b0hG5wGfG2)q zEZTmv!cRKtfOzf()F`Sx(JIB!(WB+bM>(VA$@Y4V`#-tt%PG|^bA9^Miz}IIm0joI zp>wEWlqwg{Qf+ek)z#lBqTC&ZKOV0-{P66SXVsJ0K}kz{D`pt(?rGijV0Xe`8Y9I- zib0HffY}@0Jv7r)Pacmj^y*D|wt}MDPdTOhhR1err&*`Ax8St$_}V8=GRZR>OT+P| zL9WJhe(#r^8P0WXL9ZqkvSG}wrhrXj3nLl=$fp72FeQ7-{sl+JToc7f;6LnDW%AVdbkj~rq zI*wS{dS%^b`O=D3OM@%6V;16Who%ZISI}|xP;YDJkFM|A3P84@&G~&B%rBFRS?sHo!oXes~HrZ zgrABI=+&Sb9y@LuDxh{9`{0{+VW*Fl<|h7I3gewbCvn>s?~L*7MYV+6uJTAdoRjKO zayCA`jD|^jZRAw7-Sey0nLwFSJv}Fs2o1cpH5fA6iVRC)cjJQp3*{vDEDA^+$-~l^ z*BpGzAc2V>D9BXs!px^2UV0UBqXIyr>-ftCHC?0N#-k(ZGTAE*(K*9an$&{!h~2p{ z!cWbY<$=L^7<}}%4_5Y0MEuOItj4f);$ONG`0m76cQT#G!*U`Y2ohONWVupM_tJ~~O8Nql)!Pes?qypUQwmScJ_#(%}O9!vn=hi`l84-#a%IshAjO^m>w zwIKiDrvNjv{+sLyz<3o9LQ*@@IoJctNqR$gfHi!2Cq;eOuNWwmG1#meYx<%akAqycO5cP-)*Mw4EWq2n#r6?u3#g?>&CEox}LEK}3e;>=5uZ&SD(Tb*!y ztIna7gTAl$x68g09BdRVFWsfty8NhekAM{Wr2;}62+$B94CA~o%)=Gu4CAQ4;>rE( zhL&As`W7OKCpU8D{x?r<$c`QK$ek(j$BqlK95l-pm|TI$8CVSsLjo|w2lTda{Al)W z`uMER*dzU(M4bYyQckHTgC4kfg;=fSQgtKE-aReZ-Le&Rj~Z-HCI`$S&BAHst&wln ziha_QA99px&`Z6wrmFoBT5fAn^8h#<9B_Th*J=GG?|;xAJS^sTLp9P=J%P*@??|ue zMCEN9va--US8(IG=jwZrQJPBX`}bOf2-8DoV}{|CaRQG=EH9`D+y5+L8Buw`ql9;Z zz>=ou8e+P37Y~#Hq_=VY=t|jTQ7s8-A1TR;-+d^9I}jo(HH_uF zZ8pbMM?i{FxVL465y7d@Qc-QC-_6r4$rXA5OJ1(!^_e5?NV$U{88)KVG4@Rkc)%Ig za?1MFO+Q^TYW+mMuHS6nE>5XS75gn0fOt~<86cx5T(Z{tV@q-4o(dXS**iB{f4kk@ zJJRCHxh>{%d;vM>SP2HtC;FfMiGo?=0dr*T@4^GHFeqLev|F_0OmvCB-XOhaBm)=R z)Kr#&PEj4U=9pZ@W8u}{I6!7wC_tpM*1iuVUTO%&2TNr~#%Z}Wi`0DydT+Lr%&}Fn z`L=Qynq@aZa#MIDXM+@O5a7{9trqZk-87JOZgB52$3lW` z1 X$`&2A(69q$?#x{vK+1xb|4;RwjT4M( literal 0 HcmV?d00001 diff --git a/src/ffor/request_store/fixtures/invoice/fixture.txt b/src/ffor/request_store/fixtures/invoice/fixture.txt new file mode 100644 index 0000000000..1c17d86eb9 --- /dev/null +++ b/src/ffor/request_store/fixtures/invoice/fixture.txt @@ -0,0 +1,28 @@ +network=testnet +wallet_seed=5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b +client_id=request-fixture +local_request_id=9c51c5a1f066274f1ef4cbce6404ce90df2328f7aa4d689079244ee2067a10ef +channel_id=ea6bf814aca4995e5f93bd4c0b94110bd41dd3b5b406649b685973d40807fd9e +epoch_id=8b5a2bf651434435c61eab2e37d4ec0c234f12c54946d0ff14094ea2ab62edc5 +receiver=02aa9e69b48a9595b89706a0e44a66c5526ffa70897b2986e2e27e310dcf0f5ac7 +settlement=027f921585f2ac0c7c70e36110adecfd8fd14b8a99bfb3d000a283fcac358fce88 +witness=02888f2e3df341d21240f769afd83938fdc1878356769aae64141880d810c61dce +witness_node_seed=0202020202020202020202020202020202020202020202020202020202020202 +funding_txid=9efd0708d47359689b6406b4b5d31dd40b11940b4cbd935f5e99a4ac14f86bea +funding_vout=0 +height=10 +amount_msat=2000000 +minimum_payment_msat=2000000 +settlement_deadline=145 +voucher_expiry=165 +fee_base_msat=0 +fee_proportional_millionths=0 +claim_margin_blocks=20 +hash_chain=false +description=Offline receipt +expiry_seconds=3600 +safety_margin_seconds=120 +fetch_secret=6464646464646464646464646464646464646464646464646464646464646464 +encryption_secret=7878787878787878787878787878787878787878787878787878787878787878 +generator=ffor_invoice_public_driver_one_slot_fixture_preserves_native_request +base_revision=016778d diff --git a/src/ffor/request_store/fixtures/invoice/issued-manager.bin b/src/ffor/request_store/fixtures/invoice/issued-manager.bin new file mode 100644 index 0000000000000000000000000000000000000000..89b08c14b11a0079dd72e4c07df1b6db5314e8f7 GIT binary patch literal 8986 zcmeHNd011&+CL`=0m8lts7OFXP%wmq5EVsP6cB-m3RFQzNCG4=Cn2k-&`L$AND)L_ z5R|2YphYT+3oR5->cS?AC<;{s6gOlOBliSQx&6NHrq9#wpL?&vGc)g;yl0-7-}}zH z31Ps+oxUt_S-50Qw5W!(2hEE^flGK;F$9rVH5Yf zxVmH5YH;;nuO3nOc*J3v4@9A+Da+{|=#LoHJ(rah7- zW+g>wI?VUWBro_~#$*ow2n5HHbB^U~&dByY<9E-d#^}x-&zI${8G2tgq*$|U4ITjc zfUb&SN08y>*YQr)#z{Sp&CbY_WorRoLRO@odw8~xfa!Sw@FOwV8}E&V`)nFRQZO2F zRmeQ_Ykb0~1QVrH-B@G77LJ*%rlyQo@K@lGf5ef3S6ZCHfHyaDNg2{ zUfL4RfQ1|X&^%|5oPjboz@J)p6{9tv{Q>3zVW4MD3x-V1HQgUQ5Z+nohzZj0&6w_DPy%n?`wT6y4Y z{Dr3_ga)GOx*Y%Ik36h=b{>ovIZ~6O^r6+W1BFH8T*rnCZ>(c(Q0^aJ9LK&%G%GCy zdLeP$38)@eb7B!1i#!wawy{3o0cOGa*?RNdfIft5V>`SmA#BHjqeoZ1*g2+zMQZi% z>rIYrAQQ0P^*=babqCUVA&Dr1YYbqoA=! zvroI0=7Nfcy7#B#HJWFI=4`zcBY@Wq_GK?kTA?Ri-RUk!>H$amXv$&thw?kn`bOz*)QG;mj58oYaWvf+rIj-8?h%zFZRtj)hNy% z!I}lnrH2|k4y09FtOrhi`~Dp9(Cs)w*;%O@Zqw*4FGcY#V2Ai|XRG#Bn9Zv0^ty+6 z*pENn^4pegM5#OLtM?QqCH(wmR?r-~UvIUVPM@KPDUJIV?M%h}7i|Be;9vMYBk%>k z&sfZ)zTh$wjnDKKTs~tlllp?oOf){zUvT-1#Z2no1{bN49S|)n#-59r^`m1|wK4W0 zb7YiS9=`SspB+bUA zVt@LLGi{h2p!7(9;|tf{*Ep@&VU!qSU~p|bumPE}Jt=ixy04F~imQL`uNB$nu_NMy z^PSILetoJ{&0{_5Iw*_m(Fj=xgPaOs;E+?njtERf{NU81^&XCJIE4GmfC51U<*5i7 ziF}U+K_!?S*{f&@$c^z;0JX`fH2^IT)Pd&*0p)N&L2rr@1Hdd0G@M=u0L(U-DOyjr zC--KKVcK%8$;gLZNBz6LV2}^(myCy0akBMLP^+R&+;Y@mw`KNS%jSWg6&A7Y3F!x` zkWIM=URUz$wz9-jVxi9boXcdp-25Hd7AF@h#Uc$S>aLFN+xTz1*?BghA@CUq^3C=^B`5xQ!i+bK~@+-Jef%?jwGOf1)1n ze3Ms)Bc%HQKf2`Z-E8cH!kk!)XH>XHxfb-OBa41BBI>cPj>qHFuUonUhng%f+uN_{ zzYBE!IfP%ZP<58Gg83>XDI!{m*adX9w988nlscD{{lFa34jx0?mW3aPpV(QTSFb8jo7t~kUOj9hNQw6%YI zd|7H*v3ss}>O0SDBmUms-@FTa*n0h15>bOTp2@o4BhSR%(?k z9qrJj83jCi=7DJ|s#CihZVl(ddFf%r2U1c>Q_&fZ-D%Zd?Rdx8ztiHo?(W?-YIigO zYSCD^8JUuayc-wHUq~lar%-_NNcBldcFmzFQxd5*2Da1)p>gs>ed4o@sk;w_4>daR(b1&n!zu%^!ZYMr8TQrjA z$w(B>-_VVgXukY-rvB7}a(4$PMJpYR!yNFz{^?af=2`z2?+QrO>p=wU+Tp4oA7Dk3 zCu-M~-0odDNg5Ph1WGK{uEr;M-ff^&wKrYeC6HRRD+4Mf{eIrhnvKTX$2q1kqs?b= z>gtMm-=B^ty3=;bqPC%#u3=)`y7Y2V&5^U~oLCE<3;xjh%JP}+^Lm4;C7T^u=JD~} zI%e{>3g{`oKqCNssk)k?%($wfr0O8>#d3eVp_S*EKB~wU%MIJy|4++}2$Tmsd}j*( z$%_Ri1vC{e$YKRq%rIF{QiP5ap$YV~D*RpV-SFyuuLt{dh4Ia$sZ~}sMOo!LSI;-B z^PNL-cj(#LoZDqtdGq%>{>b&AUh!VBsopK|PnQ_JcCa3xnB8$nFI!yQ_B+aIU0P#5 z_+gLa?3#BUTpOUD%CFzJsPKg9?m*ZLuNOlpukJveTQRW3$N6C4g$FwpRm3Ma*pPy^ zZHd+|k4_zNi>*r5xi#c_*iJv_`&qtmRfpNd8q0NNHyorN|w<`YKoyzl~ ztBk3=CTSck=0Fy8Z1ZWys=>~EJBE8K_pLg)xM`k7tN$*-5Jb&tsX^g+CJXTmS+8!SK3SdaBuOj-(BmQeO~?7lx;c* zN*{dzh%vdnAIK_-HLZhQHD9dXTA7N)L-~hXzY5%T#oP#=-|RgdFTj>=(!pRf4gRlx zqM*OT0n@?Ue@YynBn6EwMtyD2y#G+K&bBC*`zC9{l;6M4S?W}DQ>j*Dm32#h5jYB9 zC`uAnp+miNGHPR~9mGn)h z>c5-VvhGq`dGdzh_kQ*S!xP5{bkr_a(TUx_rfY4_A&eB{%SA^nH1dF%wz)GpkT_%T z-$<%TQEBpkuh};b#h@-6f35y-(W?qS-!+C|EK7^)##x5fL*f-Q(J*e9k5@wg4i4Tl z;U^+M!;3~^hOt9UxS^ELFis>m%w$J~;BkB#4k3~gDhj1S6k0G_Bw%rA)X?y7h|eYq zsNoUed@hmAAwYNnjc-RK+T!t{kwP&8vf~Nq;cO9wKoc=ZY@RJSIGoL4Ncc1+SHczI zIKfnbki#JgLW%Z-NE}6gCozO9F^(c4Q-baJLNO$!3CUtIm1WDd7w~yxn!whMi)WI> z;XEFPC>99ubT*5~6owM13_L}`6;s(H8czUmad;dDPloJhBo>WfN2lY+0=6gu55h53A98iN`tvE|}GM_|3Ihe!5|Hnd$kz9P}_+;^gv8&@<1~hjLj7*LLncY4g1~Hm_6(6Yw-)f2O0$ zy22~_XD6(vH0zwN*HyJob4}Xdk6Ftt0*jUNZF$_Xob}kH@s!|1XWY5tTUk%tw|ZwL zYpv|&#&G5Y&Ce`&?y9bECzDw7V0$h4JE#5QmJ8B0S>|C(FYIXb-#q(9eqT}>cu>FL zSbACL{#^AqPsbIxCCTc0t*i^8H8)ix1~w$vx7?s!JQ7@XJ8F#nbA#Lb2vqcD``xc~ zj$%3u%Pti-jx35-t2Bzq4=WlwpD7$~7^434@EOt^0OZ{^On#^U=RI;$CAl^b5pJpY z!iiZE88EOnTwoqV8_>@RHs%fBbvY-L{|MbRt6-0UIJeY_Bz zf1#=WXV7p^8*4XL_pk0Y{KDgQ5R{n=S&@A`G5rOFY&iMB#XkTB C-)V;c literal 0 HcmV?d00001 diff --git a/src/ffor/request_store/fixtures/invoice/monitor.bin b/src/ffor/request_store/fixtures/invoice/monitor.bin new file mode 100644 index 0000000000000000000000000000000000000000..c1cd318d8d3aa0ed2e5db9b2435f19e521651ae3 GIT binary patch literal 6351 zcmeHLc|4Ts9)I6=#x|BzB*`+^qRD=X zM291{G~r^hFHJ)^x2%QXybM0vd%NR)&i$kF`FK8`d7t;0_gQ|w@ALire$Vp*L4a8x z`jhhTMs;8dAi&Mh?ksHc8E+sjMx^=d4femj?+^%J0146AqUQvuR1tamfX{ZOM#`js zkW8h6ib`t8c+v?Sog?#uZ}vvHC1%akUDuKzN4g$snx2yl?3K-3aEr`Kg1NTh+2cCK8fJH~U{-EbFYBVP9X6A1zF zz#%I%qFO9sdpY-E;S20MKD}G2BBxN*xKc`v{SV_Rf`)4KnrOtZk_`C@zKB}aBA9xz zt!=v3c7kdcm~15L<~e42>$#Lw{(^8|RL0ywfux<}`fn9>RL3Y>u6FGX3cpv<_*^C< zI(aPh^u9dJzBs70lirK^3G&mG&yGlHCIkvLe?32;^yyw75MR?}@fOuR zq?p?Bt8I8tVlbs9w1^m2JR;=0UA5{}D+U0VYc0d_t(PSZee3(u|LgshkXT)>1(X?1kZ!0!cecj zKc2}BNf!{_?HZ%{WJ#|zo^wE^{$def@IaF5sq=YXPE=q$303&G^f(F5M1e@JKBdBV z8GBJ)XK6_`xDy2bRuz!dsD%^6EZ7f7NHie4FVaF?8cYN4T7++Ny--WIdZxD^KBS#n z3fSUd2y!xKPSb@?Rq#$d8VT?X!A5w+0HB40^8On?@xYK&wj> z9zmiZDuFO(shxXPoacZIGUp8LC_jHFr$hd<<%fDlXCagUtmOy!`vWCl#@AwXPRCj#b_s+J>fgWY-Y2C{X+UnKoaoneUZy@?)bgSx00Izmt6<_F=XEEs zOR%B==X^g%&&CgL)Ej1#=cf85g51%C(|(F|vc;lBO3Qo|mVit)ZriWA{# z-(dRGHG}OYU*zbc;EX41F2UP*?l2FevrJMk>GJQ-95yPFko4I_Juk)ZHMYt3;klaj z3As_dQM#ZCc3^TpJOcGEqrf#>Fy@Ca4Q35j4cN54agky`0{{cEXFxUpjN+ns!1shj zOo23A0PO4HKeZutQ#y2jEUY4=kewx`m1IrWpvhTb+n z9wL*t9IKKEbA8?l4+;l!zu{Ti5mTFUBlEmpz0+vo6V9^!(r@W(QoiX%QzKfroA>S= z-L&Pdx|$bw2+}e8J2Ln>E9Y<~ReQy)ZqO1Fj5F##jtBg1P~5fLwKhcyugb1=D$^0G z;v1K)W`;drA05WAGq*IXZxgjIUZXKw0%GYKc(neEaq$5g5W4&Tq$}%gq{ty%S~m8@ zt!=v9Z4-UAk!-v9frZYqa6_9Ys*y0ShPJ|NoJ*j!`;pk=_k0ut+A1{$tfSN4CFye~ zT{S|20K4v=wp0eB!+2k1OFq{Y2+1DXX$XitN6eG*%=@L=>Fe`j$Fas z|0B^?u(v^j-k*XA2|5%&G6qo|vmCMr(J^9+PTDtH%IaDA+DBM_Qp0tt?!*>&g>6V~;f=3GP5vN2vSRd$q> z0d${p^=Up7KtF94I~s8ZH`Yk`sS_n>Dk3O80$M7Q#8ee|luw_POiI%JJ@_jeNjK60 zC=w~=8p`OXq0SVCQ9Gw}N`9^Nt*qxUn;v1Y!4S;vdGSk2n6k6V{J^q%7oaF3)lc@2 zj>gYmu@TqHMbuxo1jcVX&`I)7*b%w`KI>RerLR7AzW<~0u!B-#ij1s>K&y_%-pI|P z=X3S>-}@%Rva?E8!LqYTSIx`LiD4ZIQ2_V6r{QJBDL5s?WD?6K19=PqumKz(z=eiF ziGqVcz%&Js4M#4d>DQ51<<(AffcHK7NF^ds^plWhupZ`+2H&Jmq&B`ex&YbnQlXCnUup&K``nH) zNljwZVV@r9eRBcY*s%02dhvd00S2?Ro7ilnz=?(;1m$ewBVuT-bYCC$U0x;Lg%r=H zA4W$8@>KZ1k4`_MzE>Z{D&M@qhb4?`uEq%(CiTQS gJWL

H@Xw!^9b2&bt5ktN0%@Y{@%7KoSK10AFV%ZU6uP literal 0 HcmV?d00001 diff --git a/src/ffor/request_store/fixtures/invoice/route-announcement.bin b/src/ffor/request_store/fixtures/invoice/route-announcement.bin new file mode 100644 index 0000000000000000000000000000000000000000..dd1056f540da3fe044a849cf0b8618075b8943d7 GIT binary patch literal 430 zcmV;f0a5-KIVUaILN~w*1HYbEG1r&X*QvrtH6y*8Z1La zf?w-RL64_xj=p<-oiU6jA*#3t3R>rmiJ?G6NUpqz8;(4}Biy?*I8B@$*bv-*a3|e(VCvQWr?Q_h)qB|r?-?2ge@+ufDl!`kY3S_ zIM&HzvzB4G&V2c2wb4l=YXpE+IDYXHmjNhM07FTC*Z3xtaR~II582McuDW@bAcW}-tpVyY2><{90000B0003100MuK6@~Jw z4193oVGynC{g2U0ikZK&&;X)?{H!&P&WHktk1jp)LDCXH_i3-#IXL~nhl5sjnyzFN z7=YLi#vRTAYAOfrLu|%$TJ3~tb|+tE?G~hIaB9jrrGfGbh1gS;0?J5jS>~l9KIyJw Y2T!0UBi{o``>qD7u+zWB6WEI)Fo>VV$p8QV literal 0 HcmV?d00001 diff --git a/src/ffor/request_store/fixtures/invoice/route-update.bin b/src/ffor/request_store/fixtures/invoice/route-update.bin new file mode 100644 index 0000000000000000000000000000000000000000..d690057190aa61e7aea24905f7742b15f2b863a1 GIT binary patch literal 136 zcmXr++kdj~5M$LIrUmxC-lhgUN;V7;XV>ZF@!r*Sk>Og-u-LR?t*GtMcUx_a1jsJm zc**YdkD!Pr?GhJzg%r(XMVvkBum4b+TFCKbG5^hThu7_@oUYLFl5Z{JD`QRuFyLlj cU}Rv(+VJEiBO`+WM2z_b0|N*{!Hj7Q0G3oQRR910 literal 0 HcmV?d00001 diff --git a/src/ffor/request_store/fixtures/invoice/witness-manifest.bin b/src/ffor/request_store/fixtures/invoice/witness-manifest.bin new file mode 100644 index 0000000000000000000000000000000000000000..e0b8bcba9cbc9a6cc38f4b24d9f7329c97d7ac7a GIT binary patch literal 365 zcmZQ%3?Kk5ez^0(J8#i`xik9H59=CkTC%I!QAP4ph>W?H?X#FG%a7hT&7$iu^U^EE zfAv)>oqqjuyI8n!*NKyxS)ZnO*_zZbFfeefOUrpwQGKq`mX9mal zuH`+>#{13nXq33m!Tn2jzSfIeb!pu+cHKV=42-4>3~}92+TQ}5T}+S3t=2QY@`gv* zU+Ac(+lBukoPLW|C%rw&!pH!GX&F^(emi4}gy_3A2*X6>WokFpb+7Dk!+wZac<$oTr lU;o2Bwsq{9;) -> fmt::Result { + f.debug_struct("ConfirmedInvoice") + .field("client_id", &self.client_id) + .field("payment_id", &self.payment.id) + .field("invoice_digest", &self.invoice_digest) + .finish_non_exhaustive() + } +} + +enum NativeInvoice { + Awaiting, + Missing, + Stored(Box), +} + +impl RequestStore { + /// Reserve the policy durably, then obtain, retain and confirm the exact native invoice. + /// + /// Retry with the same policy is idempotent. A different policy is a conflict; the reserved + /// policy is never replaced. Fresh route evidence may be supplied on every retry because an + /// existing native assignment is recovered before any new preparation is attempted. + pub(in crate::ffor) fn issue_invoice( + &mut self, client_id: &str, policy: InvoicePolicy, route: &FFORWitnessRouteEvidence, + ) -> Result { + let record = self.lookup(client_id)?.ok_or(RequestStoreError::Missing)?; + let record = self.reserve_policy(client_id, record, policy)?; + let context = self.recovery_context(&record)?; + let intent = native_intent(&record, policy); + let mut prepared = None; + if record.invoice().is_none() { + if let NativeInvoice::Missing = self.fetch_stored(&context)? { + match self.manager.prepare_ffor_receiver_invoice(&context, &intent, route) { + Ok(preparation) => { + if !self + .manager + .is_ffor_state_persisted(preparation.persistence_requirement()) + { + return Ok(InvoiceProgress::AwaitingPersistence); + } + prepared = Some(preparation.invoice_digest()); + }, + Err(FFORReceiverError::ChannelState(FFORCommitmentError::PendingUpdates)) => { + return Ok(InvoiceProgress::AwaitingPersistence); + }, + Err(error) => return Err(RequestStoreError::Native(error)), + } + } + } + self.join_native(client_id, record, &context, &intent, prepared) + } + + /// Rejoin a native assignment after restart or a lost application write, without a route. + /// None means the record reserved no policy and native holds no assignment for it. + pub(in crate::ffor) fn recover_invoice( + &mut self, client_id: &str, + ) -> Result, RequestStoreError> { + let record = self.lookup(client_id)?.ok_or(RequestStoreError::Missing)?; + let Some(policy) = record.invoice_policy() else { + return Ok(None); + }; + if record.selector().is_none() { + return Ok(None); + } + let context = self.recovery_context(&record)?; + let intent = native_intent(&record, policy); + if record.invoice().is_none() { + if let NativeInvoice::Missing = self.fetch_stored(&context)? { + return Ok(None); + } + } + self.join_native(client_id, record, &context, &intent, None).map(Some) + } + + /// Mint a handle only when the record, native assignment and Pending payment all agree. The + /// exact payment row is confirmed again through a successful write before the handle exists. + pub(in crate::ffor) fn confirmed_invoice( + &mut self, client_id: &str, + ) -> Result, RequestStoreError> { + let record = self.lookup(client_id)?.ok_or(RequestStoreError::Missing)?; + let Some(policy) = record.invoice_policy() else { + return Ok(None); + }; + let Some(retained) = record.invoice().cloned() else { + return Ok(None); + }; + if !retained.payment_confirmed() { + return Ok(None); + } + let context = self.recovery_context(&record)?; + let stored = match self.fetch_stored(&context)? { + NativeInvoice::Awaiting => return Ok(None), + NativeInvoice::Missing => return Err(RequestStoreError::MissingNative), + NativeInvoice::Stored(stored) => *stored, + }; + check_agreement(&stored, &retained, &context, &native_intent(&record, policy))?; + let record = self.confirm_pending(client_id, record)?; + let selector = record.selector().ok_or(RequestStoreError::MissingNative)?; + Ok(Some(ConfirmedInvoice { + owner: self.instance, + client_id: client_id.to_owned(), + selector, + context_digest: retained.context_digest(), + invoice_digest: retained.digest(), + stored, + payment: retained.payment()?, + })) + } + + /// Publish once into `slot` under the payment store exclusion and the native monitor guard. + /// + /// Returns Ok(false) when native declined without error. Every refusal leaves the assignment, + /// record and payment row unchanged; the caller may retry with the same or a fresh handle. + /// The innermost callback only moves the exact bytes into the slot and performs no I/O. + pub(in crate::ffor) fn release_invoice( + &mut self, handle: &ConfirmedInvoice, slot: &mut Option, + ) -> Result { + if handle.owner != self.instance { + return Err(RequestStoreError::Conflict); + } + let record = self.lookup(&handle.client_id)?.ok_or(RequestStoreError::Missing)?; + let policy = record.invoice_policy().ok_or(RequestStoreError::Unreserved)?; + let retained = record.invoice().ok_or(RequestStoreError::Missing)?; + if !retained.payment_confirmed() + || retained.digest() != handle.invoice_digest + || retained.context_digest() != handle.context_digest + || record.selector() != Some(handle.selector) + || retained.payment()? != handle.payment + { + return Err(RequestStoreError::Conflict); + } + let context = self.recovery_context(&record)?; + check_agreement(&handle.stored, retained, &context, &native_intent(&record, policy))?; + if handle.stored.invoice_digest() != handle.invoice_digest { + return Err(RequestStoreError::Conflict); + } + let manager = &self.manager; + let mut published = None; + let outcome = self + .payments + .with_ffor_pending(&handle.payment, || { + manager.release_ffor_receiver_invoice(&handle.stored, |wire| { + published = Some(wire.to_owned()); + Ok(()) + }) + }) + .map_err(RequestStoreError::Payment)?; + let released = outcome.map_err(RequestStoreError::Native)?; + if released { + *slot = published; + } + Ok(released) + } + + fn reserve_policy( + &mut self, client_id: &str, mut record: StoredRequest, policy: InvoicePolicy, + ) -> Result { + if record.reserve_invoice(policy)? { + self.write_record(&record)?; + record = self.lookup(client_id)?.ok_or(RequestStoreError::Missing)?; + } + Ok(record) + } + + /// The stored selector must still name the exact native intent and epoch context. + fn recovery_context( + &self, record: &StoredRequest, + ) -> Result { + let selector = record.selector().ok_or(RequestStoreError::MissingNative)?; + if self.validate_native(record)?.is_none() { + return Err(RequestStoreError::MissingNative); + } + let context = self + .manager + .ffor_receiver_recovery_context(&selector.channel, selector.epoch) + .map_err(RequestStoreError::Native)?; + if context.channel_id() != record.plan().channel + || context.epoch_id() != selector.epoch + || context.settlement_node_id() != record.plan().settlement + || context.receiver_node_id() != self.node + { + return Err(RequestStoreError::Conflict); + } + Ok(context) + } + + fn fetch_stored( + &self, context: &FFORReceiverRecoveryContext, + ) -> Result { + match self.manager.ffor_receiver_invoice_for_storage(context) { + Ok(Some(stored)) => Ok(NativeInvoice::Stored(Box::new(stored))), + Ok(None) => Ok(NativeInvoice::Missing), + Err(FFORReceiverError::ChannelState(FFORCommitmentError::PendingUpdates)) => { + Ok(NativeInvoice::Awaiting) + }, + Err(error) => Err(RequestStoreError::Native(error)), + } + } + + fn join_native( + &mut self, client_id: &str, record: StoredRequest, context: &FFORReceiverRecoveryContext, + intent: &FFORInvoiceIntent, prepared: Option<[u8; 32]>, + ) -> Result { + let stored = match self.fetch_stored(context)? { + NativeInvoice::Awaiting => return Ok(InvoiceProgress::AwaitingPersistence), + NativeInvoice::Missing => return Err(RequestStoreError::MissingNative), + NativeInvoice::Stored(stored) => *stored, + }; + if prepared.is_some_and(|digest| digest != stored.invoice_digest()) { + return Err(RequestStoreError::Conflict); + } + let retained = match record.invoice() { + Some(retained) => retained.clone(), + None => { + let parsed: Bolt11Invoice = + stored.invoice_for_storage().parse().map_err(|_| RequestStoreError::Corrupt)?; + RetainedInvoice::new( + context.context_digest(), + stored.invoice_for_storage().to_owned(), + parsed.duration_since_epoch().as_secs(), + )? + }, + }; + check_agreement(&stored, &retained, context, intent)?; + let record = self.retain_native(client_id, record, retained)?; + if retained_marker(&record) { + // The marker proves an earlier successful exact write. Only the current row is checked + // here; a fresh write happens when a handle is minted. + self.payments + .with_ffor_pending( + &record.invoice().ok_or(RequestStoreError::Missing)?.payment()?, + || (), + ) + .map_err(RequestStoreError::Payment)?; + } else { + self.confirm_pending(client_id, record)?; + } + Ok(InvoiceProgress::Retained) + } + + fn retain_native( + &mut self, client_id: &str, before: StoredRequest, retained: RetainedInvoice, + ) -> Result { + let mut current = self.lookup(client_id)?.ok_or(RequestStoreError::Missing)?; + if current.encode() != before.encode() { + return Err(RequestStoreError::Conflict); + } + if current.retain_invoice(retained)? { + self.write_record(¤t)?; + } + Ok(current) + } + + /// Confirm the exact Pending row through a successful write, then mark the record. The + /// candidate stays installed across an ambiguous failure, blocking every later step. + fn confirm_pending( + &mut self, client_id: &str, before: StoredRequest, + ) -> Result { + let retained = before.invoice().ok_or(RequestStoreError::Missing)?; + let expected = retained.payment()?; + self.confirm_payment_candidate(&expected, retained.payment_confirmed())?; + let mut current = self.lookup(client_id)?.ok_or(RequestStoreError::Missing)?; + if current.encode() != before.encode() { + return Err(RequestStoreError::Conflict); + } + if current.confirm_invoice_payment(&expected)? { + self.write_record(¤t)?; + } + Ok(current) + } +} + +fn retained_marker(record: &StoredRequest) -> bool { + record.invoice().is_some_and(RetainedInvoice::payment_confirmed) +} + +fn native_intent(record: &StoredRequest, policy: InvoicePolicy) -> FFORInvoiceIntent { + FFORInvoiceIntent { + description: record.intent().description().to_owned(), + expiry_seconds: policy.expiry_seconds, + safety_margin_seconds: policy.safety_margin_seconds, + } +} + +fn check_agreement( + stored: &FFORStoredInvoice, retained: &RetainedInvoice, context: &FFORReceiverRecoveryContext, + intent: &FFORInvoiceIntent, +) -> Result<(), RequestStoreError> { + if stored.invoice_for_storage() != retained.invoice() + || stored.invoice_digest() != retained.digest() + || stored.intent() != intent + || stored.recovery_context().context_digest() != context.context_digest() + || retained.context_digest() != context.context_digest() + { + return Err(RequestStoreError::Conflict); + } + Ok(()) +} diff --git a/src/ffor/request_store/native.rs b/src/ffor/request_store/native.rs index f70093b8e8..2d606632c9 100644 --- a/src/ffor/request_store/native.rs +++ b/src/ffor/request_store/native.rs @@ -11,8 +11,9 @@ use crate::Node; use super::{RequestIntent, RequestPlan, RequestStore, RequestStoreError, StoredRequest}; impl RequestStore { - /// Capture actual Node identity and its concrete manager, without retaining the Node itself. - /// The internal caller supplies the same wallet seed and backing store used for that Node. + /// Capture actual Node identity, its concrete manager and its actual payment store, without + /// retaining the Node itself. The internal caller supplies the same wallet seed and backing + /// store used for that Node; no substitute payment store is accepted. pub(in crate::ffor) fn open( seed: &[u8; 64], node: &Node, storage: Arc, ) -> Result { @@ -21,6 +22,7 @@ impl RequestStore { ChainHash::using_genesis_block(node.config.network).to_bytes(), node.node_id(), Arc::clone(&node.channel_manager), + Arc::clone(&node.payment_store), storage, ) } @@ -99,7 +101,7 @@ impl RequestStore { self.rejoin_and_bind(client_id, &before, actual).map(Some) } - fn validate_native( + pub(super) fn validate_native( &self, before: &StoredRequest, ) -> Result, RequestStoreError> { let found = self diff --git a/src/ffor/request_store/record.rs b/src/ffor/request_store/record.rs index 5525da8f33..784988534a 100644 --- a/src/ffor/request_store/record.rs +++ b/src/ffor/request_store/record.rs @@ -7,7 +7,9 @@ use lightning_ffor::amounts::FeePolicy; use lightning_invoice::Description; use zeroize::Zeroizing; -use super::{RequestStoreError, VERSION}; +use super::{RequestStoreError, LEGACY_RECORD_BYTES, LEGACY_VERSION, MAX_RECORD_BYTES, VERSION}; +pub(super) mod invoice; +use invoice::{InvoiceAllocation, InvoicePolicy, RetainedInvoice}; const MAX_CLIENT_ID_BYTES: usize = 128; @@ -85,11 +87,12 @@ pub(in crate::ffor) struct StoredRequest { intent: RequestIntent, plan: RequestPlan, selector: Option, + invoice: InvoiceAllocation, } impl StoredRequest { pub(super) fn new(intent: RequestIntent, plan: RequestPlan) -> Result { - let record = Self { intent, plan, selector: None }; + let record = Self { intent, plan, selector: None, invoice: InvoiceAllocation::Legacy }; record.validate()?; Ok(record) } @@ -110,10 +113,112 @@ impl StoredRequest { let mut left = self.clone(); let mut right = other.clone(); left.selector = None; + left.invoice = InvoiceAllocation::Legacy; right.selector = None; + right.invoice = InvoiceAllocation::Legacy; left.encode() == right.encode() } + pub(super) fn validate_identity( + &self, chain: [u8; 32], node: PublicKey, + ) -> Result<(), RequestStoreError> { + if let Some(retained) = self.invoice() { + let invoice = retained.parsed()?; + let network = [ + bitcoin::Network::Bitcoin, + bitcoin::Network::Testnet, + bitcoin::Network::Testnet4, + bitcoin::Network::Signet, + bitcoin::Network::Regtest, + ] + .into_iter() + .find(|network| { + bitcoin::blockdata::constants::ChainHash::using_genesis_block(*network).to_bytes() + == chain + }) + .ok_or(RequestStoreError::Identity)?; + if invoice.currency() != network.into() || invoice.recover_payee_pub_key() != node { + return Err(RequestStoreError::Identity); + } + } + Ok(()) + } + + pub(super) fn version(&self) -> u16 { + if matches!(self.invoice, InvoiceAllocation::Legacy) { + LEGACY_VERSION + } else { + VERSION + } + } + pub(super) fn reserved_bytes(&self) -> usize { + if self.version() == LEGACY_VERSION { + LEGACY_RECORD_BYTES + } else { + MAX_RECORD_BYTES + } + } + pub(super) fn invoice_policy(&self) -> Option { + match self.invoice { + InvoiceAllocation::Legacy => None, + InvoiceAllocation::Reserved { policy, .. } => Some(policy), + } + } + pub(super) fn reserve_invoice( + &mut self, policy: InvoicePolicy, + ) -> Result { + policy.validate()?; + if self.plan.parameters.hash_chain { + return Err(RequestStoreError::InvalidIntent); + } + match self.invoice { + InvoiceAllocation::Legacy => { + self.invoice = InvoiceAllocation::Reserved { policy, invoice: None }; + Ok(true) + }, + InvoiceAllocation::Reserved { policy: existing, .. } if existing == policy => Ok(false), + _ => Err(RequestStoreError::Conflict), + } + } + pub(super) fn invoice(&self) -> Option<&RetainedInvoice> { + match &self.invoice { + InvoiceAllocation::Reserved { invoice, .. } => invoice.as_ref(), + _ => None, + } + } + pub(super) fn retain_invoice( + &mut self, value: RetainedInvoice, + ) -> Result { + if self.selector.is_none() { + return Err(RequestStoreError::MissingNative); + } + let InvoiceAllocation::Reserved { policy, invoice } = &mut self.invoice else { + return Err(RequestStoreError::Unreserved); + }; + value.validate(&self.intent, *policy)?; + match invoice { + Some(previous) + if previous.context_digest() != value.context_digest() + || previous.invoice() != value.invoice() => + { + Err(RequestStoreError::Conflict) + }, + Some(_) => Ok(false), + None => { + *invoice = Some(value); + Ok(true) + }, + } + } + pub(super) fn confirm_invoice_payment( + &mut self, payment: &crate::payment::store::PaymentDetails, + ) -> Result { + let InvoiceAllocation::Reserved { invoice: Some(invoice), .. } = &mut self.invoice else { + return Err(RequestStoreError::Missing); + }; + invoice.confirm_payment(payment) + } + pub(super) fn bind(&mut self, id: &FFORReceiverId) -> Result { if id.channel_id() != self.plan.channel { return Err(RequestStoreError::Conflict); @@ -158,12 +263,24 @@ impl StoredRequest { return Err(RequestStoreError::InvalidIntent); } } + if let InvoiceAllocation::Reserved { policy, invoice } = &self.invoice { + policy.validate()?; + if p.hash_chain { + return Err(RequestStoreError::InvalidIntent); + } + if let Some(invoice) = invoice { + if self.selector.is_none() { + return Err(RequestStoreError::MissingNative); + } + invoice.validate(&self.intent, *policy)?; + } + } Ok(()) } pub(super) fn encode(&self) -> Zeroizing> { let mut out = Zeroizing::new(Vec::new()); - out.extend_from_slice(&VERSION.to_be_bytes()); + out.extend_from_slice(&self.version().to_be_bytes()); string(&mut out, &self.intent.client_id); out.extend_from_slice(&self.intent.amount_msat.to_be_bytes()); string(&mut out, &self.intent.description); @@ -198,12 +315,14 @@ impl StoredRequest { out.extend_from_slice(&selector.epoch); }, } + self.invoice.encode(&mut out); out } pub(super) fn decode(bytes: &[u8]) -> Result { let mut reader = Reader(bytes); - if u16::from_be_bytes(reader.array()?) != VERSION { + let version = u16::from_be_bytes(reader.array()?); + if version != LEGACY_VERSION && version != VERSION { return Err(RequestStoreError::Corrupt); } let client_id = reader.string(MAX_CLIENT_ID_BYTES)?; @@ -241,11 +360,20 @@ impl StoredRequest { 1 => Some(RetainedSelector { channel, epoch: reader.array()? }), _ => return Err(RequestStoreError::Corrupt), }; + let invoice = if version == LEGACY_VERSION { + InvoiceAllocation::Legacy + } else { + InvoiceAllocation::decode(&mut reader)? + }; if !reader.0.is_empty() { return Err(RequestStoreError::Corrupt); } - let record = - Self { intent, plan: RequestPlan { channel, settlement, parameters }, selector }; + let record = Self { + intent, + plan: RequestPlan { channel, settlement, parameters }, + selector, + invoice, + }; record.validate()?; Ok(record) } diff --git a/src/ffor/request_store/record/invoice.rs b/src/ffor/request_store/record/invoice.rs new file mode 100644 index 0000000000..311675a39e --- /dev/null +++ b/src/ffor/request_store/record/invoice.rs @@ -0,0 +1,190 @@ +//! Protected storage framing. These records are not native publication capabilities. + +use std::fmt; + +use bitcoin::hashes::{sha256, Hash}; +use lightning::ln::channelmanager::PaymentId; +use lightning::util::ser::Writeable; +use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescriptionRef}; +use lightning_types::payment::PaymentHash; + +use super::{string, Reader, RequestIntent, RequestStoreError}; +use crate::payment::store::{PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus}; + +pub(in crate::ffor::request_store) const MAX_INVOICE_BYTES: usize = 4096; + +/// Fixed application expiry policy, retained before asking native to assign invoice bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::ffor) struct InvoicePolicy { + pub(in crate::ffor) expiry_seconds: u32, + pub(in crate::ffor) safety_margin_seconds: u32, +} + +impl InvoicePolicy { + pub(super) fn validate(&self) -> Result<(), RequestStoreError> { + if self.expiry_seconds == 0 { + return Err(RequestStoreError::InvalidIntent); + } + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub(super) enum InvoiceAllocation { + Legacy, + Reserved { policy: InvoicePolicy, invoice: Option }, +} + +#[derive(Clone, PartialEq, Eq)] +pub(in crate::ffor::request_store) struct RetainedInvoice { + context_digest: [u8; 32], + bolt11: String, + payment_created_at: u64, + payment_confirmation: Option<[u8; 32]>, +} + +impl fmt::Debug for RetainedInvoice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RetainedInvoice") + .field("invoice_bytes", &self.bolt11.len()) + .field("payment_confirmed", &self.payment_confirmation.is_some()) + .finish_non_exhaustive() + } +} + +impl RetainedInvoice { + pub(in crate::ffor::request_store) fn new( + context_digest: [u8; 32], bolt11: String, payment_created_at: u64, + ) -> Result { + let value = Self { context_digest, bolt11, payment_created_at, payment_confirmation: None }; + value.parsed()?; + Ok(value) + } + pub(in crate::ffor::request_store) fn context_digest(&self) -> [u8; 32] { + self.context_digest + } + pub(in crate::ffor::request_store) fn invoice(&self) -> &str { + &self.bolt11 + } + pub(in crate::ffor::request_store) fn digest(&self) -> [u8; 32] { + sha256::Hash::hash(self.bolt11.as_bytes()).to_byte_array() + } + pub(in crate::ffor::request_store) fn payment_confirmed(&self) -> bool { + self.payment_confirmation.is_some() + } + pub(in crate::ffor::request_store) fn parsed( + &self, + ) -> Result { + if self.bolt11.is_empty() || self.bolt11.len() > MAX_INVOICE_BYTES { + return Err(RequestStoreError::Capacity); + } + let invoice: Bolt11Invoice = self.bolt11.parse().map_err(|_| RequestStoreError::Corrupt)?; + if invoice.to_string() != self.bolt11 { + return Err(RequestStoreError::Corrupt); + } + Ok(invoice) + } + pub(in crate::ffor::request_store) fn payment( + &self, + ) -> Result { + let invoice = self.parsed()?; + let description = match invoice.description() { + Bolt11InvoiceDescriptionRef::Direct(value) => value.as_inner().0.clone(), + _ => return Err(RequestStoreError::Conflict), + }; + let hash = PaymentHash(invoice.payment_hash().to_byte_array()); + Ok(PaymentDetails { + id: PaymentId(hash.0), + kind: PaymentKind::Bolt11 { + hash, + preimage: None, + secret: Some(*invoice.payment_secret()), + description: Some(description), + bolt11: Some(self.bolt11.clone()), + }, + amount_msat: invoice.amount_milli_satoshis(), + fee_paid_msat: None, + direction: PaymentDirection::Inbound, + status: PaymentStatus::Pending, + latest_update_timestamp: self.payment_created_at, + }) + } + pub(in crate::ffor::request_store) fn confirm_payment( + &mut self, payment: &PaymentDetails, + ) -> Result { + if self.payment()? != *payment { + return Err(RequestStoreError::Conflict); + } + let digest = sha256::Hash::hash(&payment.encode()).to_byte_array(); + match self.payment_confirmation { + Some(existing) if existing != digest => Err(RequestStoreError::Conflict), + Some(_) => Ok(false), + None => { + self.payment_confirmation = Some(digest); + Ok(true) + }, + } + } + pub(super) fn validate( + &self, intent: &RequestIntent, policy: InvoicePolicy, + ) -> Result<(), RequestStoreError> { + let invoice = self.parsed()?; + if invoice.amount_milli_satoshis() != Some(intent.amount_msat()) + || invoice.expiry_time().as_secs() == 0 + || invoice.expiry_time().as_secs() > u64::from(policy.expiry_seconds) + || !matches!(invoice.description(), Bolt11InvoiceDescriptionRef::Direct(value) if value.as_inner().0 == intent.description()) + { + return Err(RequestStoreError::Conflict); + } + let payment_digest = sha256::Hash::hash(&self.payment()?.encode()).to_byte_array(); + if self.payment_confirmation.is_some_and(|digest| digest != payment_digest) { + return Err(RequestStoreError::Corrupt); + } + Ok(()) + } +} + +impl InvoiceAllocation { + pub(super) fn encode(&self, out: &mut Vec) { + if let Self::Reserved { policy, invoice } = self { + out.extend_from_slice(&policy.expiry_seconds.to_be_bytes()); + out.extend_from_slice(&policy.safety_margin_seconds.to_be_bytes()); + match invoice { + None => out.push(0), + Some(invoice) => { + out.push(1); + out.extend_from_slice(&invoice.context_digest); + string(out, &invoice.bolt11); + out.extend_from_slice(&invoice.payment_created_at.to_be_bytes()); + match invoice.payment_confirmation { + None => out.push(0), + Some(digest) => { + out.push(1); + out.extend_from_slice(&digest); + }, + } + }, + } + } + } + pub(super) fn decode(reader: &mut Reader<'_>) -> Result { + let policy = + InvoicePolicy { expiry_seconds: reader.u32()?, safety_margin_seconds: reader.u32()? }; + policy.validate()?; + let invoice = match reader.byte()? { + 0 => None, + 1 => Some(RetainedInvoice { + context_digest: reader.array()?, + bolt11: reader.string(MAX_INVOICE_BYTES)?, + payment_created_at: u64::from_be_bytes(reader.array()?), + payment_confirmation: match reader.byte()? { + 0 => None, + 1 => Some(reader.array()?), + _ => return Err(RequestStoreError::Corrupt), + }, + }), + _ => return Err(RequestStoreError::Corrupt), + }; + Ok(Self::Reserved { policy, invoice }) + } +} diff --git a/src/ffor/request_store/tests.rs b/src/ffor/request_store/tests.rs index 011d281d2c..5d174a33ba 100644 --- a/src/ffor/request_store/tests.rs +++ b/src/ffor/request_store/tests.rs @@ -456,6 +456,7 @@ fn ffor_request_store_identity_key_and_parameter_substitution_are_authenticated( store.chain, store.node, Arc::clone(&store.manager), + Arc::clone(&store.payments), storage ), Err(RequestStoreError::Corrupt) @@ -522,7 +523,7 @@ fn ffor_request_store_codec_rejects_trailing_truncated_and_invalid_parameters() extra.push(0); assert!(StoredRequest::decode(&extra).is_err()); let mut future = bytes.to_vec(); - future[1] = 2; + future[1] = 3; assert!(StoredRequest::decode(&future).is_err()); let mut invalid = plan(&store, CLIENT); invalid.parameters.amounts_msat.push(1); @@ -535,7 +536,10 @@ fn ffor_request_store_codec_rejects_trailing_truncated_and_invalid_parameters() proptest! { #[test] - fn ffor_request_store_bounded_decoder_never_accepts_noncanonical_bytes(bytes in prop::collection::vec(any::(), 0..4097)) { + fn ffor_request_store_bounded_decoder_never_accepts_noncanonical_bytes(bytes in prop::collection::vec(any::(), 0..8193)) { if let Ok(record) = StoredRequest::decode(&bytes) { prop_assert_eq!(&*record.encode(), &bytes); } } } + +mod invoice; +mod issuer; diff --git a/src/ffor/request_store/tests/invoice.rs b/src/ffor/request_store/tests/invoice.rs new file mode 100644 index 0000000000..926823b73c --- /dev/null +++ b/src/ffor/request_store/tests/invoice.rs @@ -0,0 +1,251 @@ +//! Protected codec tests only. Native publication authority is tested through the issuer adapter. + +use bitcoin::hashes::{sha256, Hash}; +use lightning_invoice::{Currency, InvoiceBuilder, RouteHint, RouteHintHop, RoutingFees}; +use lightning_types::payment::PaymentSecret; + +use super::*; +use crate::ffor::request_store::record::invoice::{InvoicePolicy, RetainedInvoice}; + +fn policy() -> InvoicePolicy { + InvoicePolicy { expiry_seconds: 3600, safety_margin_seconds: 120 } +} +fn signed_invoice(note: &str, amount: u64) -> String { + signed_invoice_with_routes(note, amount, 0) +} +fn signed_invoice_with_routes(note: &str, amount: u64, routes: usize) -> String { + let root = bitcoin::bip32::Xpriv::new_master(Network::Testnet, &SEED).unwrap().private_key; + let keys = lightning::sign::KeysManager::new(&root.secret_bytes(), 0, 0, true); + let key = keys.get_node_secret_key(); + let mut builder = InvoiceBuilder::new(Currency::BitcoinTestnet) + .description(note.to_owned()) + .duration_since_epoch(std::time::Duration::from_secs(1)) + .expiry_time(std::time::Duration::from_secs(3600)) + .amount_milli_satoshis(amount) + .payment_hash(sha256::Hash::hash(&[9; 32])) + .payment_secret(PaymentSecret([8; 32])) + .min_final_cltv_expiry_delta(18); + for index in 0..routes { + builder = builder.private_route(RouteHint(vec![RouteHintHop { + src_node_id: PublicKey::from_secret_key(&Secp256k1::new(), &key), + short_channel_id: index as u64, + fees: RoutingFees { base_msat: 0, proportional_millionths: 0 }, + cltv_expiry_delta: 18, + htlc_minimum_msat: None, + htlc_maximum_msat: None, + }])); + } + builder.build_signed(|m| Secp256k1::new().sign_ecdsa_recoverable(m, &key)).unwrap().to_string() +} +fn reserved(store: &mut RequestStore) -> StoredRequest { + store.recover_native(CLIENT).unwrap().unwrap(); + let mut record = store.lookup(CLIENT).unwrap().unwrap(); + assert!(record.reserve_invoice(policy()).unwrap()); + store.write_record(&record).unwrap(); + record +} +fn retain(record: &mut StoredRequest) { + let invoice = RetainedInvoice::new( + [7; 32], + signed_invoice(record.intent().description(), record.intent().amount_msat()), + 5, + ) + .unwrap(); + assert!(record.retain_invoice(invoice).unwrap()); +} + +#[test] +fn ffor_request_invoice_v2_upgrade_preserves_intent_and_rejects_policy_replacement() { + let (storage, node, mut store) = pending(); + let legacy = store.lookup(CLIENT).unwrap().unwrap(); + assert_eq!(legacy.version(), LEGACY_VERSION); + let upgraded = reserved(&mut store); + assert_eq!(upgraded.reserved_bytes(), 8192); + assert!(upgraded.same_request(&legacy)); + let ciphertext = raw(&storage, &store, CLIENT); + let mut unchanged = upgraded.clone(); + assert!(!unchanged.reserve_invoice(policy()).unwrap()); + assert_eq!( + unchanged.reserve_invoice(InvoicePolicy { expiry_seconds: 1, ..policy() }), + Err(RequestStoreError::Conflict) + ); + assert_eq!(raw(&storage, &store, CLIENT), ciphertext); + drop(store); + let mut reopened = open(storage, &node); + assert_eq!(reopened.lookup(CLIENT).unwrap().unwrap().encode(), upgraded.encode()); +} + +#[test] +fn ffor_request_invoice_exact_bytes_and_payment_marker_are_monotonic() { + let (storage, node, mut store) = pending(); + let mut record = reserved(&mut store); + retain(&mut record); + assert!(!format!("{record:?}").contains("lntb")); + let payment = record.invoice().unwrap().payment().unwrap(); + assert!(!record.invoice().unwrap().payment_confirmed()); + store.write_record(&record).unwrap(); + assert!(record.confirm_invoice_payment(&payment).unwrap()); + assert!(!record.confirm_invoice_payment(&payment).unwrap()); + let mut changed = payment.clone(); + changed.latest_update_timestamp += 1; + assert_eq!(record.confirm_invoice_payment(&changed), Err(RequestStoreError::Conflict)); + assert_eq!( + record.retain_invoice(RetainedInvoice::new([8; 32], payment_invoice(&payment), 5).unwrap()), + Err(RequestStoreError::Conflict) + ); + store.write_record(&record).unwrap(); + drop(store); + let mut reopened = open(storage, &node); + let restored = reopened.lookup(CLIENT).unwrap().unwrap(); + assert_eq!(restored.encode(), record.encode()); + assert!(restored.invoice().unwrap().payment_confirmed()); + assert_eq!(restored.invoice().unwrap().payment().unwrap(), payment); +} +fn payment_invoice(payment: &crate::payment::store::PaymentDetails) -> String { + match &payment.kind { + crate::payment::store::PaymentKind::Bolt11 { bolt11: Some(wire), .. } => wire.clone(), + _ => panic!("invoice"), + } +} + +#[test] +fn ffor_request_invoice_every_protected_write_keeps_exact_uncertain_candidate() { + for stage in 0..3 { + for failure in [1, 2] { + let (storage, node, mut store) = pending(); + store.recover_native(CLIENT).unwrap(); + let mut record = store.lookup(CLIENT).unwrap().unwrap(); + record.reserve_invoice(policy()).unwrap(); + if stage >= 1 { + store.write_record(&record).unwrap(); + retain(&mut record); + } + if stage == 2 { + store.write_record(&record).unwrap(); + record + .confirm_invoice_payment(&record.invoice().unwrap().payment().unwrap()) + .unwrap(); + } + storage.write_failure.store(failure, Ordering::SeqCst); + assert_eq!(store.write_record(&record), Err(RequestStoreError::Storage)); + let candidate = store.uncertain.as_ref().unwrap().bytes.clone(); + assert!(matches!(store.lookup(CLIENT), Err(RequestStoreError::Uncertain))); + storage.write_failure.store(2, Ordering::SeqCst); + assert_eq!(store.recover_write(), Err(RequestStoreError::Storage)); + assert_eq!(store.uncertain.as_ref().unwrap().bytes, candidate); + drop(store); + let mut reopened = open(Arc::clone(&storage), &node); + storage.write_failure.store(2, Ordering::SeqCst); + assert!(matches!(reopened.lookup(CLIENT), Err(RequestStoreError::Storage))); + reopened.recover_write().unwrap(); + assert_eq!(reopened.lookup(CLIENT).unwrap().unwrap().encode(), record.encode()); + assert_eq!(raw(&storage, &reopened, CLIENT), candidate); + } + } +} + +#[test] +fn ffor_request_invoice_v2_strict_framing_and_payment_digest_validation() { + let (_, _, mut store) = pending(); + let mut value = reserved(&mut store); + retain(&mut value); + value.confirm_invoice_payment(&value.invoice().unwrap().payment().unwrap()).unwrap(); + let bytes = value.encode(); + for end in 0..bytes.len() { + assert!(StoredRequest::decode(&bytes[..end]).is_err()); + } + let mut extra = bytes.to_vec(); + extra.push(0); + assert!(StoredRequest::decode(&extra).is_err()); + let mut corrupt = bytes.to_vec(); + *corrupt.last_mut().unwrap() ^= 1; + assert!(StoredRequest::decode(&corrupt).is_err()); + let mut downgrade = bytes.to_vec(); + downgrade[1] = LEGACY_VERSION as u8; + assert!(StoredRequest::decode(&downgrade).is_err()); + let mut future = bytes.to_vec(); + future[1] = 3; + assert!(StoredRequest::decode(&future).is_err()); + let mut legacy = StoredRequest::new(intent(CLIENT), plan(&store, CLIENT)).unwrap(); + assert!(legacy.retain_invoice(value.invoice().unwrap().clone()).is_err()); + assert_eq!( + legacy.reserve_invoice(InvoicePolicy { expiry_seconds: 0, ..policy() }), + Err(RequestStoreError::InvalidIntent) + ); + let wrong = RetainedInvoice::new([7; 32], signed_invoice("changed", 2_000_000), 5).unwrap(); + assert_eq!(value.retain_invoice(wrong), Err(RequestStoreError::Conflict)); +} + +#[test] +fn ffor_request_invoice_reserved_quota_allows_all64_upgrades_without_eviction() { + let (_, _, mut store) = fixture(); + for index in 0..MAX_REQUESTS { + let client = format!("quota-{index}"); + let mut record = store.begin(intent(&client), plan(&store, &client)).unwrap(); + record.reserve_invoice(policy()).unwrap(); + store.write_record(&record).unwrap(); + } + assert_eq!(store.reserved_bytes_without("").unwrap(), MAX_STORE_BYTES); + assert!(matches!( + store.begin(intent(CLIENT), plan(&store, CLIENT)), + Err(RequestStoreError::Capacity) + )); + assert_eq!(store.list().unwrap().len(), MAX_REQUESTS); +} + +#[test] +fn ffor_request_invoice_full_envelope_fits_reserved_capacity_and_binds_identity() { + let (_, _, mut store) = pending(); + let native_id = store.recover_native(CLIENT).unwrap().unwrap(); + let client = "c".repeat(128); + let note = "n".repeat(639); + let intent = RequestIntent::new(client.clone(), 2_000_000, note.clone()).unwrap(); + let mut plan = plan(&store, &client); + plan.parameters.witness_peers = Some( + (1..=4) + .map(|i| { + PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[i; 32]).unwrap(), + ) + }) + .collect(), + ); + let mut record = StoredRequest::new(intent, plan).unwrap(); + record.bind(&native_id).unwrap(); + record.reserve_invoice(policy()).unwrap(); + let mut largest = String::new(); + for count in 0..64 { + let wire = signed_invoice_with_routes(¬e, 2_000_000, count); + if wire.len() > 4096 { + break; + } + largest = wire; + } + assert!(largest.len() > 4000); + record.retain_invoice(RetainedInvoice::new([7; 32], largest, u64::MAX).unwrap()).unwrap(); + record.confirm_invoice_payment(&record.invoice().unwrap().payment().unwrap()).unwrap(); + let key = hex(&record.local_request_id()); + let sealed = store.envelope.seal(&key, &record).unwrap(); + assert!(sealed.len() < MAX_RECORD_BYTES); + assert_eq!(store.envelope.open(&key, &sealed).unwrap().encode(), record.encode()); + record.validate_identity(store.chain, store.node).unwrap(); + let wrong_key = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42; 32]).unwrap()); + assert_eq!(record.validate_identity(store.chain, wrong_key), Err(RequestStoreError::Identity)); + let bitcoin = + bitcoin::blockdata::constants::ChainHash::using_genesis_block(Network::Bitcoin).to_bytes(); + assert_eq!(record.validate_identity(bitcoin, store.node), Err(RequestStoreError::Identity)); +} + +#[test] +fn ffor_request_invoice_description_bytes_are_not_display_normalized() { + let note = "exact\n\u{1b}note"; + let retained = RetainedInvoice::new([1; 32], signed_invoice(note, 2000), 5).unwrap(); + match retained.payment().unwrap().kind { + crate::payment::store::PaymentKind::Bolt11 { description, .. } => { + assert_eq!(description.as_deref(), Some(note)) + }, + _ => panic!("invoice kind"), + } +} diff --git a/src/ffor/request_store/tests/issuer.rs b/src/ffor/request_store/tests/issuer.rs new file mode 100644 index 0000000000..bb547f2faf --- /dev/null +++ b/src/ffor/request_store/tests/issuer.rs @@ -0,0 +1,547 @@ +//! Issuer adapter tests against the genuine exported native invoice fixture. + +use bitcoin::block::{Header, Version}; +use bitcoin::hash_types::TxMerkleNode; +use bitcoin::pow::CompactTarget; +use bitcoin::BlockHash; +use lightning::chain::Confirm; +use lightning::ln::ffor::FFORWitnessRouteEvidence; +use lightning::ln::msgs::{ChannelAnnouncement, ChannelUpdate, UnsignedGossipMessage}; +use lightning::sign::{KeysManager, NodeSigner, Recipient}; +use lightning::util::ser::LengthReadable; + +use super::*; +use crate::data_store::ffor::FFORPaymentError; +use crate::data_store::StorableObjectId; +use crate::ffor::request_store::invoice::{ConfirmedInvoice, InvoiceProgress}; +use crate::ffor::request_store::record::invoice::InvoicePolicy; +use crate::io::{ + PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, +}; +use crate::payment::store::{PaymentDetails, PaymentDetailsUpdate, PaymentStatus}; + +const ACTIVE: &[u8] = include_bytes!("../fixtures/invoice/active-manager.bin"); +const ISSUED: &[u8] = include_bytes!("../fixtures/invoice/issued-manager.bin"); +const MONITOR: &[u8] = include_bytes!("../fixtures/invoice/monitor.bin"); + +fn ifield(name: &str) -> &'static str { + include_str!("../fixtures/invoice/fixture.txt") + .lines() + .find_map(|line| line.strip_prefix(&format!("{name}="))) + .unwrap() +} + +fn policy() -> InvoicePolicy { + InvoicePolicy { + expiry_seconds: ifield("expiry_seconds").parse().unwrap(), + safety_margin_seconds: ifield("safety_margin_seconds").parse().unwrap(), + } +} + +fn issuer_intent() -> RequestIntent { + RequestIntent::new( + ifield("client_id").to_owned(), + ifield("amount_msat").parse().unwrap(), + ifield("description").to_owned(), + ) + .unwrap() +} + +fn issuer_plan(store: &RequestStore) -> RequestPlan { + let mut plan = plan(store, ifield("client_id")); + plan.parameters.settlement_deadline = ifield("settlement_deadline").parse().unwrap(); + plan.parameters.voucher_expiry = ifield("voucher_expiry").parse().unwrap(); + plan.parameters.witness_peers = Some(vec![PublicKey::from_str(ifield("witness")).unwrap()]); + plan +} + +fn install_native(storage: &TestStore, manager: &[u8]) { + KVStoreSync::write( + storage, + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_KEY, + manager.to_vec(), + ) + .unwrap(); + KVStoreSync::write( + storage, + CHANNEL_MONITOR_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MONITOR_PERSISTENCE_SECONDARY_NAMESPACE, + &format!("{}_{}", ifield("funding_txid"), ifield("funding_vout")), + MONITOR.to_vec(), + ) + .unwrap(); +} + +fn persist_manager(node: &Node, storage: &TestStore) { + let token = node.channel_manager.capture_ffor_persistence(); + KVStoreSync::write( + storage, + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_KEY, + node.channel_manager.encode(), + ) + .unwrap(); + node.channel_manager.ffor_persistence_completed(token).unwrap(); +} + +fn now() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() +} + +fn witness_keys() -> KeysManager { + let seed = <[u8; 32]>::from_hex(ifield("witness_node_seed")).unwrap(); + let keys = KeysManager::new(&seed, 0, 0, true); + assert_eq!( + keys.get_node_id(Recipient::Node).unwrap(), + PublicKey::from_str(ifield("witness")).unwrap() + ); + keys +} + +fn route_at(timestamp: Option) -> FFORWitnessRouteEvidence { + let announcement = ChannelAnnouncement::read_from_fixed_length_buffer( + &mut include_bytes!("../fixtures/invoice/route-announcement.bin").as_slice(), + ) + .unwrap(); + let mut update = ChannelUpdate::read_from_fixed_length_buffer( + &mut include_bytes!("../fixtures/invoice/route-update.bin").as_slice(), + ) + .unwrap(); + if let Some(timestamp) = timestamp { + update.contents.timestamp = timestamp as u32; + update.signature = witness_keys() + .sign_gossip_message(UnsignedGossipMessage::ChannelUpdate(&update.contents)) + .unwrap(); + } + FFORWitnessRouteEvidence { announcement, update } +} + +fn route() -> FFORWitnessRouteEvidence { + route_at(Some(now())) +} + +/// Begin the exact application record on the empty pair, then restore the native fixture. +fn bound(manager: &[u8]) -> (Arc, Node, RequestStore) { + let storage = TestStore::new(); + install_fixture(&storage, false); + let original = restore_node(Arc::clone(&storage)); + let mut store = open(Arc::clone(&storage), &original); + store.begin(issuer_intent(), issuer_plan(&store)).unwrap(); + drop(store); + drop(original); + install_native(&storage, manager); + let node = restore_node(Arc::clone(&storage)); + let mut store = open(Arc::clone(&storage), &node); + let id = store.recover_native(CLIENT).unwrap().unwrap(); + assert_eq!(hex(&id.epoch_id()), ifield("epoch_id")); + (storage, node, store) +} + +fn drive( + storage: &TestStore, node: &Node, store: &mut RequestStore, + mut step: impl FnMut(&mut RequestStore) -> Result, +) -> Result { + for _ in 0..4 { + match step(store)? { + InvoiceProgress::AwaitingPersistence => persist_manager(node, storage), + InvoiceProgress::Retained => return Ok(InvoiceProgress::Retained), + } + } + panic!("native persistence never completed") +} + +fn issue_live(storage: &TestStore, node: &Node, store: &mut RequestStore) -> StoredRequest { + assert_eq!( + drive(storage, node, store, |store| store.issue_invoice(CLIENT, policy(), &route())), + Ok(InvoiceProgress::Retained) + ); + let record = store.lookup(CLIENT).unwrap().unwrap(); + assert!(record.invoice().unwrap().payment_confirmed()); + record +} + +fn stored_payment(storage: &TestStore, payment: &PaymentDetails) -> Option> { + KVStoreSync::read( + storage, + PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, + PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + &payment.id.encode_to_hex_str(), + ) + .ok() +} + +fn native_digest(node: &Node, store: &RequestStore) -> Option<[u8; 32]> { + let record = store.records.keys().next().cloned().unwrap(); + let record = store.envelope.open(&record, store.records.get(&record).unwrap()).unwrap(); + let selector = record.selector().unwrap(); + let context = node + .channel_manager + .ffor_receiver_recovery_context(&selector.channel, selector.epoch) + .unwrap(); + node.channel_manager + .ffor_receiver_invoice_for_storage(&context) + .unwrap() + .map(|stored| stored.invoice_digest()) +} + +fn release( + store: &mut RequestStore, handle: &ConfirmedInvoice, +) -> Result, RequestStoreError> { + let mut slot = None; + let released = store.release_invoice(handle, &mut slot)?; + assert_eq!(released, slot.is_some()); + Ok(slot) +} + +#[test] +fn ffor_request_issuer_live_issue_retains_exact_bytes_and_payment_then_releases_repeatably() { + let (storage, node, mut store) = bound(ACTIVE); + assert_eq!(store.recover_invoice(CLIENT), Ok(None)); + assert!(store.confirmed_invoice(CLIENT).unwrap().is_none()); + assert_eq!(native_digest(&node, &store), None); + assert_eq!( + store.issue_invoice(CLIENT, policy(), &route()), + Ok(InvoiceProgress::AwaitingPersistence) + ); + assert!(store.lookup(CLIENT).unwrap().unwrap().invoice().is_none()); + let record = issue_live(&storage, &node, &mut store); + let retained = record.invoice().unwrap(); + let invoice = retained.parsed().unwrap(); + assert_eq!(invoice.amount_milli_satoshis(), Some(2_000_000)); + assert_eq!(invoice.recover_payee_pub_key(), node.node_id()); + assert_eq!(invoice.route_hints().len(), 1); + assert_eq!(invoice.route_hints()[0].0.len(), 2); + assert_eq!(native_digest(&node, &store), Some(retained.digest())); + let payment = retained.payment().unwrap(); + assert_eq!(node.payment_store.get(&payment.id), Some(payment.clone())); + assert_eq!(stored_payment(&storage, &payment), Some(payment.encode())); + + let writes = storage.writes.load(Ordering::SeqCst); + assert_eq!(store.issue_invoice(CLIENT, policy(), &route()), Ok(InvoiceProgress::Retained)); + assert_eq!(store.recover_invoice(CLIENT), Ok(Some(InvoiceProgress::Retained))); + assert_eq!(storage.writes.load(Ordering::SeqCst), writes); + assert_eq!(store.lookup(CLIENT).unwrap().unwrap().encode(), record.encode()); + + let handle = store.confirmed_invoice(CLIENT).unwrap().unwrap(); + assert_eq!(storage.writes.load(Ordering::SeqCst), writes + 1); + assert!(!format!("{handle:?}").contains("lntb")); + assert_eq!(release(&mut store, &handle), Ok(Some(retained.invoice().to_owned()))); + assert_eq!(release(&mut store, &handle), Ok(Some(retained.invoice().to_owned()))); + assert_eq!(node.payment_store.get(&payment.id).unwrap().status, PaymentStatus::Pending); + assert_eq!(storage.writes.load(Ordering::SeqCst), writes + 1); +} + +#[test] +fn ffor_request_issuer_every_write_failure_keeps_exact_candidate_and_blocks_all_handles() { + // Application writes after native persistence: retain, payment, marker, then the fresh + // handle write. Failure 1 lands the bytes but reports failure; failure 2 lands nothing. + for stage in 1..=4 { + for failure in [1, 2] { + let (storage, node, mut store) = bound(ACTIVE); + persist_manager(&node, &storage); + assert_eq!( + store.issue_invoice(CLIENT, policy(), &route()), + Ok(InvoiceProgress::AwaitingPersistence) + ); + persist_manager(&node, &storage); + let digest = native_digest(&node, &store).unwrap(); + let mut earlier = None; + if stage == 4 { + assert_eq!( + store.issue_invoice(CLIENT, policy(), &route()), + Ok(InvoiceProgress::Retained) + ); + earlier = Some(store.confirmed_invoice(CLIENT).unwrap().unwrap()); + } + let base = storage.writes.load(Ordering::SeqCst); + storage.fail_at.store(base + if stage == 4 { 1 } else { stage }, Ordering::SeqCst); + storage.write_failure.store(failure, Ordering::SeqCst); + let outcome = if stage == 4 { + store.confirmed_invoice(CLIENT).map(|_| ()).unwrap_err() + } else { + store.issue_invoice(CLIENT, policy(), &route()).unwrap_err() + }; + assert!( + matches!( + outcome, + RequestStoreError::Storage + | RequestStoreError::Payment(FFORPaymentError::Storage) + ), + "stage {stage} failure {failure}: {outcome:?}" + ); + assert_eq!( + stage == 2 || stage == 4, + matches!(outcome, RequestStoreError::Payment(FFORPaymentError::Storage)) + ); + storage.fail_at.store(0, Ordering::SeqCst); + assert_eq!( + store.issue_invoice(CLIENT, policy(), &route()), + Err(RequestStoreError::Uncertain) + ); + assert_eq!(store.recover_invoice(CLIENT), Err(RequestStoreError::Uncertain)); + assert!(matches!(store.confirmed_invoice(CLIENT), Err(RequestStoreError::Uncertain))); + if let Some(earlier) = &earlier { + assert_eq!(release(&mut store, earlier), Err(RequestStoreError::Uncertain)); + } + storage.write_failure.store(2, Ordering::SeqCst); + assert!(matches!( + store.recover_write(), + Err(RequestStoreError::Storage + | RequestStoreError::Payment(FFORPaymentError::Storage)) + )); + assert_eq!(store.recover_invoice(CLIENT), Err(RequestStoreError::Uncertain)); + store.recover_write().unwrap(); + assert_eq!( + store.issue_invoice(CLIENT, policy(), &route()), + Ok(InvoiceProgress::Retained) + ); + let record = store.lookup(CLIENT).unwrap().unwrap(); + let retained = record.invoice().unwrap(); + assert!(retained.payment_confirmed()); + assert_eq!(retained.digest(), digest); + assert_eq!(native_digest(&node, &store), Some(digest)); + let payment = retained.payment().unwrap(); + assert_eq!(node.payment_store.get(&payment.id), Some(payment.clone())); + assert_eq!(stored_payment(&storage, &payment), Some(payment.encode())); + if let Some(earlier) = &earlier { + assert_eq!(release(&mut store, earlier), Ok(Some(retained.invoice().to_owned()))); + } + let handle = store.confirmed_invoice(CLIENT).unwrap().unwrap(); + assert_eq!(release(&mut store, &handle), Ok(Some(retained.invoice().to_owned()))); + } + } +} + +#[test] +fn ffor_request_issuer_restart_requires_fresh_barrier_and_refuses_obsolete_handles() { + let (storage, node, mut store) = bound(ACTIVE); + let record = issue_live(&storage, &node, &mut store); + let wire = record.invoice().unwrap().invoice().to_owned(); + let old = store.confirmed_invoice(CLIENT).unwrap().unwrap(); + persist_manager(&node, &storage); + drop(store); + drop(node); + + let node = restore_node(Arc::clone(&storage)); + let mut store = open(Arc::clone(&storage), &node); + assert_eq!(store.recover_invoice(CLIENT), Ok(Some(InvoiceProgress::AwaitingPersistence))); + assert!(store.confirmed_invoice(CLIENT).unwrap().is_none()); + persist_manager(&node, &storage); + assert_eq!(store.recover_invoice(CLIENT), Ok(Some(InvoiceProgress::Retained))); + assert_eq!(store.lookup(CLIENT).unwrap().unwrap().encode(), record.encode()); + assert_eq!(release(&mut store, &old), Err(RequestStoreError::Conflict)); + let fresh = store.confirmed_invoice(CLIENT).unwrap().unwrap(); + assert_eq!(release(&mut store, &fresh), Ok(Some(wire.clone()))); + + drop(store); + let mut reopened = open(Arc::clone(&storage), &node); + assert_eq!(release(&mut reopened, &fresh), Err(RequestStoreError::Conflict)); + assert_eq!(reopened.recover_invoice(CLIENT), Ok(Some(InvoiceProgress::Retained))); + let again = reopened.confirmed_invoice(CLIENT).unwrap().unwrap(); + assert_eq!(release(&mut reopened, &again), Ok(Some(wire))); +} + +#[test] +fn ffor_request_issuer_historical_fixture_recovers_exact_bytes_but_never_publishes_expired() { + let (storage, node, mut store) = bound(ISSUED); + assert_eq!(store.recover_invoice(CLIENT), Ok(None)); + assert_eq!( + drive(&storage, &node, &mut store, |store| store.issue_invoice(CLIENT, policy(), &route())), + Ok(InvoiceProgress::Retained) + ); + let record = store.lookup(CLIENT).unwrap().unwrap(); + let retained = record.invoice().unwrap(); + assert_eq!(native_digest(&node, &store), Some(retained.digest())); + let invoice = retained.parsed().unwrap(); + assert_eq!(invoice.amount_milli_satoshis(), Some(2_000_000)); + assert!(invoice.is_expired()); + let payment = retained.payment().unwrap(); + assert_eq!(node.payment_store.get(&payment.id), Some(payment.clone())); + let handle = store.confirmed_invoice(CLIENT).unwrap().unwrap(); + let writes = storage.writes.load(Ordering::SeqCst); + assert!(matches!(release(&mut store, &handle), Err(RequestStoreError::Native(_)))); + assert_eq!(storage.writes.load(Ordering::SeqCst), writes); + assert_eq!(store.recover_invoice(CLIENT), Ok(Some(InvoiceProgress::Retained))); + assert_eq!(store.lookup(CLIENT).unwrap().unwrap().encode(), record.encode()); + assert_eq!(node.payment_store.get(&payment.id).unwrap().status, PaymentStatus::Pending); +} + +#[test] +fn ffor_request_issuer_conflicting_policy_and_stale_route_refuse_without_native_assignment() { + let (storage, node, mut store) = bound(ACTIVE); + persist_manager(&node, &storage); + let stale = route_at(Some(now() - 15 * 24 * 60 * 60)); + assert!(matches!( + store.issue_invoice(CLIENT, policy(), &stale), + Err(RequestStoreError::Native(_)) + )); + assert_eq!(native_digest(&node, &store), None); + let record = store.lookup(CLIENT).unwrap().unwrap(); + assert_eq!(record.invoice_policy(), Some(policy())); + assert_eq!( + store.issue_invoice(CLIENT, InvoicePolicy { expiry_seconds: 1, ..policy() }, &route()), + Err(RequestStoreError::Conflict) + ); + assert_eq!(native_digest(&node, &store), None); + assert_eq!(store.lookup(CLIENT).unwrap().unwrap().encode(), record.encode()); + assert!(store.confirmed_invoice(CLIENT).unwrap().is_none()); + issue_live(&storage, &node, &mut store); + assert_eq!( + store.issue_invoice(CLIENT, InvoicePolicy { expiry_seconds: 1, ..policy() }, &route()), + Err(RequestStoreError::Conflict) + ); + assert!(store.confirmed_invoice(CLIENT).unwrap().is_some()); +} + +#[test] +fn ffor_request_issuer_missing_corrupt_or_terminal_payment_blocks_handles_and_release() { + let (storage, node, mut store) = bound(ACTIVE); + let record = issue_live(&storage, &node, &mut store); + let payment = record.invoice().unwrap().payment().unwrap(); + let handle = store.confirmed_invoice(CLIENT).unwrap().unwrap(); + let key = payment.id.encode_to_hex_str(); + + let good = stored_payment(&storage, &payment).unwrap(); + KVStoreSync::write( + &*storage, + PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, + PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + &key, + vec![0, 1, 2], + ) + .unwrap(); + assert_eq!( + store.confirmed_invoice(CLIENT).map(|_| ()), + Err(RequestStoreError::Payment(FFORPaymentError::Conflict)) + ); + assert_eq!( + store.issue_invoice(CLIENT, policy(), &route()), + Ok(InvoiceProgress::Retained), + "a durable marker with an unchanged live row does not re-read disk" + ); + KVStoreSync::write( + &*storage, + PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, + PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + &key, + good, + ) + .unwrap(); + assert!(store.confirmed_invoice(CLIENT).unwrap().is_some()); + + node.payment_store.remove(&payment.id).unwrap(); + assert_eq!( + store.confirmed_invoice(CLIENT).map(|_| ()), + Err(RequestStoreError::Payment(FFORPaymentError::Missing)) + ); + assert_eq!( + store.issue_invoice(CLIENT, policy(), &route()), + Err(RequestStoreError::Payment(FFORPaymentError::Missing)) + ); + assert_eq!( + release(&mut store, &handle), + Err(RequestStoreError::Payment(FFORPaymentError::Missing)) + ); + node.payment_store.insert(payment.clone()).unwrap(); + assert_eq!( + release(&mut store, &handle), + Ok(Some(record.invoice().unwrap().invoice().to_owned())) + ); + + let mut update = PaymentDetailsUpdate::new(payment.id); + update.status = Some(PaymentStatus::Failed); + node.payment_store.update(&update).unwrap(); + assert_eq!( + store.confirmed_invoice(CLIENT).map(|_| ()), + Err(RequestStoreError::Payment(FFORPaymentError::Terminal)) + ); + assert_eq!( + store.issue_invoice(CLIENT, policy(), &route()), + Err(RequestStoreError::Payment(FFORPaymentError::Terminal)) + ); + assert_eq!( + release(&mut store, &handle), + Err(RequestStoreError::Payment(FFORPaymentError::Terminal)) + ); + assert_eq!(store.lookup(CLIENT).unwrap().unwrap().encode(), record.encode()); + assert_eq!(native_digest(&node, &store), Some(record.invoice().unwrap().digest())); +} + +#[test] +fn ffor_request_issuer_unbound_record_or_missing_native_history_refuses() { + let (_, _, mut store) = fixture(); + store.begin(issuer_intent(), issuer_plan(&store)).unwrap(); + assert_eq!( + store.issue_invoice(CLIENT, policy(), &route()), + Err(RequestStoreError::MissingNative) + ); + assert_eq!(store.recover_invoice(CLIENT), Ok(None)); + assert!(store.confirmed_invoice(CLIENT).unwrap().is_none()); + assert_eq!(store.lookup(CLIENT).unwrap().unwrap().invoice_policy(), Some(policy())); + + let (storage, node, mut store) = bound(ACTIVE); + issue_live(&storage, &node, &mut store); + drop(store); + drop(node); + install_fixture(&storage, false); + let node = restore_node(Arc::clone(&storage)); + let mut store = open(Arc::clone(&storage), &node); + assert_eq!(store.recover_invoice(CLIENT), Err(RequestStoreError::MissingNative)); + assert!(matches!(store.confirmed_invoice(CLIENT), Err(RequestStoreError::MissingNative))); + assert_eq!( + store.issue_invoice(CLIENT, policy(), &route()), + Err(RequestStoreError::MissingNative) + ); +} + +#[test] +fn ffor_request_issuer_monitor_tip_ahead_refuses_release_but_keeps_assignment() { + let (storage, node, mut store) = bound(ACTIVE); + let record = issue_live(&storage, &node, &mut store); + let handle = store.confirmed_invoice(CLIENT).unwrap().unwrap(); + let deadline: u32 = ifield("settlement_deadline").parse().unwrap(); + let header = Header { + version: Version::NO_SOFT_FORK_SIGNALLING, + prev_blockhash: BlockHash::all_zeros(), + merkle_root: TxMerkleNode::all_zeros(), + time: now() as u32, + bits: CompactTarget::from_consensus(0), + nonce: 0, + }; + let manager_height = node.channel_manager.current_best_block().height; + node.chain_monitor.best_block_updated(&header, deadline - 1); + assert_eq!(node.channel_manager.current_best_block().height, manager_height); + assert!(matches!(release(&mut store, &handle), Err(RequestStoreError::Native(_)))); + assert_eq!( + drive(&storage, &node, &mut store, |store| store + .recover_invoice(CLIENT) + .map(Option::unwrap)), + Ok(InvoiceProgress::Retained) + ); + assert_eq!(store.lookup(CLIENT).unwrap().unwrap().encode(), record.encode()); + assert_eq!(native_digest(&node, &store), Some(record.invoice().unwrap().digest())); +} + +#[test] +fn ffor_request_issuer_release_performs_no_storage_io_under_publication() { + let (storage, node, mut store) = bound(ACTIVE); + let record = issue_live(&storage, &node, &mut store); + let handle = store.confirmed_invoice(CLIENT).unwrap().unwrap(); + let reads = Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = Arc::clone(&reads); + *storage.read_hook.lock().unwrap() = + Some(Box::new(move |primary: &str| sink.lock().unwrap().push(primary.to_owned()))); + let writes = storage.writes.load(Ordering::SeqCst); + assert_eq!( + release(&mut store, &handle), + Ok(Some(record.invoice().unwrap().invoice().to_owned())) + ); + *storage.read_hook.lock().unwrap() = None; + assert_eq!(storage.writes.load(Ordering::SeqCst), writes); + let reads = reads.lock().unwrap(); + assert!(!reads.is_empty()); + assert!(reads.iter().all(|primary| primary == NAMESPACE), "{reads:?}"); +} From a728d64fb3d997d481b7ee332e760a51eb391b00 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 21 Sep 2026 10:00:18 -0400 Subject: [PATCH 22/23] chore: pin native cooperative outcome journal checkpoint --- Cargo.lock | 48 ++++++++++++++++++++++++------------------------ Cargo.toml | 26 +++++++++++++------------- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0940f953ec..dfbb0e3aaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -982,7 +982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1616,7 +1616,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1782,7 +1782,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1995,7 +1995,7 @@ dependencies = [ [[package]] name = "lightning" version = "0.2.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" +source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" dependencies = [ "bech32", "bitcoin", @@ -2007,7 +2007,7 @@ dependencies = [ "lightning-macros 0.2.1", "lightning-types 0.3.5", "musig2", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb)", "regex", ] @@ -2027,7 +2027,7 @@ dependencies = [ [[package]] name = "lightning-background-processor" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" +source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" dependencies = [ "bitcoin", "bitcoin-io", @@ -2035,7 +2035,7 @@ dependencies = [ "lightning 0.2.5", "lightning-liquidity 0.2.3", "lightning-rapid-gossip-sync 0.2.3", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb)", ] [[package]] @@ -2054,7 +2054,7 @@ dependencies = [ [[package]] name = "lightning-block-sync" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" +source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" dependencies = [ "bitcoin", "chunked_transfer", @@ -2066,7 +2066,7 @@ dependencies = [ [[package]] name = "lightning-ffor" version = "0.1.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" +source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" dependencies = [ "bitcoin", ] @@ -2086,7 +2086,7 @@ dependencies = [ [[package]] name = "lightning-invoice" version = "0.34.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" +source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" dependencies = [ "bech32", "bitcoin", @@ -2112,7 +2112,7 @@ dependencies = [ [[package]] name = "lightning-liquidity" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" +source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" dependencies = [ "bitcoin", "chrono", @@ -2138,7 +2138,7 @@ dependencies = [ [[package]] name = "lightning-macros" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" +source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" dependencies = [ "proc-macro2", "quote", @@ -2159,7 +2159,7 @@ dependencies = [ [[package]] name = "lightning-net-tokio" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" +source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2180,7 +2180,7 @@ dependencies = [ [[package]] name = "lightning-persister" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" +source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2203,7 +2203,7 @@ dependencies = [ [[package]] name = "lightning-rapid-gossip-sync" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" +source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" dependencies = [ "bitcoin", "bitcoin-io", @@ -2228,7 +2228,7 @@ dependencies = [ [[package]] name = "lightning-transaction-sync" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" +source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" dependencies = [ "bitcoin", "electrum-client 0.24.1", @@ -2250,7 +2250,7 @@ dependencies = [ [[package]] name = "lightning-types" version = "0.3.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" +source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" dependencies = [ "bitcoin", ] @@ -2677,7 +2677,7 @@ dependencies = [ [[package]] name = "possiblyrandom" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=a00512b79f33e2db9b1bea691ab3637122c41564#a00512b79f33e2db9b1bea691ab3637122c41564" +source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" dependencies = [ "getrandom 0.2.17", ] @@ -2977,7 +2977,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.43", - "socket2 0.6.5", + "socket2 0.5.10", "thiserror 2.0.20", "tokio", "tracing", @@ -3015,9 +3015,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2 0.5.10", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3332,7 +3332,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3734,7 +3734,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4574,7 +4574,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5b8fe07cc3..82bbfa2aeb 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,18 +34,18 @@ default = [] [dependencies] # Pin runtime dependencies directly so downstream consumers use the same native engine. -lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564" } -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564", features = ["std"] } -lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564" } -lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564" } -lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564" } -lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564" } -lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564", features = ["std"] } -lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564" } +lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb" } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb", features = ["std"] } +lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb" } +lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb" } +lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb" } +lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb" } +lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb", features = ["std"] } +lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb" } bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} @@ -81,7 +81,7 @@ prost = { version = "0.11.6", default-features = false} winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "a00512b79f33e2db9b1bea691ab3637122c41564", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb", features = ["std", "_test_utils"] } proptest = "1.0.0" ring = "0.17.14" regex = "1.5.6" From ce6891e7115527b76c90d6ad1a472de5e7699b87 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 21 Sep 2026 10:55:24 -0400 Subject: [PATCH 23/23] feat: add an opt-in offline receive runtime with durable payment credit --- Cargo.lock | 48 +-- Cargo.toml | 26 +- FFOR.md | 144 +++++-- bindings/ldk_node.udl | 55 +++ src/builder.rs | 90 +++- src/config.rs | 132 ++++++ src/error.rs | 23 ++ src/event.rs | 7 + src/ffor/mod.rs | 6 +- src/ffor/request_store.rs | 8 +- src/ffor/request_store/invoice.rs | 6 +- src/ffor/request_store/record.rs | 22 + src/ffor/runtime.rs | 573 +++++++++++++++++++++++++ src/ffor/runtime/credit.rs | 81 ++++ src/ffor/runtime/ledger.rs | 244 +++++++++++ src/ffor/runtime/route.rs | 45 ++ src/ffor/runtime/tests.rs | 667 ++++++++++++++++++++++++++++++ src/ffor/runtime/worker.rs | 650 +++++++++++++++++++++++++++++ src/ffor/witness_owner.rs | 4 +- src/ffor/witness_store.rs | 32 +- src/ffor/witness_store/record.rs | 45 ++ src/lib.rs | 39 +- src/message_handler.rs | 7 +- src/message_handler/ffor.rs | 5 +- src/message_handler/ffor/setup.rs | 6 +- src/payment/mod.rs | 2 + src/payment/offline_receive.rs | 111 +++++ 27 files changed, 2982 insertions(+), 96 deletions(-) create mode 100644 src/ffor/runtime.rs create mode 100644 src/ffor/runtime/credit.rs create mode 100644 src/ffor/runtime/ledger.rs create mode 100644 src/ffor/runtime/route.rs create mode 100644 src/ffor/runtime/tests.rs create mode 100644 src/ffor/runtime/worker.rs create mode 100644 src/payment/offline_receive.rs diff --git a/Cargo.lock b/Cargo.lock index dfbb0e3aaa..2f22867d58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -982,7 +982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1616,7 +1616,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -1782,7 +1782,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1995,7 +1995,7 @@ dependencies = [ [[package]] name = "lightning" version = "0.2.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" +source = "git+https://github.com/synonymdev/rust-lightning?rev=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bech32", "bitcoin", @@ -2007,7 +2007,7 @@ dependencies = [ "lightning-macros 0.2.1", "lightning-types 0.3.5", "musig2", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=996f5b000d575c8d54e627acc8b70810293011e8)", "regex", ] @@ -2027,7 +2027,7 @@ dependencies = [ [[package]] name = "lightning-background-processor" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" +source = "git+https://github.com/synonymdev/rust-lightning?rev=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bitcoin", "bitcoin-io", @@ -2035,7 +2035,7 @@ dependencies = [ "lightning 0.2.5", "lightning-liquidity 0.2.3", "lightning-rapid-gossip-sync 0.2.3", - "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb)", + "possiblyrandom 0.2.1 (git+https://github.com/synonymdev/rust-lightning?rev=996f5b000d575c8d54e627acc8b70810293011e8)", ] [[package]] @@ -2054,7 +2054,7 @@ dependencies = [ [[package]] name = "lightning-block-sync" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" +source = "git+https://github.com/synonymdev/rust-lightning?rev=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bitcoin", "chunked_transfer", @@ -2066,7 +2066,7 @@ dependencies = [ [[package]] name = "lightning-ffor" version = "0.1.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" +source = "git+https://github.com/synonymdev/rust-lightning?rev=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bitcoin", ] @@ -2086,7 +2086,7 @@ dependencies = [ [[package]] name = "lightning-invoice" version = "0.34.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" +source = "git+https://github.com/synonymdev/rust-lightning?rev=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bech32", "bitcoin", @@ -2112,7 +2112,7 @@ dependencies = [ [[package]] name = "lightning-liquidity" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" +source = "git+https://github.com/synonymdev/rust-lightning?rev=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bitcoin", "chrono", @@ -2138,7 +2138,7 @@ dependencies = [ [[package]] name = "lightning-macros" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" +source = "git+https://github.com/synonymdev/rust-lightning?rev=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "proc-macro2", "quote", @@ -2159,7 +2159,7 @@ dependencies = [ [[package]] name = "lightning-net-tokio" version = "0.2.0" -source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" +source = "git+https://github.com/synonymdev/rust-lightning?rev=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2180,7 +2180,7 @@ dependencies = [ [[package]] name = "lightning-persister" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" +source = "git+https://github.com/synonymdev/rust-lightning?rev=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2203,7 +2203,7 @@ dependencies = [ [[package]] name = "lightning-rapid-gossip-sync" version = "0.2.3" -source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" +source = "git+https://github.com/synonymdev/rust-lightning?rev=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bitcoin", "bitcoin-io", @@ -2228,7 +2228,7 @@ dependencies = [ [[package]] name = "lightning-transaction-sync" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" +source = "git+https://github.com/synonymdev/rust-lightning?rev=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bitcoin", "electrum-client 0.24.1", @@ -2250,7 +2250,7 @@ dependencies = [ [[package]] name = "lightning-types" version = "0.3.5" -source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" +source = "git+https://github.com/synonymdev/rust-lightning?rev=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bitcoin", ] @@ -2677,7 +2677,7 @@ dependencies = [ [[package]] name = "possiblyrandom" version = "0.2.1" -source = "git+https://github.com/synonymdev/rust-lightning?rev=6f91649374195ab7022e480eff0981fd1104e6fb#6f91649374195ab7022e480eff0981fd1104e6fb" +source = "git+https://github.com/synonymdev/rust-lightning?rev=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "getrandom 0.2.17", ] @@ -2977,7 +2977,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.43", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.20", "tokio", "tracing", @@ -3015,9 +3015,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3332,7 +3332,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3734,7 +3734,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4574,7 +4574,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 82bbfa2aeb..1bf27925c6 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,18 +34,18 @@ default = [] [dependencies] # Pin runtime dependencies directly so downstream consumers use the same native engine. -lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb" } -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb", features = ["std"] } -lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb" } -lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb" } -lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb" } -lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb" } -lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb", features = ["std"] } -lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb" } +lightning-ffor = { git = "https://github.com/synonymdev/rust-lightning", rev = "996f5b000d575c8d54e627acc8b70810293011e8" } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "996f5b000d575c8d54e627acc8b70810293011e8", features = ["std"] } +lightning-types = { git = "https://github.com/synonymdev/rust-lightning", rev = "996f5b000d575c8d54e627acc8b70810293011e8" } +lightning-invoice = { git = "https://github.com/synonymdev/rust-lightning", rev = "996f5b000d575c8d54e627acc8b70810293011e8", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/synonymdev/rust-lightning", rev = "996f5b000d575c8d54e627acc8b70810293011e8" } +lightning-persister = { git = "https://github.com/synonymdev/rust-lightning", rev = "996f5b000d575c8d54e627acc8b70810293011e8", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/synonymdev/rust-lightning", rev = "996f5b000d575c8d54e627acc8b70810293011e8" } +lightning-rapid-gossip-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "996f5b000d575c8d54e627acc8b70810293011e8" } +lightning-block-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "996f5b000d575c8d54e627acc8b70810293011e8", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/synonymdev/rust-lightning", rev = "996f5b000d575c8d54e627acc8b70810293011e8", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/synonymdev/rust-lightning", rev = "996f5b000d575c8d54e627acc8b70810293011e8", features = ["std"] } +lightning-macros = { git = "https://github.com/synonymdev/rust-lightning", rev = "996f5b000d575c8d54e627acc8b70810293011e8" } bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} @@ -81,7 +81,7 @@ prost = { version = "0.11.6", default-features = false} winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "6f91649374195ab7022e480eff0981fd1104e6fb", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/synonymdev/rust-lightning", rev = "996f5b000d575c8d54e627acc8b70810293011e8", features = ["std", "_test_utils"] } proptest = "1.0.0" ring = "0.17.14" regex = "1.5.6" diff --git a/FFOR.md b/FFOR.md index 7cbd3e4f43..c785ed5f9f 100644 --- a/FFOR.md +++ b/FFOR.md @@ -27,9 +27,9 @@ and recovery are implemented together. Node's custom-message composition retains the existing LSPS reader, outbox, features and peer callbacks. An optional private FFOR receiver can parse the seven supported signed lifecycle types, witness acknowledgement type 55057 and fetch response type -55061 through the shared canonical codecs. Its bounded outbox accepts exact wire messages from a future -native-authorized release callback. The production builder leaves this transport -disabled. This slice has no feature advertisement or public setting. +55061 through the shared canonical codecs. Its bounded outbox accepts exact wire messages from the +native-authorized release callback. The builder installs this transport only with +`NodeBuilder::set_offline_receive_config`. There is no feature advertisement. The private receiver adapter uses the concrete native manager and pairs its opaque authenticated generation with the transport token in the same bounded peer map. @@ -44,9 +44,8 @@ Only a native request for proof reads the concrete ChainMonitor; the adapter dro that monitor guard before passing the opaque snapshot and original paired generation back to the manager. Stock peer events, STFU, commitment rounds and persistence must continue between advances. The adapter retains no independent lifecycle state and -does not treat Active progress as invoice readiness. No builder constructs it yet; -runtime scheduling and payment recovery remain necessary before -offering offline invoices. +does not treat Active progress as invoice readiness. The opt-in runtime described +below schedules it. The transport accepts peer identity only from PeerManager's authenticated callback. Each successful connection gets a distinct opaque token. Disconnect or replacement @@ -99,8 +98,8 @@ property checks at the length-limited reader boundary. The private `ffor::witness_store` module retains an immutable encryption key per epoch, plus a separate fetch key, mailbox and exact signed manifest per witness. -A private witness owner composes its storage and transport operations; no production -builder or payment provider constructs either owner. Its non-test binding +A private witness owner composes its storage and transport operations; only the +opt-in runtime constructs either owner. Its non-test binding constructor accepts only an opaque native recovery context with a retained activation acknowledgement. It binds the native context digest, original funding output, identities and exact signed setup and activation. Historical evidence and successful @@ -260,7 +259,7 @@ failures, rejected candidates followed by valid evidence, one write per page, exact batch recovery and zero payment credit. Fixture provenance is in `src/ffor/witness_owner/fixtures/README.md`; test-only witness encryption uses ring against the retained public epoch key and never -exports a protected private key. No builder or scheduler enables this owner yet. +exports a protected private key. The opt-in runtime schedules this owner. The separate receipt-recovery call first rejoins immutable native witness registration and confirmed protected evidence. It captures an opaque snapshot from @@ -278,8 +277,8 @@ payment or authorizes an invoice. Tests use the actual Node persister and restor its durable monitor updates; they never manufacture a monitor-completion signal. They cover live and archive-only recovery, idempotence after restart, and uncertain receipt writes before native import. Delayed and failed native monitor writes are -covered in the pinned channel engine. Authoritative settlement outcomes and the -production recovery scheduler remain unfinished. +covered in the pinned channel engine. The opt-in runtime schedules this recovery and joins settlement outcomes only +from the native journal after Closed. ## Private durable receive requests @@ -330,7 +329,7 @@ only a policy and public witness-to-settlement route evidence. A different polic conflict, the reserved policy is never replaced, and an existing native assignment is recovered before any new preparation, so fresh route evidence is accepted on retry. Every pending native manager or monitor write reports awaiting persistence instead of -progress. No production completer for native persistence tokens exists yet. +progress; the background processor completes native tokens and the runtime re-polls. The payment store confirms the exact Pending Bolt11 row against memory and disk and persists before installing it in memory. A payment confirmation write that fails @@ -368,21 +367,112 @@ and stale route refusal without a native slot, missing, corrupt and terminal pay rows, unbound records, missing native history, a monitor tip ahead of the manager, and zero storage I/O under publication. Run `cargo test --lib ffor_request` for the request-store, codec and issuer checks and `cargo test --lib ffor_payment` for the -payment-store checks. No production caller constructs this owner yet. - -## Required runtime integration - -- Bind signed setup to the actual local identity, peer, chain and channel limits. -- Park a complete voucher book outside ordinary invoice and forwarding handling. -- Freeze the verified commitment pair through the real quiescence protocol. -- Persist exact activation and acknowledgement evidence before releasing either - acknowledgement or invoice readiness. Retain evidence after channel removal. -- Restore active epochs, reconcile reconnect state and fail setup safely after a - disconnect, timeout, partial round or storage failure. -- Connect settlement, slot accounting, witness/mailbox recovery and on-chain - protection before offering an offline invoice. -- Generate native bindings and connect the Android and iOS providers to the same - exact-amount, single-channel eligibility and invoice metadata contract. +payment-store checks. Only the opt-in runtime constructs this owner. + +## Opt-in receiver runtime + +The runtime is disabled unless `NodeBuilder::set_offline_receive_config` (or the `Arc` +builder's method) is called; `Config` is unchanged so the existing UDL still mirrors it. +At build time the builder validates the `OfflineReceiveConfig` (settlement peer, one through four distinct witness peers with a +retention and minimum-receipt policy, invoice expiry and safety margin, settlement +deadline, deadline safety margin, claim margin, voucher expiry, fee policy and poll +interval; the constants are development defaults, not production policy), installs the +private transport and setup adapter into the custom message handler, and constructs +`ffor::runtime::FforReceiverRuntime`. The runtime opens the request store and witness +store with the wallet seed at build time only, composes the witness owner, and keeps a +bounded in-memory table of at most 64 live requests. `Node::start` runs recovery and +spawns one stop-aware worker; `Node::offline_receive()` returns the handler. Without the +configuration no transport, adapter, runtime or handler state exists and every handler +method fails with `OfflineReceiveDisabled`. + +The handler exposes `can_receive(amount_msat)`, `prepare(request_id, amount_msat, +description)`, `status(request_id)` and `cancel(request_id)`. Eligibility is a positive +amount whose fee-inclusive gross fits one channel-ready channel's inbound capacity with the +settlement peer, with no live request on that channel and every historical native epoch +on it terminal; the only public terminal signal is a completed cooperative drain (the +journal outcome getter answers for slot one), so an aborted previous epoch keeps its +channel ineligible here even though native may admit a replacement. Peer connectivity is +not part of eligibility. Native remains the admission authority: `AlreadyRegistered` and +`RecoveryUnavailable` from preparation fail the request, `PendingUpdates` waits. `prepare` is idempotent per request ID +through the protected request record; different arguments for a known ID are a conflict. +The status enum is `Preparing`, `AwaitingActivation`, `AwaitingWitnesses`, `Ready`, +`Expired`, `Settled` and `Failed`. `Ready` carries the BOLT 11 string only after the exact +invoice was retained, its Pending payment confirmed and native publication released it +into the runtime's slot in this process; after restart the same request reports +`AwaitingWitnesses` until a fresh native barrier and a fresh release. + +Each worker pass first retries uncertain store writes, then pops bounded transport input +(witness acknowledgements to the witness owner, fetch pages to receipt retention, with at +most 32 acknowledgements retained across a native persistence wait), then advances every +live request by one bounded step. Stages are `Prepare` (native preparation on the genuine +settlement connection; a channel that native refuses because it is not yet reestablished +keeps waiting), `Setup` (Active observed through the native active-context capture, or +the setup adapter advanced on the live connection), `Witnesses` (registration and +provisioning with policies from the configuration, explicit retry after sixty seconds +without acknowledgement, a missing sidecar fails closed without regenerating keys), +`Invoice` (signed witness-to-settlement announcement and witness-signed update taken from +the node's own network graph; absent evidence waits, it is never fabricated; then +issuance, confirmation and release), `Ready`, `Closing` and `Closed`. Cancellation before +binding writes a durable marker; before activation it cancels the native setup; afterwards +it requests a cooperative close. The runtime requests a close when the invoice expires, at +the deadline safety margin or at voucher expiry, and drives Draining to Closed on the live +connection. `ReconnectRequired` and `ResolutionRequired` schedule witness fetches and +receipt import into the original monitor; nothing there credits a payment. + +After `Closed`, the single slot's outcome comes only from +`ffor_receiver_voucher_outcome` on the current native context. `Fulfilled` credits the +exact Pending payment exactly once through a durable intent record in the `ffor_runtime` +namespace keyed by channel, epoch and slot: `Intended` is written before the payment row +changes, `Credited` after the successful payment write (preimage from a retained +authenticated witness receipt when one exists, otherwise none), and `Notified` after the +`PaymentReceived` event is durably queued. Resumption from `Credited` does not queue the +event again when the queue already holds it. An event handled by the application between +queueing and the `Notified` write can still be delivered twice; that window is not hidden. +`Failed` marks the row failed; `None` after `Closed` reports `Expired`. + +Run `cargo test --lib ffor_runtime`. Verified with the genuine exported fixtures: +`ffor_runtime_is_absent_without_config_and_refuses_invalid_config`, +`ffor_runtime_handler_requires_running_node_for_mutations`, +`ffor_runtime_prepare_is_idempotent_and_eligibility_tracks_channel_and_live_requests`, +`ffor_runtime_cancel_and_deadline_before_binding_are_durable_and_release_nothing`, +`ffor_runtime_recovers_active_epoch_and_reports_ready_only_after_native_release` +(restored Active epoch, existing native registration rejoined through a fixture sidecar, +route evidence from the graph, issuance through native persistence, release, no credit, +restart with the same bytes, deadline close), `ffor_runtime_missing_witness_sidecar_never_regenerates_keys_or_issues`, +`ffor_runtime_unknown_witness_acknowledgement_is_dropped`, +`ffor_runtime_credit_sequence_survives_a_failed_write_at_every_step_and_restart` +(both failure modes at each of the five writes, then restart through the ordinary +builder reload) and `ffor_runtime_credit_sequence_refuses_conflicts_and_missing_rows`. + +Not verified in Node unit tests: fresh native preparation and Init release on a +reestablished channel, witness provisioning driven by the runtime against a live witness, +Draining to Closed, a `Fulfilled` journal outcome and receipt-driven preimage recovery. +No exported native fixture reaches Closed with a journal; those paths need the regtest +end-to-end run below. + +## What remains + +- Repeated epochs on one channel: native revision `996f5b0` admits a new epoch once the + previous one is terminal (drain Closed with its retained completion hash, or aborted + without a drain) and its terminal write completed. The runtime admits a channel whose + historical epochs all have a completed journal; aborted epochs need a public terminal + getter before the runtime can offer their channel again. Sequential epochs are not + exercised in Node unit tests because no exported fixture reaches Closed. +- No native pre-close observation of a paid voucher exists; the runtime closes only on + invoice expiry, deadline margin or voucher expiry, and credits after `Closed`. +- Rapid gossip sync retains no signed announcements or updates, so route evidence is + available only with P2P gossip. +- Bindings: the Rust API is shaped for a UDL mirror (plain structs and enums, an `Arc` + handler under the `uniffi` feature) but `bindings/ldk_node.udl` and the generated + `bindings/ldk_node.udl` now declares `BuildError.InvalidOfflineReceiveConfig`, the + five `NodeError.OfflineReceive*` variants, `OfflineReceiveConfig`, + `OfflineReceiveWitnessConfig`, `OfflineReceiveStatus`, `OfflineReceiveOutcome`, the + `OfflineReceivePayment` interface, `Builder.set_offline_receive_config` and + `Node.offline_receive`; `cargo check --features uniffi` compiles. The generated Swift, + Kotlin and Python bindings have not been regenerated (`./bindgen.sh` not run), and the + mobile providers are not connected. `Config` itself is unchanged. +- Regtest end-to-end: process crashes, reconnection, chain enforcement, cooperative close + with a fulfilled voucher, and mobile lifecycle tests. ## Trust boundaries and validation diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 9cdcdc85b4..2b236cc1c9 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -47,6 +47,26 @@ dictionary AnchorChannelsConfig { u64 per_channel_reserve_sats; }; +dictionary OfflineReceiveWitnessConfig { + PublicKey node_id; + u32 retention_blocks; + u8 minimum_receipts; +}; + +dictionary OfflineReceiveConfig { + PublicKey settlement_node_id; + sequence witnesses; + u32 invoice_expiry_seconds; + u32 invoice_safety_margin_seconds; + u32 settlement_deadline_blocks; + u32 deadline_safety_margin_blocks; + u32 claim_margin_blocks; + u32 voucher_expiry_blocks; + u32 fee_base_msat; + u32 fee_proportional_millionths; + u64 poll_interval_secs; +}; + dictionary BackgroundSyncConfig { u64 onchain_wallet_sync_interval_secs; u64 lightning_wallet_sync_interval_secs; @@ -179,6 +199,7 @@ interface Builder { void set_announcement_addresses(sequence announcement_addresses); [Throws=BuildError] void set_node_alias(string node_alias); + void set_offline_receive_config(OfflineReceiveConfig config); [Throws=BuildError] void set_async_payments_role(AsyncPaymentsRole? role); [Throws=BuildError] @@ -211,6 +232,7 @@ interface Node { sequence? announcement_addresses(); NodeAlias? node_alias(); Bolt11Payment bolt11_payment(); + OfflineReceivePayment offline_receive(); Bolt12Payment bolt12_payment(); SpontaneousPayment spontaneous_payment(); OnchainPayment onchain_payment(); @@ -504,6 +526,11 @@ enum NodeError { "AddressTypeNotMonitored", "OnchainWalletAccountNotRegistered", "InvalidSeedBytes", + "OfflineReceiveDisabled", + "OfflineReceiveUnavailable", + "OfflineReceiveIneligible", + "OfflineReceiveRequestNotFound", + "OfflineReceiveRequestConflict", }; dictionary NodeStatus { @@ -542,6 +569,7 @@ enum BuildError { "LoggerSetupFailed", "NetworkMismatch", "AsyncPaymentsConfigMismatch", + "InvalidOfflineReceiveConfig", }; [Trait] @@ -660,6 +688,33 @@ interface QrPaymentResult { Bolt12(PaymentId payment_id); }; +enum OfflineReceiveOutcome { + "Fulfilled", + "Failed", +}; + +[Enum] +interface OfflineReceiveStatus { + Preparing(); + AwaitingActivation(); + AwaitingWitnesses(); + Ready(string bolt11); + Expired(); + Settled(OfflineReceiveOutcome outcome); + Failed(string reason); +}; + +interface OfflineReceivePayment { + [Throws=NodeError] + boolean can_receive(u64 amount_msat); + [Throws=NodeError] + OfflineReceiveStatus prepare(string request_id, u64 amount_msat, string description); + [Throws=NodeError] + OfflineReceiveStatus status(string request_id); + [Throws=NodeError] + void cancel(string request_id); +}; + enum PaymentDirection { "Inbound", "Outbound", diff --git a/src/builder.rs b/src/builder.rs index 67cb9afec8..bb28c2c9d4 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -51,13 +51,15 @@ use crate::chain::ChainSource; use crate::config::{ default_user_config, may_announce_channel, AddressType, AddressTypeRuntimeConfig, AnnounceError, AsyncPaymentsRole, BitcoindRestClientConfig, Config, ElectrumSyncConfig, - EsploraSyncConfig, OnchainWalletAccount, RuntimeSyncIntervals, ScoringDecayParameters, - ScoringFeeParameters, BDK_CLIENT_STOP_GAP, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, - DEFAULT_LOG_LEVEL, MAX_ONCHAIN_WALLET_ACCOUNT_INDEX, WALLET_KEYS_SEED_LEN, + EsploraSyncConfig, OfflineReceiveConfig, OnchainWalletAccount, RuntimeSyncIntervals, + ScoringDecayParameters, ScoringFeeParameters, BDK_CLIENT_STOP_GAP, DEFAULT_ESPLORA_SERVER_URL, + DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL, MAX_ONCHAIN_WALLET_ACCOUNT_INDEX, + WALLET_KEYS_SEED_LEN, }; use crate::connection::ConnectionManager; use crate::event::EventQueue; use crate::fee_estimator::OnchainFeeEstimator; +use crate::ffor::runtime::FforReceiverRuntime; use crate::gossip::GossipSource; use crate::io::local_graph_store::read_local_graph_cache; use crate::io::sqlite_store::SqliteStore; @@ -76,6 +78,7 @@ use crate::liquidity::{ LSPS1ClientConfig, LSPS2ClientConfig, LSPS2ServiceConfig, LiquiditySourceBuilder, }; use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger}; +use crate::message_handler::ffor::{FforReceiverTransport, FforSetupAdapter}; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; use crate::peer_store::{persist_missing_channel_peers, PeerStore}; @@ -237,6 +240,8 @@ pub enum BuildError { NetworkMismatch, /// The role of the node in an asynchronous payments context is not compatible with the current configuration. AsyncPaymentsConfigMismatch, + /// The offline-receive configuration is invalid. + InvalidOfflineReceiveConfig, } impl fmt::Display for BuildError { @@ -276,6 +281,9 @@ impl fmt::Display for BuildError { "The async payments role is not compatible with the current configuration." ) }, + Self::InvalidOfflineReceiveConfig => { + write!(f, "The offline-receive configuration is invalid.") + }, } } } @@ -301,6 +309,7 @@ pub struct NodeBuilder { runtime_handle: Option, pathfinding_scores_sync_config: Option, channel_data_migration: Option, + offline_receive_config: Option, } impl NodeBuilder { @@ -331,6 +340,7 @@ impl NodeBuilder { async_payments_role: None, pathfinding_scores_sync_config, channel_data_migration, + offline_receive_config: None, } } @@ -665,6 +675,15 @@ impl NodeBuilder { Ok(self) } + /// Enables the experimental offline-receive (FFOR) runtime. + /// + /// Disabled by default. The configuration is validated at build time; see + /// [`OfflineReceiveConfig`] for the development defaults and their limits. + pub fn set_offline_receive_config(&mut self, config: OfflineReceiveConfig) -> &mut Self { + self.offline_receive_config = Some(config); + self + } + /// Sets the role of the node in an asynchronous payments context. /// /// See for more information about the async payments protocol. @@ -847,6 +866,7 @@ impl NodeBuilder { logger, Arc::new(vss_store), self.channel_data_migration.as_ref(), + self.offline_receive_config.as_ref(), ) } @@ -882,6 +902,7 @@ impl NodeBuilder { logger, kv_store, self.channel_data_migration.as_ref(), + self.offline_receive_config.as_ref(), ) } } @@ -1169,6 +1190,11 @@ impl ArcedNodeBuilder { self.inner.write().unwrap().set_node_alias(node_alias).map(|_| ()) } + /// Enables the experimental offline-receive (FFOR) runtime. Disabled by default. + pub fn set_offline_receive_config(&self, config: OfflineReceiveConfig) { + self.inner.write().unwrap().set_offline_receive_config(config); + } + /// Sets the role of the node in an asynchronous payments context. pub fn set_async_payments_role( &self, role: Option, @@ -1728,9 +1754,17 @@ fn build_with_store_internal( async_payments_role: Option, seed_bytes: [u8; 64], runtime: Runtime, logger: Arc, kv_store: Arc, channel_data_migration: Option<&ChannelDataMigration>, + offline_receive_config: Option<&OfflineReceiveConfig>, ) -> Result { optionally_install_rustls_cryptoprovider(); + if let Some(offline_receive) = offline_receive_config { + if let Err(err) = offline_receive.validate() { + log_error!(logger, "Invalid offline receive configuration: {:?}", err); + return Err(BuildError::InvalidOfflineReceiveConfig); + } + } + if let Err(err) = may_announce_channel(&config) { if config.announcement_addresses.is_some() { log_error!(logger, "Announcement addresses were set but some required configuration options for node announcement are missing: {}", err); @@ -2346,6 +2380,15 @@ fn build_with_store_internal( )), }; + // The FFOR transport and setup adapter exist only when offline receiving is configured. + let ffor_setup = offline_receive_config.map(|_| { + Arc::new(FforSetupAdapter::new( + Arc::clone(&channel_manager), + Arc::clone(&chain_monitor), + Arc::new(FforReceiverTransport::default()), + )) + }); + let (liquidity_source, custom_message_handler) = if let Some(lsc) = liquidity_source_config.as_ref() { let mut liquidity_source_builder = LiquiditySourceBuilder::new( @@ -2390,11 +2433,18 @@ fn build_with_store_internal( let liquidity_source = runtime .block_on(async move { liquidity_source_builder.build().await.map(Arc::new) })?; - let custom_message_handler = - Arc::new(NodeCustomMessageHandler::new_liquidity(Arc::clone(&liquidity_source))); - (Some(liquidity_source), custom_message_handler) + let mut custom_message_handler = + NodeCustomMessageHandler::new_liquidity(Arc::clone(&liquidity_source)); + if let Some(setup) = ffor_setup.as_ref() { + custom_message_handler = custom_message_handler.with_ffor_setup(Arc::clone(setup)); + } + (Some(liquidity_source), Arc::new(custom_message_handler)) } else { - (None, Arc::new(NodeCustomMessageHandler::new_ignoring())) + let mut custom_message_handler = NodeCustomMessageHandler::new_ignoring(); + if let Some(setup) = ffor_setup.as_ref() { + custom_message_handler = custom_message_handler.with_ffor_setup(Arc::clone(setup)); + } + (None, Arc::new(custom_message_handler)) }; let msg_handler = match gossip_source.as_gossip_sync() { @@ -2567,6 +2617,31 @@ fn build_with_store_internal( Arc::clone(&logger), )); + let offline_receive = match (offline_receive_config.cloned(), ffor_setup) { + (Some(offline_config), Some(setup)) => { + let runtime = FforReceiverRuntime::new( + offline_config, + config.network, + &seed_bytes, + Arc::clone(&channel_manager), + Arc::clone(&chain_monitor), + Arc::clone(&network_graph), + Arc::clone(&payment_store), + Arc::clone(&event_queue), + Arc::clone(&kv_store), + Arc::clone(setup.transport()), + setup, + Arc::clone(&logger), + ) + .map_err(|e| { + log_error!(logger, "Failed to open offline receive storage: {:?}", e); + BuildError::ReadFailed + })?; + Some(Arc::new(runtime)) + }, + _ => None, + }; + let om_mailbox = if let Some(AsyncPaymentsRole::Server) = async_payments_role { Some(Arc::new(OnionMessageMailbox::new())) } else { @@ -2612,6 +2687,7 @@ fn build_with_store_internal( background_processor_generation: Arc::new(AtomicU64::new(0)), node_metrics, om_mailbox, + offline_receive, async_payments_role, runtime_sync_intervals: Arc::new(RwLock::new(RuntimeSyncIntervals::default())), local_rgs_timestamp, diff --git a/src/config.rs b/src/config.rs index c271b71200..4372d5c4ec 100644 --- a/src/config.rs +++ b/src/config.rs @@ -442,6 +442,138 @@ impl Default for Config { } } +/// Development defaults for [`OfflineReceiveConfig`]. These are not production policy. +pub const DEFAULT_OFFLINE_RECEIVE_INVOICE_EXPIRY_SECS: u32 = 3600; +/// Development defaults for [`OfflineReceiveConfig`]. These are not production policy. +pub const DEFAULT_OFFLINE_RECEIVE_INVOICE_SAFETY_MARGIN_SECS: u32 = 120; +/// Development defaults for [`OfflineReceiveConfig`]. These are not production policy. +pub const DEFAULT_OFFLINE_RECEIVE_SETTLEMENT_DEADLINE_BLOCKS: u32 = 144; +/// Development defaults for [`OfflineReceiveConfig`]. These are not production policy. +pub const DEFAULT_OFFLINE_RECEIVE_DEADLINE_SAFETY_MARGIN_BLOCKS: u32 = 6; +/// Development defaults for [`OfflineReceiveConfig`]. These are not production policy. +pub const DEFAULT_OFFLINE_RECEIVE_CLAIM_MARGIN_BLOCKS: u32 = 20; +/// Development defaults for [`OfflineReceiveConfig`]. These are not production policy. +pub const DEFAULT_OFFLINE_RECEIVE_VOUCHER_EXPIRY_BLOCKS: u32 = 288; +/// Development defaults for [`OfflineReceiveConfig`]. These are not production policy. +pub const DEFAULT_OFFLINE_RECEIVE_WITNESS_RETENTION_BLOCKS: u32 = 144; +/// Development defaults for [`OfflineReceiveConfig`]. These are not production policy. +pub const DEFAULT_OFFLINE_RECEIVE_POLL_INTERVAL_SECS: u64 = 5; + +/// One witness peer selected for offline-receive evidence retention. +/// +/// The witness must have a public, announced channel with the settlement peer whose gossip is +/// present in the node's network graph; the runtime refuses to issue an invoice otherwise. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OfflineReceiveWitnessConfig { + /// The witness peer's node ID. + pub node_id: PublicKey, + /// Blocks past the voucher expiry through which the witness must retain records. + pub retention_blocks: u32, + /// Requested guardian receipt count; zero requests local witness durability only. + pub minimum_receipts: u8, +} + +/// Experimental offline-receive (FFOR) runtime configuration. +/// +/// **These are development defaults, not production policy.** Deadlines, expiries, margins and +/// fees have not been tuned against any production settlement peer or witness deployment. The +/// runtime is disabled unless the builder's `set_offline_receive_config` was called; ordinary +/// invoices are never affected. +/// +/// ### Defaults (via [`OfflineReceiveConfig::new`]) +/// +/// | Parameter | Value | +/// |------------------------------------|--------| +/// | `invoice_expiry_seconds` | 3600 | +/// | `invoice_safety_margin_seconds` | 120 | +/// | `settlement_deadline_blocks` | 144 | +/// | `deadline_safety_margin_blocks` | 6 | +/// | `claim_margin_blocks` | 20 | +/// | `voucher_expiry_blocks` | 288 | +/// | `fee_base_msat` | 0 | +/// | `fee_proportional_millionths` | 0 | +/// | `poll_interval_secs` | 5 | +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OfflineReceiveConfig { + /// The settlement peer. Every offline receive uses one channel with this peer. + pub settlement_node_id: PublicKey, + /// One through four distinct witness peers. + pub witnesses: Vec, + /// BOLT 11 invoice expiry in seconds. + pub invoice_expiry_seconds: u32, + /// Seconds subtracted from the block-derived remaining time when validating invoice expiry. + pub invoice_safety_margin_seconds: u32, + /// Blocks after the current tip until the last permitted settlement admission height. + pub settlement_deadline_blocks: u32, + /// Blocks before the settlement deadline at which the runtime cancels or closes a request. + pub deadline_safety_margin_blocks: u32, + /// Local on-chain claim margin in blocks. + pub claim_margin_blocks: u32, + /// Blocks after the current tip at which vouchers expire. Must be at least + /// `settlement_deadline_blocks + claim_margin_blocks`. + pub voucher_expiry_blocks: u32, + /// Proposed base forwarding fee in millisatoshis for the settlement peer. + pub fee_base_msat: u32, + /// Proposed proportional forwarding fee in millionths for the settlement peer. + pub fee_proportional_millionths: u32, + /// Runtime worker poll interval in seconds. + pub poll_interval_secs: u64, +} + +impl OfflineReceiveConfig { + /// Build a configuration with development defaults for the given peers. + pub fn new(settlement_node_id: PublicKey, witnesses: Vec) -> Self { + Self { + settlement_node_id, + witnesses, + invoice_expiry_seconds: DEFAULT_OFFLINE_RECEIVE_INVOICE_EXPIRY_SECS, + invoice_safety_margin_seconds: DEFAULT_OFFLINE_RECEIVE_INVOICE_SAFETY_MARGIN_SECS, + settlement_deadline_blocks: DEFAULT_OFFLINE_RECEIVE_SETTLEMENT_DEADLINE_BLOCKS, + deadline_safety_margin_blocks: DEFAULT_OFFLINE_RECEIVE_DEADLINE_SAFETY_MARGIN_BLOCKS, + claim_margin_blocks: DEFAULT_OFFLINE_RECEIVE_CLAIM_MARGIN_BLOCKS, + voucher_expiry_blocks: DEFAULT_OFFLINE_RECEIVE_VOUCHER_EXPIRY_BLOCKS, + fee_base_msat: 0, + fee_proportional_millionths: 0, + poll_interval_secs: DEFAULT_OFFLINE_RECEIVE_POLL_INTERVAL_SECS, + } + } + + /// Check structural validity. The builder refuses an invalid configuration. + pub(crate) fn validate(&self) -> Result<(), OfflineReceiveConfigError> { + if self.witnesses.is_empty() || self.witnesses.len() > 4 { + return Err(OfflineReceiveConfigError::WitnessCount); + } + for (index, witness) in self.witnesses.iter().enumerate() { + if witness.node_id == self.settlement_node_id + || self.witnesses[..index].iter().any(|other| other.node_id == witness.node_id) + { + return Err(OfflineReceiveConfigError::WitnessIdentity); + } + } + if self.invoice_expiry_seconds == 0 + || self.settlement_deadline_blocks == 0 + || self.claim_margin_blocks == 0 + || self.deadline_safety_margin_blocks >= self.settlement_deadline_blocks + || self + .settlement_deadline_blocks + .checked_add(self.claim_margin_blocks) + .is_none_or(|minimum| minimum > self.voucher_expiry_blocks) + || self.poll_interval_secs == 0 + { + return Err(OfflineReceiveConfigError::Bounds); + } + Ok(()) + } +} + +/// Reasons an [`OfflineReceiveConfig`] is refused by the builder. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum OfflineReceiveConfigError { + WitnessCount, + WitnessIdentity, + Bounds, +} + /// Configuration options for [`ProbabilisticScoringFeeParameters`]. #[derive(Debug, Clone)] pub struct ScoringFeeParameters { diff --git a/src/error.rs b/src/error.rs index c285162ca6..88b0c361bc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -156,6 +156,16 @@ pub enum Error { OnchainWalletAccountNotRegistered, /// The seed bytes or a seed-derived wallet account are invalid. InvalidSeedBytes, + /// Offline receiving is not enabled in the node configuration. + OfflineReceiveDisabled, + /// The offline-receive runtime is not ready or a durable step could not complete. + OfflineReceiveUnavailable, + /// No eligible channel with the configured settlement peer can admit the request. + OfflineReceiveIneligible, + /// No offline-receive request with the given ID is known. + OfflineReceiveRequestNotFound, + /// An offline-receive request with the given ID exists with different arguments. + OfflineReceiveRequestConflict, } impl fmt::Display for Error { @@ -264,6 +274,19 @@ impl fmt::Display for Error { Self::InvalidSeedBytes => { write!(f, "The seed bytes or seed-derived wallet account are invalid.") }, + Self::OfflineReceiveDisabled => write!(f, "Offline receiving is not enabled."), + Self::OfflineReceiveUnavailable => { + write!(f, "The offline-receive runtime is not ready or a durable step failed.") + }, + Self::OfflineReceiveIneligible => { + write!(f, "No eligible channel with the settlement peer can admit the request.") + }, + Self::OfflineReceiveRequestNotFound => { + write!(f, "No offline-receive request with the given ID is known.") + }, + Self::OfflineReceiveRequestConflict => { + write!(f, "An offline-receive request with the given ID has different arguments.") + }, } } } diff --git a/src/event.rs b/src/event.rs index 0ba8cefab1..fea0a45838 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1018,6 +1018,13 @@ where locked_queue.front().cloned() } + /// Whether a not-yet-handled `PaymentReceived` for `payment_hash` is still queued. + pub(crate) fn contains_payment_received(&self, payment_hash: &PaymentHash) -> bool { + self.queue.lock().unwrap().iter().any(|event| { + matches!(event, Event::PaymentReceived { payment_hash: hash, .. } if hash == payment_hash) + }) + } + pub(crate) async fn next_event_async(&self) -> Event { EventFuture { event_queue: Arc::clone(&self.queue), waker: Arc::clone(&self.waker) }.await } diff --git a/src/ffor/mod.rs b/src/ffor/mod.rs index d420fe0382..d804af6436 100644 --- a/src/ffor/mod.rs +++ b/src/ffor/mod.rs @@ -1,7 +1,7 @@ //! Private witness recovery storage and bounded orchestration, not invoice authority. //! -//! No builder or payment provider constructs the witness owner. Bindings come from opaque native -//! historical contexts; provisioning requires current native authority checked under the manager's +//! Only the opt-in `runtime` composes these owners, and only when the builder's offline-receive +//! configuration is set. Bindings come from opaque native historical contexts; provisioning requires current native authority checked under the manager's //! transition locks. Retained acknowledgements and encrypted evidence confer no payment credit. #[allow(dead_code)] @@ -12,3 +12,5 @@ mod witness_owner; #[allow(dead_code)] mod request_store; + +pub(crate) mod runtime; diff --git a/src/ffor/request_store.rs b/src/ffor/request_store.rs index 4e80ed0095..ce2ea43d73 100644 --- a/src/ffor/request_store.rs +++ b/src/ffor/request_store.rs @@ -1,7 +1,7 @@ //! Durable application intent, without invoice, activation or payment authority. //! //! One exclusive owner retains exact arguments before native preparation. Native alone owns the -//! epoch and lifecycle. No builder constructs this store yet. The 64-record bound matches the +//! epoch and lifecycle. Only the opt-in runtime constructs this store. The 64-record bound matches the //! current native archive and includes unbound intents; neither this store nor native currently //! retires historical requests. Legacy intents reserve 4 KiB; an explicit v2 upgrade reserves 8 KiB before invoice issuance, //! for at most 512 KiB. Exact invoice and Pending payment confirmation are historical storage facts, @@ -39,6 +39,8 @@ mod invoice; mod native; mod record; use envelope::EnvelopeKey; +pub(super) use invoice::InvoiceProgress; +pub(super) use record::invoice::InvoicePolicy; pub(super) use record::{RequestIntent, RequestPlan, StoredRequest}; const NAMESPACE: &str = "ffor_requests"; @@ -52,7 +54,7 @@ const MAX_RECORD_BYTES: usize = 8192; const MAX_STORE_BYTES: usize = MAX_REQUESTS * MAX_RECORD_BYTES; #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum RequestStoreError { +pub(crate) enum RequestStoreError { InvalidIntent, Conflict, Missing, @@ -97,7 +99,7 @@ pub(super) struct RequestStore { } impl RequestStore { - fn open_bound( + pub(in crate::ffor) fn open_bound( seed: &[u8; 64], chain: [u8; 32], node: PublicKey, manager: Arc, payments: Arc, storage: Arc, ) -> Result { diff --git a/src/ffor/request_store/invoice.rs b/src/ffor/request_store/invoice.rs index 281ee11c39..6040f0b7b8 100644 --- a/src/ffor/request_store/invoice.rs +++ b/src/ffor/request_store/invoice.rs @@ -6,9 +6,9 @@ //! store exclusion and the native monitor guard. Amount, hash, description and route terms come //! from the record and native ownership; the caller supplies only a policy and route evidence. //! -//! `AwaitingPersistence` covers every pending native manager or monitor write. No production -//! completer for native persistence tokens exists yet; the runtime that drives the background -//! persister remains separate work. Nothing here emits payment events or credits a payment. +//! `AwaitingPersistence` covers every pending native manager or monitor write; the background +//! processor completes native persistence tokens and the runtime re-polls. Nothing here emits +//! payment events or credits a payment. use std::fmt; diff --git a/src/ffor/request_store/record.rs b/src/ffor/request_store/record.rs index 784988534a..cae666ba60 100644 --- a/src/ffor/request_store/record.rs +++ b/src/ffor/request_store/record.rs @@ -108,6 +108,28 @@ impl StoredRequest { pub(in crate::ffor) fn local_request_id(&self) -> [u8; 32] { self.plan.parameters.local_request_id } + /// The exact confirmed Pending payment row for the retained invoice, if the invoice and its + /// payment confirmation marker are both retained. This is storage history, not readiness. + pub(in crate::ffor) fn confirmed_payment( + &self, + ) -> Result, RequestStoreError> { + match self.invoice() { + Some(retained) if retained.payment_confirmed() => retained.payment().map(Some), + _ => Ok(None), + } + } + /// Absolute UNIX expiry of the retained invoice, when one is retained. + pub(in crate::ffor) fn invoice_expires_at(&self) -> Result, RequestStoreError> { + match self.invoice() { + Some(retained) => { + let invoice = retained.parsed()?; + Ok(Some( + invoice.duration_since_epoch().saturating_add(invoice.expiry_time()).as_secs(), + )) + }, + None => Ok(None), + } + } pub(super) fn same_request(&self, other: &Self) -> bool { let mut left = self.clone(); diff --git a/src/ffor/runtime.rs b/src/ffor/runtime.rs new file mode 100644 index 0000000000..afeba4ef4f --- /dev/null +++ b/src/ffor/runtime.rs @@ -0,0 +1,573 @@ +//! Bounded production runtime joining the private FFOR components for one exact-amount, +//! single-channel offline receive per request. +//! +//! The runtime is constructed only when the builder's [`OfflineReceiveConfig`] is set. It owns the protected +//! request and witness stores, the witness owner, the transport and the setup adapter, and a +//! small in-memory request table. One background worker drives every live request through the +//! native progress machine. Readiness is reported only after the exact invoice has been retained, +//! confirmed and released through native publication in this process. A payment is credited only +//! from the native journal outcome getter after the epoch reached `Closed`, through a durable +//! idempotent intent record, never from receipts, preimages, peer reports or monitor snapshots. +//! +//! Known limits: repeated epochs on one channel need native epoch reuse; a fulfilled voucher is +//! observed only after cooperative close; witness route evidence requires P2P gossip retention. + +use std::collections::BTreeMap; +use std::fmt; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use bitcoin::blockdata::constants::ChainHash; +use bitcoin::secp256k1::PublicKey; +use bitcoin::Network; +use lightning::ln::ffor::{ + FFORReceiverError, FFORReceiverId, FFORReceiverParameters, FFORReceiverRecoveryContext, +}; +use lightning::ln::types::ChannelId; +use lightning_ffor::amounts::FeePolicy; +use lightning_types::payment::PaymentHash; +use tokio::sync::Notify; + +use super::request_store::{RequestIntent, RequestPlan, RequestStore, RequestStoreError}; +use super::witness_owner::{WitnessOwner, WitnessOwnerError}; +use super::witness_store::{WitnessPolicy, WitnessSecretStore, WitnessStoreError}; +use crate::config::OfflineReceiveConfig; +use crate::error::Error; +use crate::event::EventQueue; +use crate::logger::{log_error, log_info, LdkLogger, Logger}; +use crate::message_handler::ffor::{FforReceiverTransport, FforSetupAdapter, ReceivedFforMessage}; +use crate::payment::{OfflineReceiveOutcome, OfflineReceiveStatus}; +use crate::types::{ChainMonitor, ChannelManager, DynStore, Graph, PaymentStore}; + +mod credit; +mod ledger; +mod route; +mod worker; +use ledger::{LedgerError, OutcomeIntent, OutcomeLedger}; + +const MAX_LIVE_REQUESTS: usize = 64; +const MAX_PENDING_ACKS: usize = 32; +/// Time a queued witness provision may wait for an acknowledgement before an explicit retry. +const PROVISION_TIMEOUT: Duration = Duration::from_secs(60); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum RuntimeError { + NotRecovered, + Ineligible, + NotFound, + Conflict, + InvalidIntent, + Capacity, + Request(RequestStoreError), + Witness(WitnessOwnerError), + WitnessStore(WitnessStoreError), + Ledger(LedgerError), + Native(FFORReceiverError), +} + +impl From for RuntimeError { + fn from(error: RequestStoreError) -> Self { + match error { + RequestStoreError::InvalidIntent => Self::InvalidIntent, + RequestStoreError::Conflict => Self::Conflict, + RequestStoreError::Capacity => Self::Capacity, + other => Self::Request(other), + } + } +} + +impl From for RuntimeError { + fn from(error: LedgerError) -> Self { + Self::Ledger(error) + } +} + +impl From for Error { + fn from(error: RuntimeError) -> Self { + match error { + RuntimeError::NotRecovered => Error::OfflineReceiveUnavailable, + RuntimeError::Ineligible => Error::OfflineReceiveIneligible, + RuntimeError::NotFound => Error::OfflineReceiveRequestNotFound, + RuntimeError::Conflict => Error::OfflineReceiveRequestConflict, + RuntimeError::InvalidIntent => Error::InvalidAmount, + RuntimeError::Capacity => Error::OfflineReceiveUnavailable, + RuntimeError::Request(RequestStoreError::Storage) + | RuntimeError::Request(RequestStoreError::Uncertain) + | RuntimeError::Ledger(LedgerError::Storage) => Error::PersistenceFailed, + _ => Error::OfflineReceiveUnavailable, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum CloseReason { + InvoiceExpired, + Deadline, + Cancelled, + Peer, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Stage { + /// Durable intent exists; no native selector yet. + Prepare, + /// Native selector bound; the epoch is not yet durably Active. + Setup, + /// Native Active; witness registration and provisioning in progress. + Witnesses, + /// All witness acknowledgements retained; invoice issuance and release in progress. + Invoice, + /// The exact invoice was released in this process. + Ready, + /// Cooperative close requested; driving Draining to Closed. + Closing(CloseReason), + /// Native Closed; joining the journal outcome. + Closed(CloseReason), + Settled(OfflineReceiveOutcome), + Expired, + Failed(String), +} + +impl Stage { + fn is_terminal(&self) -> bool { + matches!(self, Self::Settled(_) | Self::Expired | Self::Failed(_)) + } +} + +pub(super) struct LiveRequest { + client_id: String, + local_request_id: [u8; 32], + channel: ChannelId, + settlement: PublicKey, + amount_msat: u64, + settlement_deadline: u32, + voucher_expiry: u32, + selector: Option, + stage: Stage, + bolt11: Option, + invoice_expires_at: Option, + cancel_requested: bool, + needs_receipts: bool, + provision_started: BTreeMap, +} + +impl fmt::Debug for LiveRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LiveRequest") + .field("channel", &self.channel) + .field("amount_msat", &self.amount_msat) + .field("stage", &self.stage) + .finish_non_exhaustive() + } +} + +impl LiveRequest { + fn status(&self) -> OfflineReceiveStatus { + match &self.stage { + Stage::Prepare => OfflineReceiveStatus::Preparing, + Stage::Setup => OfflineReceiveStatus::AwaitingActivation, + Stage::Witnesses | Stage::Invoice => OfflineReceiveStatus::AwaitingWitnesses, + Stage::Ready => match &self.bolt11 { + Some(bolt11) => OfflineReceiveStatus::Ready { bolt11: bolt11.clone() }, + None => OfflineReceiveStatus::AwaitingWitnesses, + }, + Stage::Closing(CloseReason::Cancelled) | Stage::Closed(CloseReason::Cancelled) => { + OfflineReceiveStatus::Failed { reason: "cancelled".to_owned() } + }, + Stage::Closing(_) | Stage::Closed(_) | Stage::Expired => OfflineReceiveStatus::Expired, + Stage::Settled(outcome) => OfflineReceiveStatus::Settled { outcome: *outcome }, + Stage::Failed(reason) => OfflineReceiveStatus::Failed { reason: reason.clone() }, + } + } +} + +pub(super) struct RuntimeState { + requests: RequestStore, + witness_store: Arc, + witnesses: WitnessOwner, + ledger: OutcomeLedger, + live: BTreeMap, + pending_acks: Vec, + credits: Vec, + recovered: bool, +} + +/// Owns every private FFOR component for one node. Construct only through the builder. +pub(crate) struct FforReceiverRuntime { + config: OfflineReceiveConfig, + manager: Arc, + graph: Arc, + payments: Arc, + event_queue: Arc>>, + transport: Arc, + setup: Arc, + logger: Arc, + state: Mutex, + wake: Notify, +} + +impl FforReceiverRuntime { + /// Open both protected stores with the wallet seed and compose the owner. The seed is used + /// only here. Nothing is recovered or scheduled until [`Self::recover`] and the worker run. + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + config: OfflineReceiveConfig, network: Network, seed: &[u8; 64], + manager: Arc, monitor: Arc, graph: Arc, + payments: Arc, event_queue: Arc>>, + storage: Arc, transport: Arc, + setup: Arc, logger: Arc, + ) -> Result { + let requests = RequestStore::open_bound( + seed, + ChainHash::using_genesis_block(network).to_bytes(), + manager.get_our_node_id(), + Arc::clone(&manager), + Arc::clone(&payments), + Arc::clone(&storage), + )?; + let witness_store = Arc::new( + WitnessSecretStore::open(seed, Arc::clone(&storage)) + .map_err(RuntimeError::WitnessStore)?, + ); + let witnesses = WitnessOwner::new( + Arc::clone(&manager), + monitor, + Arc::clone(&witness_store), + Arc::clone(&transport), + ); + let ledger = OutcomeLedger::new(storage); + Ok(Self { + config, + manager, + graph, + payments, + event_queue, + transport, + setup, + logger, + state: Mutex::new(RuntimeState { + requests, + witness_store, + witnesses, + ledger, + live: BTreeMap::new(), + pending_acks: Vec::new(), + credits: Vec::new(), + recovered: false, + }), + wake: Notify::new(), + }) + } + + pub(crate) fn poll_interval(&self) -> Duration { + Duration::from_secs(self.config.poll_interval_secs) + } + + /// Start-time recovery: resolve uncertain writes, rejoin every durable request to native + /// history and resume unfinished completion intents. Safe to call repeatedly. + pub(crate) fn recover(&self) -> Result<(), RuntimeError> { + let mut state = self.state.lock().unwrap(); + self.recover_locked(&mut state) + } + + fn recover_locked(&self, state: &mut RuntimeState) -> Result<(), RuntimeError> { + state.requests.recover_write()?; + state.witnesses.recover_storage().map_err(RuntimeError::Witness)?; + let records = state.requests.list()?; + for record in records { + let client_id = record.intent().client_id().to_owned(); + if state.live.contains_key(&client_id) { + continue; + } + let local_request_id = record.local_request_id(); + let mut live = LiveRequest { + client_id: client_id.clone(), + local_request_id, + channel: record.plan().channel, + settlement: record.plan().settlement, + amount_msat: record.intent().amount_msat(), + settlement_deadline: record.plan().parameters.settlement_deadline, + voucher_expiry: record.plan().parameters.voucher_expiry, + selector: None, + stage: Stage::Prepare, + bolt11: None, + invoice_expires_at: None, + cancel_requested: false, + needs_receipts: false, + provision_started: BTreeMap::new(), + }; + // Storage uncertainty aborts recovery; a record that no longer matches native + // history is retained as failed rather than blocking every other request. + match state.requests.recover_native(&client_id) { + Ok(None) => { + if state.ledger.is_cancelled(local_request_id)? { + live.stage = Stage::Failed("cancelled".to_owned()); + } + }, + Ok(Some(id)) => { + live.selector = Some(id); + live.stage = Stage::Setup; + // A confirmed invoice means witnesses were natively acknowledged; rejoin the + // assignment so the worker can release it again in this process. + match state.requests.recover_invoice(&client_id) { + Ok(Some(_)) => live.stage = Stage::Invoice, + Ok(None) => {}, + Err(RequestStoreError::Native(FFORReceiverError::ChannelState(_))) => { + live.stage = Stage::Invoice; + }, + Err( + error @ (RequestStoreError::Storage | RequestStoreError::Uncertain), + ) => { + return Err(error.into()); + }, + Err(error) => live.stage = Stage::Failed(format!("recovery: {error:?}")), + } + if record.confirmed_payment()?.is_some() && !live.stage.is_terminal() { + live.stage = Stage::Invoice; + } + }, + Err(error @ (RequestStoreError::Storage | RequestStoreError::Uncertain)) => { + return Err(error.into()); + }, + Err(error) => live.stage = Stage::Failed(format!("recovery: {error:?}")), + } + if state.live.len() >= MAX_LIVE_REQUESTS { + return Err(RuntimeError::Capacity); + } + log_info!( + self.logger, + "Recovered offline receive request on channel {} at stage {:?}", + live.channel, + live.stage + ); + state.live.insert(client_id, live); + } + for intent in state.ledger.unfinished()? { + if !state.credits.iter().any(|existing| existing.key == intent.key) { + state.credits.push(intent); + } + } + state.recovered = true; + Ok(()) + } + + /// Positive amount within one ready channel's inbound capacity on the settlement peer, with + /// no other live request or historical native epoch on that channel. Peer connectivity is + /// transient and is not part of eligibility; native preparation waits for the connection. + pub(crate) fn can_receive(&self, amount_msat: u64) -> Result { + let state = self.state.lock().unwrap(); + if !state.recovered { + return Err(RuntimeError::NotRecovered); + } + Ok(self.eligible_channel(&state, amount_msat)?.is_some()) + } + + fn eligible_channel( + &self, state: &RuntimeState, amount_msat: u64, + ) -> Result, RuntimeError> { + if amount_msat == 0 { + return Ok(None); + } + let gross = match self.fee_policy().gross_msat(amount_msat) { + Ok(gross) => gross, + Err(_) => return Ok(None), + }; + let contexts = + self.manager.list_ffor_receiver_recovery_contexts().map_err(RuntimeError::Native)?; + let busy = |channel: &ChannelId| { + state.live.values().any(|live| live.channel == *channel && !live.stage.is_terminal()) + || history_blocks_channel(channel, &contexts, |context| { + self.epoch_is_terminal(context) + }) + }; + Ok(self + .manager + .list_channels() + .into_iter() + .filter(|details| { + details.counterparty.node_id == self.config.settlement_node_id + && details.is_channel_ready + && details.inbound_capacity_msat >= gross + && !busy(&details.channel_id) + }) + .map(|details| details.channel_id) + .next()) + } + + /// The only public terminal signal for a historical epoch is a completed cooperative drain: + /// the journal outcome getter answers `Some` for slot one. Aborted epochs expose no public + /// terminal signal and therefore keep their channel ineligible here, even though native may + /// admit a replacement; pending drains and incomplete terminal writes are not terminal. + fn epoch_is_terminal(&self, context: &FFORReceiverRecoveryContext) -> bool { + let Some(voucher) = context.setup().vouchers().first() else { return false }; + matches!( + self.manager.ffor_receiver_voucher_outcome( + context, + 1, + PaymentHash(voucher.payment_hash), + voucher.amount_msat, + ), + Ok(Some(_)) + ) + } + + fn fee_policy(&self) -> FeePolicy { + FeePolicy { + base_msat: self.config.fee_base_msat, + proportional_millionths: self.config.fee_proportional_millionths, + } + } + + fn witness_ids(&self) -> Vec { + let mut ids: Vec = + self.config.witnesses.iter().map(|witness| witness.node_id).collect(); + ids.sort_unstable(); + ids + } + + fn witness_policies(&self, voucher_expiry: u32) -> Vec { + self.config + .witnesses + .iter() + .map(|witness| WitnessPolicy { + witness: witness.node_id, + retention_until: voucher_expiry.saturating_add(witness.retention_blocks), + minimum_receipts: witness.minimum_receipts, + }) + .collect() + } + + /// Idempotent per request ID. Begins the durable record and schedules the worker. The same + /// ID with different arguments is a conflict. + pub(crate) fn prepare( + &self, client_id: String, amount_msat: u64, description: String, + ) -> Result { + let mut state = self.state.lock().unwrap(); + if !state.recovered { + return Err(RuntimeError::NotRecovered); + } + let intent = RequestIntent::new(client_id.clone(), amount_msat, description)?; + if state.live.contains_key(&client_id) { + let record = state.requests.lookup(&client_id)?.ok_or(RuntimeError::NotFound)?; + if record.intent() != &intent { + return Err(RuntimeError::Conflict); + } + return Ok(state.live[&client_id].status()); + } + if state.live.len() >= MAX_LIVE_REQUESTS { + return Err(RuntimeError::Capacity); + } + let local_request_id = state.requests.local_request_id(&client_id)?; + let record = match state.requests.lookup(&client_id)? { + Some(existing) => existing, + None => { + let channel = + self.eligible_channel(&state, amount_msat)?.ok_or(RuntimeError::Ineligible)?; + let tip = self.manager.current_best_block().height; + let plan = RequestPlan { + channel, + settlement: self.config.settlement_node_id, + parameters: FFORReceiverParameters { + local_request_id, + amounts_msat: vec![amount_msat], + minimum_payment_msat: amount_msat, + settlement_deadline: tip + .checked_add(self.config.settlement_deadline_blocks) + .ok_or(RuntimeError::InvalidIntent)?, + voucher_expiry: tip + .checked_add(self.config.voucher_expiry_blocks) + .ok_or(RuntimeError::InvalidIntent)?, + fee_base_msat: self.config.fee_base_msat, + fee_proportional_millionths: self.config.fee_proportional_millionths, + claim_margin_blocks: self.config.claim_margin_blocks, + witness_peers: Some(self.witness_ids()), + hash_chain: false, + }, + }; + state.requests.begin(intent.clone(), plan)? + }, + }; + if record.intent() != &intent { + return Err(RuntimeError::Conflict); + } + let live = LiveRequest { + client_id: client_id.clone(), + local_request_id, + channel: record.plan().channel, + settlement: record.plan().settlement, + amount_msat, + settlement_deadline: record.plan().parameters.settlement_deadline, + voucher_expiry: record.plan().parameters.voucher_expiry, + selector: None, + stage: Stage::Prepare, + bolt11: None, + invoice_expires_at: None, + cancel_requested: false, + needs_receipts: false, + provision_started: BTreeMap::new(), + }; + let status = live.status(); + state.live.insert(client_id, live); + drop(state); + self.wake.notify_one(); + Ok(status) + } + + pub(crate) fn status(&self, client_id: &str) -> Result { + let state = self.state.lock().unwrap(); + state.live.get(client_id).map(LiveRequest::status).ok_or(RuntimeError::NotFound) + } + + /// Request cancellation. Before activation the native setup is cancelled; afterwards a + /// cooperative close is requested. The worker applies it on its next pass. + pub(crate) fn cancel(&self, client_id: &str) -> Result<(), RuntimeError> { + let mut state = self.state.lock().unwrap(); + let live = state.live.get_mut(client_id).ok_or(RuntimeError::NotFound)?; + if !live.stage.is_terminal() { + live.cancel_requested = true; + } + drop(state); + self.wake.notify_one(); + Ok(()) + } + + /// Spawn the worker. It stops with the node's stop signal. + pub(crate) fn spawn_worker( + self: &Arc, runtime: &crate::runtime::Runtime, + mut stop: tokio::sync::watch::Receiver<()>, + ) { + let this = Arc::clone(self); + runtime.spawn_cancellable_background_task(async move { + let mut interval = tokio::time::interval(this.poll_interval()); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + _ = stop.changed() => { + log_info!(this.logger, "Stopping offline receive worker."); + return; + }, + _ = interval.tick() => {}, + _ = this.wake.notified() => {}, + } + this.tick().await; + } + }); + } + + fn log_error(&self, context: &str, error: &dyn fmt::Debug) { + log_error!(self.logger, "Offline receive {}: {:?}", context, error); + } +} + +/// A channel with native history is eligible only when every historical epoch is terminal. +/// Native still decides admission; this predicate only avoids allocating an intent that +/// native would refuse with `AlreadyRegistered`. +fn history_blocks_channel( + channel: &ChannelId, contexts: &[FFORReceiverRecoveryContext], + terminal: impl Fn(&FFORReceiverRecoveryContext) -> bool, +) -> bool { + contexts.iter().any(|context| context.channel_id() == *channel && !terminal(context)) +} + +#[cfg(test)] +mod tests; diff --git a/src/ffor/runtime/credit.rs b/src/ffor/runtime/credit.rs new file mode 100644 index 0000000000..084195cc8f --- /dev/null +++ b/src/ffor/runtime/credit.rs @@ -0,0 +1,81 @@ +//! The idempotent credit sequence for one fulfilled voucher. +//! +//! `Intended` is written before the payment row changes, `Credited` after the successful +//! payment write, and `Notified` only after the `PaymentReceived` event is durably queued. A +//! crash between any two writes resumes from the persisted state. If the queue already holds +//! the event when resuming from `Credited`, it is not queued again. An event handled by the +//! application between queueing and the `Notified` write can still be delivered twice; that +//! window is documented, not hidden. + +use std::sync::Arc; + +use lightning::ln::channelmanager::PaymentId; +use lightning_types::payment::PaymentPreimage; + +use super::ledger::{IntentState, LedgerError, OutcomeIntent, OutcomeLedger}; +use super::RuntimeError; +use crate::data_store::DataStoreUpdateResult; +use crate::event::{Event, EventQueue}; +use crate::logger::Logger; +use crate::payment::store::{PaymentDetailsUpdate, PaymentStatus}; +use crate::types::PaymentStore; + +/// Advance the intent to `Credited`. Returns the event that must be queued before +/// [`mark_notified`], or `None` when nothing remains to queue. +pub(super) fn credit_intent( + ledger: &OutcomeLedger, payments: &PaymentStore, event_queue: &EventQueue>, + intent: &OutcomeIntent, preimage: Option, +) -> Result, RuntimeError> { + let mut current = match ledger.load(&intent.key)? { + Some(existing) => { + if existing.payment_hash != intent.payment_hash + || existing.amount_msat != intent.amount_msat + { + return Err(RuntimeError::Conflict); + } + existing + }, + None => { + let fresh = OutcomeIntent { state: IntentState::Intended, ..intent.clone() }; + ledger.write(&fresh)?; + fresh + }, + }; + if current.state == IntentState::Intended { + let update = PaymentDetailsUpdate { + preimage: preimage.map(Some), + amount_msat: Some(Some(current.amount_msat)), + status: Some(PaymentStatus::Succeeded), + ..PaymentDetailsUpdate::new(PaymentId(current.payment_hash.0)) + }; + match payments.update(&update) { + Ok(DataStoreUpdateResult::Updated | DataStoreUpdateResult::Unchanged) => {}, + Ok(DataStoreUpdateResult::NotFound) => return Err(RuntimeError::NotFound), + Err(_) => return Err(RuntimeError::Ledger(LedgerError::Storage)), + } + current.state = IntentState::Credited; + ledger.write(¤t)?; + } + if current.state == IntentState::Credited { + if event_queue.contains_payment_received(¤t.payment_hash) { + return mark_notified(ledger, ¤t).map(|()| None); + } + let event = Event::PaymentReceived { + payment_id: Some(PaymentId(current.payment_hash.0)), + payment_hash: current.payment_hash, + amount_msat: current.amount_msat, + custom_records: Vec::new(), + }; + return Ok(Some((current, event))); + } + Ok(None) +} + +/// Persist the terminal `Notified` state after the event was durably queued. +pub(super) fn mark_notified( + ledger: &OutcomeLedger, intent: &OutcomeIntent, +) -> Result<(), RuntimeError> { + let notified = OutcomeIntent { state: IntentState::Notified, ..intent.clone() }; + ledger.write(¬ified)?; + Ok(()) +} diff --git a/src/ffor/runtime/ledger.rs b/src/ffor/runtime/ledger.rs new file mode 100644 index 0000000000..c9cf20a73e --- /dev/null +++ b/src/ffor/runtime/ledger.rs @@ -0,0 +1,244 @@ +//! Durable, monotone completion intents for fulfilled vouchers and cancellation markers. +//! +//! One record per `(channel, epoch, slot)` moves `Intended -> Credited -> Notified`. Every step +//! is idempotent, so a crash between any two writes is resumed by re-running the sequence from +//! the persisted state. The record contains only the payment hash, amount and client ID that +//! already exist in the plaintext payment ledger; it is not a payment credit by itself. + +use std::fmt; + +use lightning::ln::types::ChannelId; +use lightning::util::persist::KVStoreSync; +use lightning_types::payment::PaymentHash; + +use crate::types::DynStore; + +pub(super) const NAMESPACE: &str = "ffor_runtime"; +const VERSION: u16 = 1; +const MAX_CLIENT_ID_BYTES: usize = 128; +const MAX_RECORDS: usize = 512; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum IntentState { + Intended, + Credited, + Notified, +} + +impl IntentState { + fn byte(self) -> u8 { + match self { + Self::Intended => 1, + Self::Credited => 2, + Self::Notified => 3, + } + } + fn from_byte(byte: u8) -> Result { + match byte { + 1 => Ok(Self::Intended), + 2 => Ok(Self::Credited), + 3 => Ok(Self::Notified), + _ => Err(LedgerError::Corrupt), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct IntentKey { + pub(crate) channel: ChannelId, + pub(crate) epoch: [u8; 32], + pub(crate) slot: u16, +} + +impl IntentKey { + fn storage_key(&self) -> String { + format!("outcome_{}_{}_{}", hex(&self.channel.0), hex(&self.epoch), self.slot) + } +} + +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct OutcomeIntent { + pub(crate) key: IntentKey, + pub(crate) state: IntentState, + pub(crate) payment_hash: PaymentHash, + pub(crate) amount_msat: u64, + pub(crate) client_id: String, +} + +impl fmt::Debug for OutcomeIntent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OutcomeIntent") + .field("slot", &self.key.slot) + .field("state", &self.state) + .field("amount_msat", &self.amount_msat) + .finish_non_exhaustive() + } +} + +impl OutcomeIntent { + fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(2 + 1 + 32 + 32 + 2 + 32 + 8 + 2 + self.client_id.len()); + out.extend_from_slice(&VERSION.to_be_bytes()); + out.push(self.state.byte()); + out.extend_from_slice(&self.key.channel.0); + out.extend_from_slice(&self.key.epoch); + out.extend_from_slice(&self.key.slot.to_be_bytes()); + out.extend_from_slice(&self.payment_hash.0); + out.extend_from_slice(&self.amount_msat.to_be_bytes()); + out.extend_from_slice(&(self.client_id.len() as u16).to_be_bytes()); + out.extend_from_slice(self.client_id.as_bytes()); + out + } + + fn decode(bytes: &[u8]) -> Result { + let mut reader = Reader(bytes); + if u16::from_be_bytes(reader.array()?) != VERSION { + return Err(LedgerError::Corrupt); + } + let state = IntentState::from_byte(reader.array::<1>()?[0])?; + let channel = ChannelId(reader.array()?); + let epoch = reader.array()?; + let slot = u16::from_be_bytes(reader.array()?); + let payment_hash = PaymentHash(reader.array()?); + let amount_msat = u64::from_be_bytes(reader.array()?); + let len = usize::from(u16::from_be_bytes(reader.array()?)); + if len == 0 || len > MAX_CLIENT_ID_BYTES { + return Err(LedgerError::Corrupt); + } + let client_id = + String::from_utf8(reader.take(len)?.to_vec()).map_err(|_| LedgerError::Corrupt)?; + if !reader.0.is_empty() || slot == 0 || amount_msat == 0 { + return Err(LedgerError::Corrupt); + } + Ok(Self { + key: IntentKey { channel, epoch, slot }, + state, + payment_hash, + amount_msat, + client_id, + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum LedgerError { + Storage, + Corrupt, + Conflict, + Capacity, +} + +/// Plain KV records. Storage success is the durability contract; a failed write is retried by +/// re-running the idempotent step, never by trusting visible bytes. +pub(crate) struct OutcomeLedger { + storage: std::sync::Arc, +} + +impl OutcomeLedger { + pub(crate) fn new(storage: std::sync::Arc) -> Self { + Self { storage } + } + + pub(crate) fn load(&self, key: &IntentKey) -> Result, LedgerError> { + match KVStoreSync::read(&*self.storage, NAMESPACE, "", &key.storage_key()) { + Ok(bytes) => { + let intent = OutcomeIntent::decode(&bytes)?; + if intent.key != *key { + return Err(LedgerError::Corrupt); + } + Ok(Some(intent)) + }, + Err(error) if error.kind() == lightning::io::ErrorKind::NotFound => Ok(None), + Err(_) => Err(LedgerError::Storage), + } + } + + /// Persist `intent`. The state may only stay equal or advance; payment identity is immutable. + pub(crate) fn write(&self, intent: &OutcomeIntent) -> Result<(), LedgerError> { + if let Some(existing) = self.load(&intent.key)? { + if existing.payment_hash != intent.payment_hash + || existing.amount_msat != intent.amount_msat + || existing.client_id != intent.client_id + || existing.state > intent.state + { + return Err(LedgerError::Conflict); + } + } + KVStoreSync::write( + &*self.storage, + NAMESPACE, + "", + &intent.key.storage_key(), + intent.encode(), + ) + .map_err(|_| LedgerError::Storage) + } + + /// Every intent that has not reached `Notified`, for resumption after restart. + pub(crate) fn unfinished(&self) -> Result, LedgerError> { + let keys = + KVStoreSync::list(&*self.storage, NAMESPACE, "").map_err(|_| LedgerError::Storage)?; + if keys.len() > MAX_RECORDS { + return Err(LedgerError::Capacity); + } + let mut out = Vec::new(); + for key in keys.iter().filter(|key| key.starts_with("outcome_")) { + let bytes = KVStoreSync::read(&*self.storage, NAMESPACE, "", key) + .map_err(|_| LedgerError::Storage)?; + let intent = OutcomeIntent::decode(&bytes)?; + if intent.key.storage_key() != *key { + return Err(LedgerError::Corrupt); + } + if intent.state != IntentState::Notified { + out.push(intent); + } + } + Ok(out) + } + + pub(crate) fn mark_cancelled(&self, local_request_id: [u8; 32]) -> Result<(), LedgerError> { + KVStoreSync::write( + &*self.storage, + NAMESPACE, + "", + &cancel_key(local_request_id), + VERSION.to_be_bytes().to_vec(), + ) + .map_err(|_| LedgerError::Storage) + } + + pub(crate) fn is_cancelled(&self, local_request_id: [u8; 32]) -> Result { + match KVStoreSync::read(&*self.storage, NAMESPACE, "", &cancel_key(local_request_id)) { + Ok(bytes) if bytes == VERSION.to_be_bytes() => Ok(true), + Ok(_) => Err(LedgerError::Corrupt), + Err(error) if error.kind() == lightning::io::ErrorKind::NotFound => Ok(false), + Err(_) => Err(LedgerError::Storage), + } + } +} + +fn cancel_key(local_request_id: [u8; 32]) -> String { + format!("cancel_{}", hex(&local_request_id)) +} + +pub(super) fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8] = b"0123456789abcdef"; + let mut result = String::with_capacity(bytes.len() * 2); + for byte in bytes { + result.push(DIGITS[(byte >> 4) as usize] as char); + result.push(DIGITS[(byte & 15) as usize] as char); + } + result +} + +struct Reader<'a>(&'a [u8]); +impl<'a> Reader<'a> { + fn take(&mut self, count: usize) -> Result<&'a [u8], LedgerError> { + let value = self.0.get(..count).ok_or(LedgerError::Corrupt)?; + self.0 = &self.0[count..]; + Ok(value) + } + fn array(&mut self) -> Result<[u8; N], LedgerError> { + self.take(N)?.try_into().map_err(|_| LedgerError::Corrupt) + } +} diff --git a/src/ffor/runtime/route.rs b/src/ffor/runtime/route.rs new file mode 100644 index 0000000000..a34fe7a7de --- /dev/null +++ b/src/ffor/runtime/route.rs @@ -0,0 +1,45 @@ +//! Public witness-to-settlement route evidence from the node's own network graph. +//! +//! Only a fully signed `ChannelAnnouncement` and an enabled, signed witness-sourced +//! `ChannelUpdate` are acceptable. Rapid gossip sync retains neither, so the runtime refuses to +//! issue an invoice rather than fabricate advisory route terms. Native revalidates everything. + +use bitcoin::secp256k1::PublicKey; +use lightning::ln::ffor::FFORWitnessRouteEvidence; +use lightning::routing::gossip::NodeId; + +use crate::types::Graph; + +/// Find signed gossip for a public channel between `witness` and `settlement` whose update was +/// signed by the witness (source == witness). Returns `None` when no such evidence is retained. +pub(super) fn witness_route_evidence( + graph: &Graph, witness: PublicKey, settlement: PublicKey, +) -> Option { + let witness_id = NodeId::from_pubkey(&witness); + let settlement_id = NodeId::from_pubkey(&settlement); + let read = graph.read_only(); + let node = read.node(&witness_id)?; + for scid in node.channels.iter() { + let Some(info) = read.channel(*scid) else { continue }; + let direction = if info.node_one == witness_id && info.node_two == settlement_id { + info.one_to_two.as_ref() + } else if info.node_one == settlement_id && info.node_two == witness_id { + info.two_to_one.as_ref() + } else { + continue; + }; + let (Some(announcement), Some(direction)) = (info.announcement_message.as_ref(), direction) + else { + continue; + }; + if !direction.enabled { + continue; + } + let Some(update) = direction.last_update_message.as_ref() else { continue }; + return Some(FFORWitnessRouteEvidence { + announcement: announcement.clone(), + update: update.clone(), + }); + } + None +} diff --git a/src/ffor/runtime/tests.rs b/src/ffor/runtime/tests.rs new file mode 100644 index 0000000000..7864122542 --- /dev/null +++ b/src/ffor/runtime/tests.rs @@ -0,0 +1,667 @@ +//! Runtime state-machine tests against the genuine exported native fixtures, plus durable +//! completion-intent tests with injected write failures and restart. + +use std::str::FromStr; +use std::sync::atomic::Ordering; + +use bitcoin::block::{Header, Version}; +use bitcoin::hash_types::TxMerkleNode; +use bitcoin::hashes::Hash; +use bitcoin::hex::FromHex; +use bitcoin::pow::CompactTarget; +use bitcoin::BlockHash; +use lightning::chain::Confirm; +use lightning::ln::channelmanager::PaymentId; +use lightning::ln::msgs::{BaseMessageHandler, ChannelAnnouncement, ChannelUpdate, Init}; +use lightning::ln::peer_handler::CustomMessageHandler; +use lightning::ln::wire::{CustomMessageReader, Type}; +use lightning::sign::{KeysManager, NodeSigner, Recipient}; +use lightning::util::persist::{ + KVStoreSync, CHANNEL_MANAGER_PERSISTENCE_KEY, CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, CHANNEL_MONITOR_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MONITOR_PERSISTENCE_SECONDARY_NAMESPACE, +}; +use lightning::util::ser::{LengthReadable, Writeable}; +use lightning_ffor::witness::{Acknowledgement, AcknowledgementResult}; +use lightning_invoice::Bolt11Invoice; +use lightning_types::features::InitFeatures; +use lightning_types::payment::{PaymentHash, PaymentPreimage}; + +use super::credit::{credit_intent, mark_notified}; +use super::ledger::{IntentKey, IntentState, OutcomeIntent, OutcomeLedger}; +use super::*; +use crate::builder::{BuildError, NodeBuilder}; +use crate::config::{OfflineReceiveConfig, OfflineReceiveWitnessConfig}; +use crate::ffor::witness_store::tests::TestStore; +use crate::ffor::witness_store::{FixtureWitness, StoredWitnessEpoch, WitnessStorageBinding}; +use crate::message_handler::NodeCustomMessageHandler; +use crate::payment::store::{PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus}; +use crate::{Config, Event, Node}; + +const SEED: [u8; 64] = [91; 64]; +const CLIENT: &str = "request-fixture"; +const AMOUNT: u64 = 2_000_000; +const EMPTY_MANAGER: &[u8] = include_bytes!("../request_store/fixtures/empty-manager.bin"); +const EMPTY_MONITOR: &[u8] = include_bytes!("../request_store/fixtures/empty-monitor.bin"); +const ACTIVE_MANAGER: &[u8] = + include_bytes!("../request_store/fixtures/invoice/active-manager.bin"); +const ACTIVE_MONITOR: &[u8] = include_bytes!("../request_store/fixtures/invoice/monitor.bin"); +const MANIFEST: &[u8] = include_bytes!("../request_store/fixtures/invoice/witness-manifest.bin"); + +fn ifield(name: &str) -> &'static str { + include_str!("../request_store/fixtures/invoice/fixture.txt") + .lines() + .find_map(|line| line.strip_prefix(&format!("{name}="))) + .unwrap() +} + +fn settlement() -> PublicKey { + PublicKey::from_str(ifield("settlement")).unwrap() +} + +fn witness() -> PublicKey { + PublicKey::from_str(ifield("witness")).unwrap() +} + +fn install_native(storage: &TestStore, manager: &[u8], monitor: &[u8]) { + KVStoreSync::write( + storage, + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_KEY, + manager.to_vec(), + ) + .unwrap(); + KVStoreSync::write( + storage, + CHANNEL_MONITOR_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MONITOR_PERSISTENCE_SECONDARY_NAMESPACE, + &format!("{}_{}", ifield("funding_txid"), ifield("funding_vout")), + monitor.to_vec(), + ) + .unwrap(); +} + +fn build(storage: Arc, offline_receive: Option) -> Node { + let mut builder = + NodeBuilder::from_config(Config { network: Network::Testnet, ..Config::default() }); + if let Some(config) = offline_receive { + builder.set_offline_receive_config(config); + } + builder.set_entropy_seed_bytes(SEED); + builder.set_log_facade_logger(); + builder.build_with_store(storage).unwrap() +} + +/// Fixture terms: tip 10, settlement deadline 145, voucher expiry 165, claim margin 20. The +/// witness retention follows the exported manifest so an existing native registration rejoins. +fn offline_config(retention_until: u32, minimum_receipts: u8) -> OfflineReceiveConfig { + let voucher_expiry: u32 = ifield("voucher_expiry").parse().unwrap(); + let deadline: u32 = ifield("settlement_deadline").parse().unwrap(); + let tip: u32 = ifield("height").parse().unwrap(); + let mut config = OfflineReceiveConfig::new( + settlement(), + vec![OfflineReceiveWitnessConfig { + node_id: witness(), + retention_blocks: retention_until - voucher_expiry, + minimum_receipts, + }], + ); + config.invoice_expiry_seconds = ifield("expiry_seconds").parse().unwrap(); + config.invoice_safety_margin_seconds = ifield("safety_margin_seconds").parse().unwrap(); + config.settlement_deadline_blocks = deadline - tip; + config.voucher_expiry_blocks = voucher_expiry - tip; + config.claim_margin_blocks = ifield("claim_margin_blocks").parse().unwrap(); + config +} + +fn manifest_terms(storage: &Arc) -> (u32, u8) { + let node = build(Arc::clone(storage), None); + let context = node.channel_manager.list_ffor_receiver_recovery_contexts().unwrap().remove(0); + let manifest = + lightning_ffor::witness::SignedManifest::decode(MANIFEST, context.setup()).unwrap(); + let params = manifest.unsigned().parameters(); + (params.retention_until, params.minimum_receipts) +} + +struct Harness { + storage: Arc, + node: Node, + runtime: Arc, + handler: NodeCustomMessageHandler, +} + +impl Harness { + fn empty() -> Self { + let storage = TestStore::new(); + install_native(&storage, EMPTY_MANAGER, EMPTY_MONITOR); + Self::from_storage(storage, offline_config(300, 0)) + } + + fn from_storage(storage: Arc, config: OfflineReceiveConfig) -> Self { + let node = build(Arc::clone(&storage), Some(config)); + let runtime = node.offline_receive.clone().expect("runtime constructed from config"); + let handler = + NodeCustomMessageHandler::new_ignoring().with_ffor_setup(Arc::clone(&runtime.setup)); + // Node::start performs this recovery before spawning the worker. + runtime.recover().unwrap(); + Self { storage, node, runtime, handler } + } + + fn connect(&self, peer: PublicKey) { + let mut features = InitFeatures::empty(); + features.set_static_remote_key_optional(); + let init = Init { features, networks: None, remote_network_address: None }; + self.node.channel_manager.peer_connected(peer, &init, false).unwrap(); + self.handler.peer_connected(peer, &init, false).unwrap(); + } + + fn persist_manager(&self) { + let token = self.node.channel_manager.capture_ffor_persistence(); + KVStoreSync::write( + &*self.storage, + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_KEY, + self.node.channel_manager.encode(), + ) + .unwrap(); + self.node.channel_manager.ffor_persistence_completed(token).unwrap(); + } + + fn tick(&self) { + self.node.runtime.block_on(self.runtime.tick()); + } + + fn status(&self) -> OfflineReceiveStatus { + self.runtime.status(CLIENT).unwrap() + } + + fn stage(&self) -> Stage { + self.runtime.state.lock().unwrap().live.get(CLIENT).unwrap().stage.clone() + } + + fn set_tip(&self, height: u32) { + let header = Header { + version: Version::NO_SOFT_FORK_SIGNALLING, + prev_blockhash: BlockHash::all_zeros(), + merkle_root: TxMerkleNode::all_zeros(), + time: now() as u32, + bits: CompactTarget::from_consensus(0), + nonce: 0, + }; + self.node.channel_manager.best_block_updated(&header, height); + } + + fn install_sidecar(&self, acknowledged: bool) { + let context = + self.node.channel_manager.list_ffor_receiver_recovery_contexts().unwrap().remove(0); + let binding = WitnessStorageBinding::from_native_context(&context).unwrap(); + let record = StoredWitnessEpoch::from_fixture( + &binding, + <[u8; 32]>::from_hex(ifield("encryption_secret")).unwrap(), + vec![FixtureWitness { + witness: witness(), + fetch_secret: <[u8; 32]>::from_hex(ifield("fetch_secret")).unwrap(), + manifest: MANIFEST.to_vec(), + acknowledged, + }], + ) + .unwrap(); + let state = self.runtime.state.lock().unwrap(); + state.witness_store.install_fixture(&binding, record).unwrap(); + } + + fn install_route(&self) { + let announcement = ChannelAnnouncement::read_from_fixed_length_buffer( + &mut include_bytes!("../request_store/fixtures/invoice/route-announcement.bin") + .as_slice(), + ) + .unwrap(); + let mut update = ChannelUpdate::read_from_fixed_length_buffer( + &mut include_bytes!("../request_store/fixtures/invoice/route-update.bin").as_slice(), + ) + .unwrap(); + update.contents.timestamp = now() as u32; + let seed = <[u8; 32]>::from_hex(ifield("witness_node_seed")).unwrap(); + let keys = KeysManager::new(&seed, 0, 0, true); + assert_eq!(keys.get_node_id(Recipient::Node).unwrap(), witness()); + update.signature = keys + .sign_gossip_message(lightning::ln::msgs::UnsignedGossipMessage::ChannelUpdate( + &update.contents, + )) + .unwrap(); + self.node.network_graph.update_channel_from_announcement_no_lookup(&announcement).unwrap(); + self.node.network_graph.update_channel(&update).unwrap(); + } + + fn outbound_types(&self) -> Vec { + self.handler + .get_and_clear_pending_msg() + .into_iter() + .map(|(_, message)| message.type_id()) + .collect() + } +} + +fn now() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() +} + +fn prepared_active_storage() -> (Arc, OfflineReceiveConfig) { + // Begin the durable record on the empty pair exactly as the issuer fixture did, then + // restore the exported Active manager over it. + let h = Harness::empty(); + assert_eq!( + h.runtime.prepare(CLIENT.to_owned(), AMOUNT, ifield("description").to_owned()), + Ok(OfflineReceiveStatus::Preparing) + ); + let storage = Arc::clone(&h.storage); + drop(h); + install_native(&storage, ACTIVE_MANAGER, ACTIVE_MONITOR); + let (retention_until, minimum_receipts) = manifest_terms(&storage); + (storage, offline_config(retention_until, minimum_receipts)) +} + +#[test] +fn ffor_runtime_is_absent_without_config_and_refuses_invalid_config() { + let storage = TestStore::new(); + install_native(&storage, EMPTY_MANAGER, EMPTY_MONITOR); + let node = build(Arc::clone(&storage), None); + assert!(node.offline_receive.is_none()); + let handler = node.offline_receive(); + assert_eq!(handler.can_receive(AMOUNT), Err(Error::OfflineReceiveDisabled)); + assert_eq!(handler.status(CLIENT.to_owned()), Err(Error::OfflineReceiveDisabled)); + assert_eq!( + handler.prepare(CLIENT.to_owned(), AMOUNT, "x".to_owned()), + Err(Error::OfflineReceiveDisabled) + ); + assert_eq!(handler.cancel(CLIENT.to_owned()), Err(Error::OfflineReceiveDisabled)); + drop(node); + + let mut invalid = offline_config(300, 0); + invalid.witnesses.clear(); + let mut builder = + NodeBuilder::from_config(Config { network: Network::Testnet, ..Config::default() }); + builder.set_offline_receive_config(invalid); + builder.set_entropy_seed_bytes(SEED); + builder.set_log_facade_logger(); + let backing: Arc = storage; + assert!(matches!( + builder.build_with_store(backing).map(|_| ()), + Err(BuildError::InvalidOfflineReceiveConfig) + )); + + let mut config = offline_config(300, 0); + config.witnesses[0].node_id = settlement(); + assert!(config.validate().is_err()); + let mut config = offline_config(300, 0); + config.voucher_expiry_blocks = config.settlement_deadline_blocks; + assert!(config.validate().is_err()); + assert!(offline_config(300, 0).validate().is_ok()); +} + +#[test] +fn ffor_runtime_handler_requires_running_node_for_mutations() { + let h = Harness::empty(); + let handler = h.node.offline_receive(); + assert_eq!(handler.can_receive(AMOUNT), Err(Error::NotRunning)); + assert_eq!(handler.prepare(CLIENT.to_owned(), AMOUNT, "x".to_owned()), Err(Error::NotRunning)); + assert_eq!(handler.cancel(CLIENT.to_owned()), Err(Error::NotRunning)); + assert_eq!(handler.status(CLIENT.to_owned()), Err(Error::OfflineReceiveRequestNotFound)); +} + +#[test] +fn ffor_runtime_prepare_is_idempotent_and_eligibility_tracks_channel_and_live_requests() { + let h = Harness::empty(); + assert_eq!(h.runtime.can_receive(0), Ok(false)); + assert_eq!(h.runtime.can_receive(AMOUNT), Ok(true)); + h.connect(settlement()); + assert_eq!(h.runtime.can_receive(AMOUNT), Ok(true)); + assert_eq!(h.runtime.can_receive(u64::MAX), Ok(false)); + assert_eq!(h.runtime.status(CLIENT), Err(RuntimeError::NotFound)); + assert_eq!( + h.runtime.prepare(CLIENT.to_owned(), AMOUNT, ifield("description").to_owned()), + Ok(OfflineReceiveStatus::Preparing) + ); + { + let mut state = h.runtime.state.lock().unwrap(); + let record = state.requests.lookup(CLIENT).unwrap().unwrap(); + assert_eq!(super::ledger::hex(&record.local_request_id()), ifield("local_request_id")); + assert_eq!(record.plan().parameters.settlement_deadline, 145); + assert_eq!(record.plan().parameters.voucher_expiry, 165); + assert_eq!(record.plan().parameters.witness_peers, Some(vec![witness()])); + } + assert_eq!( + h.runtime.prepare(CLIENT.to_owned(), AMOUNT, ifield("description").to_owned()), + Ok(OfflineReceiveStatus::Preparing) + ); + assert_eq!( + h.runtime.prepare(CLIENT.to_owned(), AMOUNT + 1, ifield("description").to_owned()), + Err(RuntimeError::Conflict) + ); + assert_eq!( + h.runtime.prepare(CLIENT.to_owned(), AMOUNT, "other".to_owned()), + Err(RuntimeError::Conflict) + ); + assert_eq!(h.runtime.can_receive(AMOUNT), Ok(false)); + assert_eq!( + h.runtime.prepare("second".to_owned(), AMOUNT, "x".to_owned()), + Err(RuntimeError::Ineligible) + ); + + // A connected but not yet reestablished channel refuses native preparation; the request + // keeps waiting instead of failing, and nothing is released or registered natively. + h.tick(); + assert_eq!(h.status(), OfflineReceiveStatus::Preparing); + assert!(h + .runtime + .state + .lock() + .unwrap() + .requests + .lookup(CLIENT) + .unwrap() + .unwrap() + .selector() + .is_none()); + assert!(h.node.channel_manager.list_ffor_receiver_recovery_contexts().unwrap().is_empty()); + assert!(h.outbound_types().is_empty()); + assert!(h.node.event_queue.next_event().is_none()); +} + +#[test] +fn ffor_runtime_cancel_and_deadline_before_binding_are_durable_and_release_nothing() { + let h = Harness::empty(); + h.runtime.prepare(CLIENT.to_owned(), AMOUNT, "a".to_owned()).unwrap(); + h.runtime.cancel(CLIENT).unwrap(); + h.tick(); + assert_eq!(h.status(), OfflineReceiveStatus::Failed { reason: "cancelled".to_owned() }); + let storage = Arc::clone(&h.storage); + drop(h); + let restored = Harness::from_storage(storage, offline_config(300, 0)); + assert_eq!(restored.status(), OfflineReceiveStatus::Failed { reason: "cancelled".to_owned() }); + assert_eq!( + restored.runtime.prepare(CLIENT.to_owned(), AMOUNT, "a".to_owned()), + Ok(OfflineReceiveStatus::Failed { reason: "cancelled".to_owned() }) + ); + + let h = Harness::empty(); + h.runtime.prepare("late".to_owned(), AMOUNT, "a".to_owned()).unwrap(); + h.set_tip(145 - 6); + h.tick(); + assert_eq!( + h.runtime.status("late"), + Ok(OfflineReceiveStatus::Failed { reason: "deadline".to_owned() }) + ); + assert!(h.outbound_types().is_empty()); + assert!(h.node.channel_manager.list_ffor_receiver_recovery_contexts().unwrap().is_empty()); +} + +#[test] +fn ffor_runtime_recovers_active_epoch_and_reports_ready_only_after_native_release() { + let (storage, config) = prepared_active_storage(); + let h = Harness::from_storage(storage, config.clone()); + h.install_sidecar(true); + assert_eq!(h.status(), OfflineReceiveStatus::AwaitingActivation); + + // Restored Active authority requires a fresh persistence barrier; no peer is connected. + h.tick(); + assert_eq!(h.status(), OfflineReceiveStatus::AwaitingActivation); + h.persist_manager(); + h.tick(); + assert_eq!(h.stage(), Stage::Witnesses); + assert_eq!(h.status(), OfflineReceiveStatus::AwaitingWitnesses); + h.tick(); + assert_eq!(h.stage(), Stage::Invoice); + assert_eq!(h.status(), OfflineReceiveStatus::AwaitingWitnesses); + + // No signed witness route in the graph: nothing is issued. + h.tick(); + assert_eq!(h.stage(), Stage::Invoice); + assert!(h + .runtime + .state + .lock() + .unwrap() + .requests + .lookup(CLIENT) + .unwrap() + .unwrap() + .confirmed_payment() + .unwrap() + .is_none()); + h.install_route(); + h.tick(); + assert_eq!(h.status(), OfflineReceiveStatus::AwaitingWitnesses); + h.persist_manager(); + h.tick(); + let bolt11 = match h.status() { + OfflineReceiveStatus::Ready { bolt11 } => bolt11, + other => panic!("expected Ready, got {other:?}"), + }; + let invoice: Bolt11Invoice = bolt11.parse().unwrap(); + assert_eq!(invoice.amount_milli_satoshis(), Some(AMOUNT)); + assert_eq!(invoice.recover_payee_pub_key(), h.node.node_id()); + let payment_id = PaymentId(invoice.payment_hash().to_byte_array()); + let payment = h.node.payment_store.get(&payment_id).unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(h.node.event_queue.next_event().is_none()); + assert!(h.runtime.state.lock().unwrap().credits.is_empty()); + + // The Active epoch has no journal: the outcome getter credits nothing. + let context = h.node.channel_manager.list_ffor_receiver_recovery_contexts().unwrap().remove(0); + let voucher = context.setup().vouchers()[0]; + assert_eq!(voucher.payment_hash, payment_id.0); + assert_eq!( + h.node + .channel_manager + .ffor_receiver_voucher_outcome(&context, 1, PaymentHash(payment_id.0), AMOUNT) + .unwrap(), + None + ); + + // Restart: the same exact invoice is released again only after a fresh native barrier. + h.persist_manager(); + let storage = Arc::clone(&h.storage); + drop(h); + let h = Harness::from_storage(storage, config); + assert_eq!(h.stage(), Stage::Invoice); + assert_eq!(h.status(), OfflineReceiveStatus::AwaitingWitnesses); + h.install_route(); + h.tick(); + assert_eq!(h.status(), OfflineReceiveStatus::AwaitingWitnesses); + h.persist_manager(); + h.tick(); + assert_eq!(h.status(), OfflineReceiveStatus::Ready { bolt11: bolt11.clone() }); + assert_eq!(h.node.payment_store.get(&payment_id).unwrap().status, PaymentStatus::Pending); + + // Deadline margin: the runtime requests a cooperative close and stops presenting the invoice. + h.set_tip(145 - 6); + h.tick(); + assert_eq!(h.stage(), Stage::Closing(CloseReason::Deadline)); + assert_eq!(h.status(), OfflineReceiveStatus::Expired); + assert_eq!(h.runtime.can_receive(AMOUNT), Ok(false)); + assert!(h.node.event_queue.next_event().is_none()); +} + +#[test] +fn ffor_runtime_history_blocks_channel_until_every_epoch_is_terminal() { + let (storage, config) = prepared_active_storage(); + let h = Harness::from_storage(storage, config); + h.persist_manager(); + let contexts = h.node.channel_manager.list_ffor_receiver_recovery_contexts().unwrap(); + assert_eq!(contexts.len(), 1); + let channel = contexts[0].channel_id(); + let other = ChannelId([9; 32]); + // The Active epoch has no completed journal: not terminal, so the channel stays blocked + // while a different channel is unaffected. + assert!(!h.runtime.epoch_is_terminal(&contexts[0])); + assert!(history_blocks_channel(&channel, &contexts, |c| h.runtime.epoch_is_terminal(c))); + assert!(!history_blocks_channel(&other, &contexts, |_| false)); + assert!(!history_blocks_channel(&channel, &contexts, |_| true)); + assert!(history_blocks_channel(&channel, &contexts, |_| false)); + assert!(!history_blocks_channel(&channel, &[], |_| false)); + assert_eq!(h.runtime.can_receive(AMOUNT), Ok(false)); + assert_eq!( + h.runtime.prepare("next".to_owned(), AMOUNT, "x".to_owned()), + Err(RuntimeError::Ineligible) + ); +} + +#[test] +fn ffor_runtime_missing_witness_sidecar_never_regenerates_keys_or_issues() { + let (storage, config) = prepared_active_storage(); + let h = Harness::from_storage(storage, config); + h.persist_manager(); + h.tick(); + assert_eq!(h.stage(), Stage::Witnesses); + let writes = h.storage.writes.load(Ordering::SeqCst); + h.tick(); + assert!(matches!(h.stage(), Stage::Failed(reason) if reason.contains("Missing"))); + assert_eq!(h.storage.writes.load(Ordering::SeqCst), writes); + assert!(h + .runtime + .state + .lock() + .unwrap() + .requests + .lookup(CLIENT) + .unwrap() + .unwrap() + .confirmed_payment() + .unwrap() + .is_none()); +} + +#[test] +fn ffor_runtime_unknown_witness_acknowledgement_is_dropped() { + let (storage, config) = prepared_active_storage(); + let h = Harness::from_storage(storage, config); + h.connect(witness()); + let ack = Acknowledgement::new( + [7; 16], + AcknowledgementResult::Accepted { witness: witness(), retention_until: 400 }, + ) + .unwrap() + .encode(); + let message = + h.handler.read(u16::from_be_bytes([ack[0], ack[1]]), &mut &ack[2..]).unwrap().unwrap(); + h.handler.handle_custom_message(message, witness()).unwrap(); + h.tick(); + assert!(h.runtime.state.lock().unwrap().pending_acks.is_empty()); + assert!(h.runtime.transport.pop().is_none()); +} + +fn pending_row(hash: PaymentHash) -> PaymentDetails { + PaymentDetails::new( + PaymentId(hash.0), + PaymentKind::Bolt11 { hash, preimage: None, secret: None, description: None, bolt11: None }, + Some(AMOUNT), + None, + PaymentDirection::Inbound, + PaymentStatus::Pending, + ) +} + +fn intent(hash: PaymentHash) -> OutcomeIntent { + OutcomeIntent { + key: IntentKey { channel: ChannelId([3; 32]), epoch: [4; 32], slot: 1 }, + state: IntentState::Intended, + payment_hash: hash, + amount_msat: AMOUNT, + client_id: CLIENT.to_owned(), + } +} + +/// Run the complete credit sequence once; every error is returned for the caller to inject. +fn run_credit(node: &Node, ledger: &OutcomeLedger, hash: PaymentHash) -> Result<(), RuntimeError> { + let preimage = Some(PaymentPreimage([9; 32])); + if let Some((credited, event)) = + credit_intent(ledger, &node.payment_store, &node.event_queue, &intent(hash), preimage)? + { + node.runtime + .block_on(node.event_queue.add_event(event)) + .map_err(|_| RuntimeError::Ledger(super::ledger::LedgerError::Storage))?; + mark_notified(ledger, &credited)?; + } + Ok(()) +} + +fn received_events(node: &Node) -> usize { + let mut count = 0; + while let Some(event) = node.event_queue.next_event() { + assert!(matches!(event, Event::PaymentReceived { amount_msat: AMOUNT, .. })); + count += 1; + node.runtime.block_on(node.event_queue.event_handled()).unwrap(); + } + count +} + +#[test] +fn ffor_runtime_credit_sequence_survives_a_failed_write_at_every_step_and_restart() { + let hash = PaymentHash([5; 32]); + // Writes: Intended, payment row, Credited, event queue, Notified. + for step in 1..=5 { + for failure in [1usize, 2] { + let storage = TestStore::new(); + install_native(&storage, EMPTY_MANAGER, EMPTY_MONITOR); + let node = build(Arc::clone(&storage), None); + node.payment_store.insert(pending_row(hash)).unwrap(); + let backing: Arc = storage.clone(); + let ledger = OutcomeLedger::new(backing); + let base = storage.writes.load(Ordering::SeqCst); + storage.fail_at.store(base + step, Ordering::SeqCst); + storage.write_failure.store(failure, Ordering::SeqCst); + let result = run_credit(&node, &ledger, hash); + assert!(result.is_err(), "step {step} failure {failure} must surface"); + storage.fail_at.store(0, Ordering::SeqCst); + drop(node); + + // Restart reloads the payment ledger and the event queue from the same storage. + let node = build(Arc::clone(&storage), None); + let backing: Arc = storage.clone(); + let ledger = OutcomeLedger::new(backing); + run_credit(&node, &ledger, hash).unwrap(); + let writes = storage.writes.load(Ordering::SeqCst); + run_credit(&node, &ledger, hash).unwrap(); + assert_eq!(storage.writes.load(Ordering::SeqCst), writes, "third run is a no-op"); + let payment = node.payment_store.get(&PaymentId(hash.0)).unwrap(); + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert!(matches!( + payment.kind, + PaymentKind::Bolt11 { preimage: Some(preimage), .. } if preimage.0 == [9; 32] + )); + assert_eq!( + ledger.load(&intent(hash).key).unwrap().unwrap().state, + IntentState::Notified + ); + assert_eq!(received_events(&node), 1, "step {step} failure {failure}"); + assert!(ledger.unfinished().unwrap().is_empty()); + } + } +} + +#[test] +fn ffor_runtime_credit_sequence_refuses_conflicts_and_missing_rows() { + let hash = PaymentHash([6; 32]); + let storage = TestStore::new(); + install_native(&storage, EMPTY_MANAGER, EMPTY_MONITOR); + let node = build(Arc::clone(&storage), None); + let backing: Arc = storage.clone(); + let ledger = OutcomeLedger::new(backing); + assert_eq!(run_credit(&node, &ledger, hash), Err(RuntimeError::NotFound)); + assert_eq!(ledger.load(&intent(hash).key).unwrap().unwrap().state, IntentState::Intended); + node.payment_store.insert(pending_row(hash)).unwrap(); + run_credit(&node, &ledger, hash).unwrap(); + let mut other = intent(hash); + other.amount_msat += 1; + assert_eq!( + credit_intent(&ledger, &node.payment_store, &node.event_queue, &other, None).map(|_| ()), + Err(RuntimeError::Conflict) + ); + let mut regress = intent(hash); + regress.state = IntentState::Credited; + assert_eq!(ledger.write(®ress), Err(super::ledger::LedgerError::Conflict)); + assert_eq!(received_events(&node), 1); +} diff --git a/src/ffor/runtime/worker.rs b/src/ffor/runtime/worker.rs new file mode 100644 index 0000000000..093c71e59e --- /dev/null +++ b/src/ffor/runtime/worker.rs @@ -0,0 +1,650 @@ +//! The worker pass. Every step observes native progress and takes exactly one bounded action. +//! +//! No mutex is held across an await: the credit sequence's only asynchronous step, adding the +//! `PaymentReceived` event, runs after the state lock is released, and the durable `Notified` +//! marker is written afterwards under a fresh lock. + +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use lightning::ln::ffor::{ + FFORCommitmentError, FFORReceiverError, FFORReceiverProgress, FFORReceiverRecoveryContext, + FFORVoucherOutcome, FFORWitnessReceiptProgress, +}; +use lightning::ln::wire::Type; +use lightning_types::payment::{PaymentHash, PaymentPreimage}; + +use super::credit::{credit_intent, mark_notified}; +use super::ledger::{IntentKey, IntentState, OutcomeIntent}; +use super::route::witness_route_evidence; +use super::{ + CloseReason, FforReceiverRuntime, LiveRequest, RuntimeError, RuntimeState, Stage, + MAX_PENDING_ACKS, PROVISION_TIMEOUT, +}; +use crate::event::Event; +use crate::ffor::request_store::{InvoicePolicy, InvoiceProgress, RequestStoreError}; +use crate::ffor::witness_owner::{ + AcknowledgementProgress, FetchProgress, ProvisioningProgress, RegistrationProgress, + WitnessOwnerError, +}; +use crate::ffor::witness_store::WitnessStorageBinding; +use crate::logger::{log_debug, log_info, LdkLogger}; +use crate::payment::store::{PaymentDetailsUpdate, PaymentStatus}; +use crate::payment::OfflineReceiveOutcome; + +const WITNESS_ACK_TYPE: u16 = 55057; +const FETCH_RESPONSE_TYPE: u16 = 55061; +const MAX_TRANSPORT_MESSAGES_PER_PASS: usize = 64; +/// The single voucher slot of a one-amount Variant D request. +const SLOT: u16 = 1; + +enum Verdict { + Wait, + Fail(String), +} + +fn verdict_native(error: &FFORReceiverError) -> Verdict { + match error { + FFORReceiverError::ChannelState( + FFORCommitmentError::PendingUpdates | FFORCommitmentError::ChannelUnavailable, + ) + | FFORReceiverError::PersistenceUnavailable => Verdict::Wait, + other => Verdict::Fail(format!("native: {other}")), + } +} + +fn verdict_request(error: &RequestStoreError) -> Verdict { + match error { + RequestStoreError::Native(native) => verdict_native(native), + RequestStoreError::Storage + | RequestStoreError::Uncertain + | RequestStoreError::Payment(crate::data_store::ffor::FFORPaymentError::Storage) => Verdict::Wait, + other => Verdict::Fail(format!("request store: {other:?}")), + } +} + +fn verdict_witness(error: &WitnessOwnerError) -> Verdict { + match error { + WitnessOwnerError::Native(native) => verdict_native(native), + WitnessOwnerError::StaleConnection + | WitnessOwnerError::Capacity + | WitnessOwnerError::Entropy + | WitnessOwnerError::Storage(crate::ffor::witness_store::WitnessStoreError::Storage) + | WitnessOwnerError::Storage(crate::ffor::witness_store::WitnessStoreError::Uncertain) => { + Verdict::Wait + }, + other => Verdict::Fail(format!("witness owner: {other:?}")), + } +} + +fn now_secs() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +impl FforReceiverRuntime { + pub(crate) async fn tick(&self) { + let emits = { + let mut state = self.state.lock().unwrap(); + self.pass(&mut state) + }; + for (intent, event) in emits { + let queued = self.event_queue.add_event(event).await; + let mut state = self.state.lock().unwrap(); + let outcome = queued + .map_err(|_| ()) + .and_then(|()| mark_notified(&state.ledger, &intent).map_err(|_| ())); + if outcome.is_err() { + // The persisted Credited record and the queue membership check make the retry + // idempotent; keep the intent for the next pass. + self.log_error("notification", &"event queue or marker write failed"); + state.credits.push(intent); + } + } + } + + /// One synchronous pass over transport input, recovery, every live request and pending + /// credits. Returns the events whose intents reached `Credited` and are not yet queued. + pub(super) fn pass(&self, state: &mut RuntimeState) -> Vec<(OutcomeIntent, Event)> { + if let Err(error) = state.requests.recover_write() { + self.log_error("request store recovery", &error); + } + if let Err(error) = state.witnesses.recover_storage() { + self.log_error("witness store recovery", &error); + } + if !state.recovered { + if let Err(error) = self.recover_locked(state) { + self.log_error("recovery", &error); + return Vec::new(); + } + } + self.drain_transport(state); + let keys: Vec = state.live.keys().cloned().collect(); + for key in keys { + self.advance_request(state, &key); + } + self.run_credits(state) + } + + fn drain_transport(&self, state: &mut RuntimeState) { + let mut messages = std::mem::take(&mut state.pending_acks); + while messages.len() < MAX_TRANSPORT_MESSAGES_PER_PASS { + match self.transport.pop() { + Some(message) => messages.push(message), + None => break, + } + } + for message in messages { + let retain = match message.frame().type_id() { + WITNESS_ACK_TYPE => match state.witnesses.acknowledge(&message) { + Ok(AcknowledgementProgress::AwaitingPersistence) => true, + Ok(_) => false, + Err(error) => { + log_debug!(self.logger, "Witness acknowledgement not retained: {error:?}"); + matches!(verdict_witness(&error), Verdict::Wait) + && !matches!(error, WitnessOwnerError::StaleConnection) + }, + }, + FETCH_RESPONSE_TYPE => match state.witnesses.accept_fetch_page(&message) { + Ok(_) => false, + Err(error) => { + log_debug!(self.logger, "Witness fetch page not retained: {error:?}"); + matches!(verdict_witness(&error), Verdict::Wait) + && !matches!(error, WitnessOwnerError::StaleConnection) + }, + }, + _ => false, + }; + if retain && state.pending_acks.len() < MAX_PENDING_ACKS { + state.pending_acks.push(message); + } + } + } + + fn tip(&self) -> u32 { + self.manager.current_best_block().height + } + + fn past_deadline_margin(&self, live: &LiveRequest) -> bool { + let tip = self.tip(); + tip.saturating_add(self.config.deadline_safety_margin_blocks) >= live.settlement_deadline + || tip >= live.voucher_expiry + } + + fn advance_request(&self, state: &mut RuntimeState, key: &str) { + let stage = match state.live.get(key) { + Some(live) if !live.stage.is_terminal() => live.stage.clone(), + _ => return, + }; + let result = match stage { + Stage::Prepare => self.step_prepare(state, key), + Stage::Setup => self.step_setup(state, key), + Stage::Witnesses => self.step_witnesses(state, key), + Stage::Invoice => self.step_invoice(state, key), + Stage::Ready => self.step_ready(state, key), + Stage::Closing(reason) => self.step_closing(state, key, reason), + Stage::Closed(reason) => self.step_closed(state, key, reason), + Stage::Settled(_) | Stage::Expired | Stage::Failed(_) => Ok(()), + }; + let Some(live) = state.live.get_mut(key) else { return }; + match result { + Ok(()) => {}, + Err(Verdict::Wait) => {}, + Err(Verdict::Fail(reason)) => { + log_info!( + self.logger, + "Offline receive request on channel {} failed: {}", + live.channel, + reason + ); + live.stage = Stage::Failed(reason); + }, + } + } + + fn set_stage(state: &mut RuntimeState, key: &str, stage: Stage) { + if let Some(live) = state.live.get_mut(key) { + live.stage = stage; + } + } + + fn step_prepare(&self, state: &mut RuntimeState, key: &str) -> Result<(), Verdict> { + let live = state.live.get(key).ok_or(Verdict::Wait)?; + let (client_id, settlement, local_request_id) = + (live.client_id.clone(), live.settlement, live.local_request_id); + if live.cancel_requested || self.past_deadline_margin(live) { + let reason = if live.cancel_requested { "cancelled" } else { "deadline" }; + state.ledger.mark_cancelled(local_request_id).map_err(|_| Verdict::Wait)?; + return Err(Verdict::Fail(reason.to_owned())); + } + let Some(connection) = self.transport.native_connection(settlement) else { + return Err(Verdict::Wait); + }; + match state.requests.prepare_native(&client_id, connection.native()) { + Ok(id) => { + let live = state.live.get_mut(key).ok_or(Verdict::Wait)?; + live.selector = Some(id); + live.stage = Stage::Setup; + Ok(()) + }, + // Native refuses a book on a channel that is not yet reestablished or not empty with + // the same error as a genuinely invalid book. Both wait; the deadline bounds the retry. + Err(RequestStoreError::Native(FFORReceiverError::ChannelState( + FFORCommitmentError::InvalidVoucherBook, + ))) => { + log_debug!(self.logger, "Native preparation deferred: channel not ready"); + Err(Verdict::Wait) + }, + Err(error) => Err(verdict_request(&error)), + } + } + + /// Observe Active without a peer when possible; otherwise drive native setup on the live + /// settlement connection. + fn step_setup(&self, state: &mut RuntimeState, key: &str) -> Result<(), Verdict> { + let live = state.live.get(key).ok_or(Verdict::Wait)?; + let id = live.selector.ok_or(Verdict::Wait)?; + let settlement = live.settlement; + if live.cancel_requested || self.past_deadline_margin(live) { + let reason = if live.cancel_requested { "cancelled" } else { "deadline" }; + if self.transport.native_connection(settlement).is_none() { + return Err(Verdict::Wait); + } + return match self.setup.cancel(settlement, &id) { + Ok(_) => Err(Verdict::Fail(reason.to_owned())), + Err(error) => Err(verdict_native(&error)), + }; + } + if self.is_active(live) { + Self::set_stage(state, key, Stage::Witnesses); + return Ok(()); + } + if self.transport.native_connection(settlement).is_none() { + return Err(Verdict::Wait); + } + let progress = self.setup.advance(settlement, &id).map_err(|e| verdict_native(&e))?; + self.apply_progress(state, key, progress, Stage::Witnesses) + } + + fn is_active(&self, live: &LiveRequest) -> bool { + live.selector.is_some_and(|id| { + self.manager + .capture_ffor_receiver_active_context( + &live.channel, + &live.settlement, + id.epoch_id(), + ) + .is_ok() + }) + } + + /// Map an observed native progress to the stage machine. `on_active` is the stage entered + /// when native reports a durable Active epoch. + fn apply_progress( + &self, state: &mut RuntimeState, key: &str, progress: FFORReceiverProgress, + on_active: Stage, + ) -> Result<(), Verdict> { + let live = state.live.get_mut(key).ok_or(Verdict::Wait)?; + match progress { + FFORReceiverProgress::Active => { + if matches!(live.stage, Stage::Setup) { + live.stage = on_active; + } + Ok(()) + }, + FFORReceiverProgress::Draining => { + if !matches!(live.stage, Stage::Closing(_)) { + live.stage = Stage::Closing(CloseReason::Peer); + } + Err(Verdict::Wait) + }, + FFORReceiverProgress::Closed => { + let reason = match live.stage { + Stage::Closing(reason) => reason, + _ => CloseReason::Peer, + }; + live.stage = Stage::Closed(reason); + Ok(()) + }, + FFORReceiverProgress::Aborted { reason } => Err(Verdict::Fail(format!("{reason:?}"))), + FFORReceiverProgress::ReconnectRequired | FFORReceiverProgress::ResolutionRequired => { + if !matches!(live.stage, Stage::Setup) { + live.needs_receipts = true; + } + Err(Verdict::Wait) + }, + FFORReceiverProgress::AwaitingPersistence + | FFORReceiverProgress::Backpressured + | FFORReceiverProgress::AwaitingPeer + | FFORReceiverProgress::AwaitingVoucherCommitments + | FFORReceiverProgress::NeedsMonitorSnapshot => Err(Verdict::Wait), + } + } + + fn context(&self, live: &LiveRequest) -> Result { + let id = live.selector.ok_or(Verdict::Wait)?; + self.manager + .ffor_receiver_recovery_context(&live.channel, id.epoch_id()) + .map_err(|e| verdict_native(&e)) + } + + /// Shared liveness for post-activation stages: apply cancellation and deadlines, observe an + /// unexpected close or a reconnect requirement, and recover receipts when flagged. + fn post_activation_probe(&self, state: &mut RuntimeState, key: &str) -> Result<(), Verdict> { + let live = state.live.get(key).ok_or(Verdict::Wait)?; + let id = live.selector.ok_or(Verdict::Wait)?; + let settlement = live.settlement; + if live.cancel_requested { + Self::set_stage(state, key, Stage::Closing(CloseReason::Cancelled)); + return Err(Verdict::Wait); + } + if self.past_deadline_margin(live) { + Self::set_stage(state, key, Stage::Closing(CloseReason::Deadline)); + return Err(Verdict::Wait); + } + if live.needs_receipts { + let context = self.context(live)?; + self.recover_receipts(state, key, &context); + } + if !self.is_active(state.live.get(key).ok_or(Verdict::Wait)?) + && self.transport.native_connection(settlement).is_some() + { + let progress = self.setup.advance(settlement, &id).map_err(|e| verdict_native(&e))?; + self.apply_progress(state, key, progress, Stage::Witnesses)?; + let live = state.live.get(key).ok_or(Verdict::Wait)?; + if matches!(live.stage, Stage::Closed(_) | Stage::Closing(_)) { + return Err(Verdict::Wait); + } + } + Ok(()) + } + + fn step_witnesses(&self, state: &mut RuntimeState, key: &str) -> Result<(), Verdict> { + self.post_activation_probe(state, key)?; + let live = state.live.get(key).ok_or(Verdict::Wait)?; + let context = self.context(live)?; + let policies = self.witness_policies(live.voucher_expiry); + match state.witnesses.register(&context, &policies) { + Ok(RegistrationProgress::Retained) => {}, + Ok(RegistrationProgress::AwaitingPersistence) => return Err(Verdict::Wait), + Err(error) => return Err(verdict_witness(&error)), + } + let mut retained = 0usize; + for witness in self.witness_ids() { + let started = + state.live.get(key).and_then(|live| live.provision_started.get(&witness).copied()); + let timed_out = started.is_some_and(|since| since.elapsed() >= PROVISION_TIMEOUT); + let progress = if timed_out { + state.witnesses.retry_provision(&context, witness) + } else { + state.witnesses.provision(&context, witness) + }; + match progress { + Ok(ProvisioningProgress::AcknowledgementRetained) => retained += 1, + Ok(ProvisioningProgress::Queued) => { + if let Some(live) = state.live.get_mut(key) { + live.provision_started.insert(witness, Instant::now()); + } + }, + Ok( + ProvisioningProgress::AwaitingAcknowledgement + | ProvisioningProgress::Backpressured + | ProvisioningProgress::AwaitingPersistence, + ) => {}, + Err(WitnessOwnerError::StaleConnection) => { + log_debug!(self.logger, "Witness {} is not connected", witness); + }, + Err(error) => return Err(verdict_witness(&error)), + } + } + if retained == self.config.witnesses.len() { + Self::set_stage(state, key, Stage::Invoice); + } + Ok(()) + } + + fn invoice_policy(&self) -> InvoicePolicy { + InvoicePolicy { + expiry_seconds: self.config.invoice_expiry_seconds, + safety_margin_seconds: self.config.invoice_safety_margin_seconds, + } + } + + fn step_invoice(&self, state: &mut RuntimeState, key: &str) -> Result<(), Verdict> { + self.post_activation_probe(state, key)?; + let live = state.live.get(key).ok_or(Verdict::Wait)?; + let client_id = live.client_id.clone(); + let settlement = live.settlement; + let Some(route) = self + .witness_ids() + .into_iter() + .find_map(|witness| witness_route_evidence(&self.graph, witness, settlement)) + else { + log_debug!(self.logger, "No signed witness route evidence in the network graph yet"); + return Err(Verdict::Wait); + }; + match state.requests.issue_invoice(&client_id, self.invoice_policy(), &route) { + Ok(InvoiceProgress::Retained) => {}, + Ok(InvoiceProgress::AwaitingPersistence) => return Err(Verdict::Wait), + Err(error) => return Err(verdict_request(&error)), + } + let handle = match state.requests.confirmed_invoice(&client_id) { + Ok(Some(handle)) => handle, + Ok(None) => return Err(Verdict::Wait), + Err(error) => return Err(verdict_request(&error)), + }; + let mut slot = None; + match state.requests.release_invoice(&handle, &mut slot) { + Ok(true) => {}, + Ok(false) => return Err(Verdict::Wait), + Err(error) => return Err(verdict_request(&error)), + } + let expires_at = state + .requests + .lookup(&client_id) + .map_err(|e| verdict_request(&e))? + .ok_or(Verdict::Wait)? + .invoice_expires_at() + .map_err(|e| verdict_request(&e))?; + let live = state.live.get_mut(key).ok_or(Verdict::Wait)?; + live.bolt11 = slot; + live.invoice_expires_at = expires_at; + live.stage = Stage::Ready; + log_info!(self.logger, "Offline receive invoice ready on channel {}", live.channel); + Ok(()) + } + + fn step_ready(&self, state: &mut RuntimeState, key: &str) -> Result<(), Verdict> { + self.post_activation_probe(state, key)?; + let live = state.live.get(key).ok_or(Verdict::Wait)?; + if live.invoice_expires_at.is_some_and(|expires| now_secs() >= expires) { + Self::set_stage(state, key, Stage::Closing(CloseReason::InvoiceExpired)); + } + Ok(()) + } + + fn step_closing( + &self, state: &mut RuntimeState, key: &str, reason: CloseReason, + ) -> Result<(), Verdict> { + let live = state.live.get(key).ok_or(Verdict::Wait)?; + let id = live.selector.ok_or(Verdict::Wait)?; + let settlement = live.settlement; + if live.needs_receipts { + let context = self.context(live)?; + self.recover_receipts(state, key, &context); + } + if self.transport.native_connection(settlement).is_none() { + return Err(Verdict::Wait); + } + if reason != CloseReason::Peer { + match self.setup.close(settlement, &id) { + Ok(_) => {}, + Err(FFORReceiverError::ChannelState(FFORCommitmentError::PendingUpdates)) => {}, + Err(error) => match verdict_native(&error) { + Verdict::Wait => return Err(Verdict::Wait), + // A close request refused after Draining or Closed is observed by advance. + Verdict::Fail(_) => {}, + }, + } + } + let progress = self.setup.advance(settlement, &id).map_err(|e| verdict_native(&e))?; + self.apply_progress(state, key, progress, Stage::Closing(reason)) + } + + /// After Closed, join the single slot's journal outcome. Only `Fulfilled` credits, and only + /// through the durable intent record. + fn step_closed( + &self, state: &mut RuntimeState, key: &str, reason: CloseReason, + ) -> Result<(), Verdict> { + let live = state.live.get(key).ok_or(Verdict::Wait)?; + let client_id = live.client_id.clone(); + let context = self.context(live)?; + let record = state + .requests + .lookup(&client_id) + .map_err(|e| verdict_request(&e))? + .ok_or(Verdict::Wait)?; + let Some(payment) = record.confirmed_payment().map_err(|e| verdict_request(&e))? else { + Self::set_stage(state, key, Stage::Expired); + return Ok(()); + }; + let voucher = context.setup().vouchers().first().ok_or(Verdict::Wait)?; + let hash = PaymentHash(voucher.payment_hash); + if payment.id.0 != hash.0 || payment.amount_msat != Some(voucher.amount_msat) { + return Err(Verdict::Fail("voucher does not match the confirmed payment".to_owned())); + } + let outcome = match self.manager.ffor_receiver_voucher_outcome( + &context, + SLOT, + hash, + voucher.amount_msat, + ) { + Ok(outcome) => outcome, + Err(error) => return Err(verdict_native(&error)), + }; + let stage = match outcome { + None => Stage::Expired, + Some(FFORVoucherOutcome::Fulfilled) => { + let intent = OutcomeIntent { + key: IntentKey { + channel: context.channel_id(), + epoch: context.epoch_id(), + slot: SLOT, + }, + state: IntentState::Intended, + payment_hash: hash, + amount_msat: voucher.amount_msat, + client_id: client_id.clone(), + }; + if !state.credits.iter().any(|existing| existing.key == intent.key) { + state.credits.push(intent); + } + Stage::Settled(OfflineReceiveOutcome::Fulfilled) + }, + Some(FFORVoucherOutcome::Failed) => { + let update = PaymentDetailsUpdate { + status: Some(PaymentStatus::Failed), + ..PaymentDetailsUpdate::new(payment.id) + }; + if self.payments.update(&update).is_err() { + return Err(Verdict::Wait); + } + Stage::Settled(OfflineReceiveOutcome::Failed) + }, + }; + log_info!( + self.logger, + "Offline receive epoch on channel {} closed ({:?}) with outcome {:?}", + context.channel_id(), + reason, + stage + ); + Self::set_stage(state, key, stage); + Ok(()) + } + + /// Fetch each witness's evidence and import the slot receipt into the original monitor. + /// Nothing here credits a payment. + fn recover_receipts( + &self, state: &mut RuntimeState, key: &str, context: &FFORReceiverRecoveryContext, + ) { + let mut complete = true; + for witness in self.witness_ids() { + let progress = state.witnesses.fetch(context, witness); + let progress = match progress { + Ok(FetchProgress::RestartRequired | FetchProgress::RejectedPage) => { + state.witnesses.retry_fetch(context, witness) + }, + other => other, + }; + match progress { + Ok(FetchProgress::Complete) => { + match state.witnesses.recover_receipt(context, witness, SLOT) { + Ok(Some(FFORWitnessReceiptProgress::MonitorPersisted { .. })) + | Ok(None) => {}, + Ok(Some(FFORWitnessReceiptProgress::PendingMonitor { .. })) => { + complete = false; + }, + Err(error) => { + complete = false; + log_debug!(self.logger, "Receipt import deferred: {error:?}"); + }, + } + }, + Ok(_) => complete = false, + Err(error) => { + complete = false; + log_debug!(self.logger, "Witness fetch deferred: {error:?}"); + }, + } + } + if complete { + if let Some(live) = state.live.get_mut(key) { + live.needs_receipts = false; + } + } + } + + /// The preimage from a retained, authenticated witness receipt when one exists. Absence is + /// never treated as evidence and never blocks the credit. + fn known_preimage( + &self, state: &RuntimeState, intent: &OutcomeIntent, + ) -> Option { + let context = self + .manager + .ffor_receiver_recovery_context(&intent.key.channel, intent.key.epoch) + .ok()?; + let binding = WitnessStorageBinding::from_native_context(&context).ok()?; + self.witness_ids().into_iter().find_map(|witness| { + let receipt = + state.witness_store.load_receipt(&binding, witness, intent.key.slot).ok()??; + let body = receipt.body(); + (body.payment_hash() == intent.payment_hash.0 + && body.amount_msat() == intent.amount_msat) + .then(|| PaymentPreimage(body.preimage())) + }) + } + + /// Run every pending intent through `Intended -> Credited`, returning the events that must + /// be queued before `Notified`. Intents already queued are marked `Notified` here. + fn run_credits(&self, state: &mut RuntimeState) -> Vec<(OutcomeIntent, Event)> { + let pending = std::mem::take(&mut state.credits); + let mut emits = Vec::new(); + for intent in pending { + match self.credit(state, &intent) { + Ok(Some((credited, event))) => emits.push((credited, event)), + Ok(None) => {}, + Err(error) => { + self.log_error("credit", &error); + state.credits.push(intent); + }, + } + } + emits + } + + fn credit( + &self, state: &RuntimeState, intent: &OutcomeIntent, + ) -> Result, RuntimeError> { + let preimage = self.known_preimage(state, intent); + credit_intent(&state.ledger, &self.payments, &self.event_queue, intent, preimage) + } +} diff --git a/src/ffor/witness_owner.rs b/src/ffor/witness_owner.rs index 37cbfc5cb4..7c6115a633 100644 --- a/src/ffor/witness_owner.rs +++ b/src/ffor/witness_owner.rs @@ -1,6 +1,6 @@ //! Private orchestration of native-authorized witness provisioning and durable acknowledgement. //! -//! No builder constructs this owner. Methods require exclusive access, while the protected store +//! Only the opt-in runtime constructs this owner. Methods require exclusive access, while the protected store //! remains the single persistence owner. Native registration and release own phase authority; //! this module retains only bounded transient response correlation. Historical acknowledgements //! authorize neither an invoice nor a native transition. Transport callbacks never call the store. @@ -22,6 +22,8 @@ mod fetch; mod pending; mod provisioning; mod recovery; +pub(crate) use fetch::FetchProgress; +pub(crate) use provisioning::{ProvisioningProgress, RegistrationProgress}; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum WitnessOwnerError { diff --git a/src/ffor/witness_store.rs b/src/ffor/witness_store.rs index ecf40f0220..b4d1070131 100644 --- a/src/ffor/witness_store.rs +++ b/src/ffor/witness_store.rs @@ -42,6 +42,8 @@ pub(super) use binding::WitnessStorageBinding; #[cfg(test)] use binding::WitnessStorageIdentity; use envelope::WrappingKey; +#[cfg(test)] +pub(super) use record::FixtureWitness; pub(super) use record::{StoredWitnessEpoch, WitnessKeyUseError, WitnessPolicy}; const PRIMARY_NAMESPACE: &str = "ffor_witness"; @@ -210,7 +212,29 @@ impl WitnessSecretStore { Ok(_) => return Err(WitnessStoreError::Conflict), Err(error) => return Err(error), } - let mut record = StoredWitnessEpoch::generate(binding, &policies)?; + let record = StoredWitnessEpoch::generate(binding, &policies)?; + self.install_locked(&mut state, binding, record) + } + + /// Test-only: install a record built from public fixture secrets through the exact + /// production write sequence. Never available outside tests. + #[cfg(test)] + pub(in crate::ffor) fn install_fixture( + &self, binding: &WitnessStorageBinding, record: StoredWitnessEpoch, + ) -> Result { + let mut state = self.state.lock().map_err(|_| WitnessStoreError::Uncertain)?; + ensure_certain(&state)?; + if state.records.contains_key(&binding.storage_key()) { + return Err(WitnessStoreError::Conflict); + } + self.install_locked(&mut state, binding, record) + } + + fn install_locked( + &self, state: &mut StoreState, binding: &WitnessStorageBinding, + mut record: StoredWitnessEpoch, + ) -> Result { + let key = binding.storage_key(); let receipt = self .receipt_key .seal_plaintext(binding, &ReceiptBook::empty(binding, &record).encode())?; @@ -231,9 +255,9 @@ impl WitnessSecretStore { return Err(WitnessStoreError::Capacity); } state.receipt_reservations.insert(key.clone(), receipt.len()); - self.confirm_write(&mut state, Namespace::Secrets, key.clone(), bytes)?; - self.confirm_write(&mut state, Namespace::Receipts, key, receipt)?; - self.finish_initialization(&mut state, binding, &mut record)?; + self.confirm_write(state, Namespace::Secrets, key.clone(), bytes)?; + self.confirm_write(state, Namespace::Receipts, key, receipt)?; + self.finish_initialization(state, binding, &mut record)?; Ok(record) } diff --git a/src/ffor/witness_store/record.rs b/src/ffor/witness_store/record.rs index 45877e6b4b..9b2cf3f2f5 100644 --- a/src/ffor/witness_store/record.rs +++ b/src/ffor/witness_store/record.rs @@ -36,6 +36,15 @@ pub(crate) struct WitnessPolicy { pub(in crate::ffor) minimum_receipts: u8, } +/// Test-only fixture input for [`StoredWitnessEpoch::from_fixture`]. +#[cfg(test)] +pub(in crate::ffor) struct FixtureWitness { + pub(in crate::ffor) witness: PublicKey, + pub(in crate::ffor) fetch_secret: [u8; 32], + pub(in crate::ffor) manifest: Vec, + pub(in crate::ffor) acknowledged: bool, +} + struct WitnessKeys { policy: WitnessPolicy, fetch_secret: Zeroizing<[u8; 32]>, @@ -143,6 +152,42 @@ impl StoredWitnessEpoch { }) } + /// Test-only: rebuild a record from public fixture secrets and the exact signed manifest. + #[cfg(test)] + pub(in crate::ffor) fn from_fixture( + binding: &WitnessStorageBinding, encryption_secret: [u8; 32], + witnesses: Vec, + ) -> Result { + let mut entries = Vec::with_capacity(witnesses.len()); + for fixture in witnesses { + let manifest = SignedManifest::decode(&fixture.manifest, binding.setup()) + .map_err(|_| WitnessStoreError::Corrupt)?; + let params = manifest.unsigned().parameters(); + let policy = WitnessPolicy { + witness: fixture.witness, + retention_until: params.retention_until, + minimum_receipts: params.minimum_receipts, + }; + let acknowledgement = fixture.acknowledged.then_some(RetainedAcknowledgement { + request_id: [1; 16], + retention_until: params.retention_until, + }); + entries.push(WitnessKeys { + policy, + fetch_secret: Zeroizing::new(fixture.fetch_secret), + manifest, + acknowledgement, + }); + } + entries.sort_by_key(|entry| entry.policy.witness); + Ok(Self { + binding_digest: binding.digest(), + encryption_secret: Zeroizing::new(encryption_secret), + witnesses: entries, + receipt_allocation: ReceiptAllocation::Legacy, + }) + } + pub(super) fn manifest(&self, witness: PublicKey) -> Option<&SignedManifest> { self.witnesses .iter() diff --git a/src/lib.rs b/src/lib.rs index 865f491166..75a233c79e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -127,6 +127,8 @@ use config::{ ChannelConfig, Config, NODE_ANN_BCAST_INTERVAL, PEER_RECONNECTION_INTERVAL, RGS_SYNC_INTERVAL, }; pub use config::{AddressType, OnchainWalletAccount, OnchainWalletAccountConfig}; +#[cfg(feature = "uniffi")] +use config::{OfflineReceiveConfig, OfflineReceiveWitnessConfig}; use connection::ConnectionManager; pub use error::Error as NodeError; use error::Error; @@ -158,9 +160,11 @@ use payment::asynchronous::om_mailbox::OnionMessageMailbox; use payment::asynchronous::static_invoice_store::StaticInvoiceStore; pub use payment::{AddressInfo, KeychainKind}; use payment::{ - Bolt11Payment, Bolt12Payment, OnchainPayment, PaymentDetails, SpontaneousPayment, - UnifiedQrPayment, + Bolt11Payment, Bolt12Payment, OfflineReceivePayment, OnchainPayment, PaymentDetails, + SpontaneousPayment, UnifiedQrPayment, }; +#[cfg(feature = "uniffi")] +use payment::{OfflineReceiveOutcome, OfflineReceiveStatus}; use peer_store::{persist_missing_channel_peers_excluding, PeerInfo, PeerStore}; pub use probe_handle::ProbeHandle; use rand::Rng; @@ -220,6 +224,7 @@ pub struct Node { background_processor_generation: Arc, node_metrics: Arc>, om_mailbox: Option>, + offline_receive: Option>, async_payments_role: Option, runtime_sync_intervals: Arc>, /// Shared RGS timestamp used by LocalGraphStore to persist the timestamp alongside the graph. @@ -779,6 +784,15 @@ impl Node { }); } + if let Some(offline_receive) = self.offline_receive.as_ref() { + // Recovery failure is logged and retried by the worker; the API reports it as + // unavailable until it completes. + if let Err(e) = offline_receive.recover() { + log_error!(self.logger, "Offline receive recovery deferred: {:?}", e); + } + offline_receive.spawn_worker(&self.runtime, self.stop_sender.subscribe()); + } + log_info!(self.logger, "Startup complete."); *self.is_running.write().unwrap() = true; if self.background_processor_failed.load(Ordering::Acquire) { @@ -1052,6 +1066,27 @@ impl Node { )) } + /// Returns a handler for experimental offline-receive requests. + /// + /// Every method fails with [`Error::OfflineReceiveDisabled`] unless the builder's + /// `set_offline_receive_config` was called. + #[cfg(not(feature = "uniffi"))] + pub fn offline_receive(&self) -> OfflineReceivePayment { + OfflineReceivePayment::new(self.offline_receive.clone(), Arc::clone(&self.is_running)) + } + + /// Returns a handler for experimental offline-receive requests. + /// + /// Every method fails with [`Error::OfflineReceiveDisabled`] unless the builder's + /// `set_offline_receive_config` was called. + #[cfg(feature = "uniffi")] + pub fn offline_receive(&self) -> Arc { + Arc::new(OfflineReceivePayment::new( + self.offline_receive.clone(), + Arc::clone(&self.is_running), + )) + } + /// Returns a payment handler allowing to create and pay [BOLT 12] offers and refunds. /// /// [BOLT 12]: https://github.com/lightning/bolts/blob/master/12-offer-encoding.md diff --git a/src/message_handler.rs b/src/message_handler.rs index 1e38e3f447..03fb24b26f 100644 --- a/src/message_handler.rs +++ b/src/message_handler.rs @@ -5,7 +5,8 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -//! Compose LSPS with an optional bounded FFOR transport. The builder leaves FFOR disabled. +//! Compose LSPS with an optional bounded FFOR transport, installed only when the builder's +//! offline-receive configuration is set. pub(crate) mod ffor; #[cfg(test)] @@ -86,7 +87,6 @@ where Self { liquidity: None, ffor: None, ffor_setup: None } } - // No production caller enables this seam until a native receiver owns admission and recovery. #[allow(dead_code)] pub(crate) fn with_ffor_receiver(mut self, receiver: Arc) -> Self { self.ffor = Some(receiver); @@ -94,8 +94,7 @@ where self } - // Explicit opt-in only. There is no builder setting, feature bit or invoice-facing API. - #[allow(dead_code)] + // Explicit opt-in only through the builder; there is no feature bit. pub(crate) fn with_ffor_setup(mut self, setup: Arc) -> Self { self.ffor = Some(Arc::clone(setup.transport())); self.ffor_setup = Some(setup); diff --git a/src/message_handler/ffor.rs b/src/message_handler/ffor.rs index 08ba09a9ec..fe0920ac2d 100644 --- a/src/message_handler/ffor.rs +++ b/src/message_handler/ffor.rs @@ -7,8 +7,8 @@ //! native lifecycle callers must first authorize and durably retain the exact bytes. Witness callers //! must retain their manifests and protected keys; provisioning additionally needs current native //! authority. Fetching historical evidence establishes no current activation or payment authority. -//! No protocol transition or signature generation exists here, and the production builder leaves -//! this transport disabled. +//! No protocol transition or signature generation exists here; the builder installs this +//! transport only with the builder's offline-receive configuration. pub(super) mod setup; use std::collections::{HashMap, VecDeque}; @@ -24,7 +24,6 @@ use lightning::util::logger::Level; use lightning::util::ser::{LengthLimitedRead, Writeable, Writer}; use lightning_ffor::wire::{Message, MAX_MESSAGE_LEN}; use lightning_ffor::witness::{Acknowledgement, FetchResponse, Provision, SignedFetch}; -#[cfg(test)] pub(crate) use setup::FforSetupAdapter; const MAX_PEERS: usize = 64; diff --git a/src/message_handler/ffor/setup.rs b/src/message_handler/ffor/setup.rs index db08e5c05f..f38f2f1308 100644 --- a/src/message_handler/ffor/setup.rs +++ b/src/message_handler/ffor/setup.rs @@ -1,5 +1,6 @@ //! Private receiver bridge. The concrete native manager owns lifecycle and persistence authority. -//! No production builder constructs this bridge, and progress grants no invoice authority. +//! The builder installs this bridge only with its offline-receive configuration; progress grants no +//! invoice authority. use std::sync::Arc; @@ -20,7 +21,6 @@ pub(crate) struct FforSetupAdapter { transport: Arc, } -// The production builder deliberately does not install this experimental adapter yet. #[allow(dead_code)] impl FforSetupAdapter { pub(crate) fn new( @@ -30,7 +30,7 @@ impl FforSetupAdapter { Self { manager, monitor, transport } } - pub(in crate::message_handler) fn transport(&self) -> &Arc { + pub(crate) fn transport(&self) -> &Arc { &self.transport } diff --git a/src/payment/mod.rs b/src/payment/mod.rs index ba59aea06a..99997641bf 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod asynchronous; mod bolt11; mod bolt12; +mod offline_receive; mod onchain; mod spontaneous; pub(crate) mod store; @@ -17,6 +18,7 @@ mod unified_qr; pub use bolt11::Bolt11Payment; pub use bolt12::Bolt12Payment; +pub use offline_receive::{OfflineReceiveOutcome, OfflineReceivePayment, OfflineReceiveStatus}; pub use onchain::{AddressInfo, KeychainKind, OnchainPayment}; pub use spontaneous::SpontaneousPayment; pub use store::{ diff --git a/src/payment/offline_receive.rs b/src/payment/offline_receive.rs new file mode 100644 index 0000000000..38d522bba5 --- /dev/null +++ b/src/payment/offline_receive.rs @@ -0,0 +1,111 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! Experimental offline-receive (FFOR) requests. +//! +//! Every method returns [`Error::OfflineReceiveDisabled`] unless the builder's +//! `set_offline_receive_config` was called before the node was built. Readiness is reported +//! only after native durable readiness and publication in this process; ordinary invoices are +//! never presented as offline-capable. + +use std::sync::{Arc, RwLock}; + +use crate::error::Error; +use crate::ffor::runtime::FforReceiverRuntime; + +/// Terminal outcome of a closed offline-receive epoch, reported from the native journal. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OfflineReceiveOutcome { + /// The voucher was fulfilled and the payment was credited exactly once. + Fulfilled, + /// The voucher was removed as a failure; nothing was credited. + Failed, +} + +/// Progress of one offline-receive request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OfflineReceiveStatus { + /// The durable intent exists; native preparation has not completed. + Preparing, + /// Native setup is in progress with the settlement peer. + AwaitingActivation, + /// The epoch is active; witness acknowledgements or route evidence are outstanding. + AwaitingWitnesses, + /// The exact invoice was retained, confirmed and released in this process. + Ready { + /// The BOLT 11 invoice to present to the payer. + bolt11: String, + }, + /// The invoice or settlement window expired without a fulfilled voucher. + Expired, + /// The epoch closed with a journaled outcome. + Settled { + /// The journaled outcome. + outcome: OfflineReceiveOutcome, + }, + /// The request failed permanently. + Failed { + /// A short reason. + reason: String, + }, +} + +/// A handler for experimental offline-receive requests. +/// +/// Should be retrieved by calling [`Node::offline_receive`]. +/// +/// [`Node::offline_receive`]: crate::Node::offline_receive +pub struct OfflineReceivePayment { + runtime: Option>, + is_running: Arc>, +} + +impl OfflineReceivePayment { + pub(crate) fn new( + runtime: Option>, is_running: Arc>, + ) -> Self { + Self { runtime, is_running } + } + + fn runtime(&self) -> Result<&Arc, Error> { + self.runtime.as_ref().ok_or(Error::OfflineReceiveDisabled) + } + + fn running_runtime(&self) -> Result<&Arc, Error> { + let runtime = self.runtime()?; + if !*self.is_running.read().unwrap() { + return Err(Error::NotRunning); + } + Ok(runtime) + } + + /// Whether an exact `amount_msat` can be received offline right now: the runtime recovered, + /// one ready channel with the configured settlement peer has enough inbound capacity, and no + /// other request or historical epoch occupies that channel. + pub fn can_receive(&self, amount_msat: u64) -> Result { + Ok(self.running_runtime()?.can_receive(amount_msat)?) + } + + /// Begin or resume the request identified by `request_id`. Idempotent for identical + /// arguments; different arguments for a known ID are refused. + pub fn prepare( + &self, request_id: String, amount_msat: u64, description: String, + ) -> Result { + Ok(self.running_runtime()?.prepare(request_id, amount_msat, description)?) + } + + /// Current status. `Ready` carries the invoice only after it was released in this process. + pub fn status(&self, request_id: String) -> Result { + Ok(self.runtime()?.status(&request_id)?) + } + + /// Cancel a request. Before activation the native setup is cancelled; afterwards the epoch + /// is closed cooperatively and its outcome is still joined. + pub fn cancel(&self, request_id: String) -> Result<(), Error> { + Ok(self.running_runtime()?.cancel(&request_id)?) + } +}