diff --git a/Cargo.lock b/Cargo.lock index 5be41f2822..2f22867d58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1924,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", @@ -1939,6 +1940,7 @@ dependencies = [ "rand 0.9.5", "regex", "reqwest 0.12.28", + "ring", "rusqlite", "rustls 0.23.43", "serde", @@ -1987,24 +1989,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=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" 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=996f5b000d575c8d54e627acc8b70810293011e8)", "regex", ] @@ -2024,8 +2027,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=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bitcoin", "bitcoin-io", @@ -2033,7 +2035,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=996f5b000d575c8d54e627acc8b70810293011e8)", ] [[package]] @@ -2052,8 +2054,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=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bitcoin", "chunked_transfer", @@ -2062,6 +2063,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "lightning-ffor" +version = "0.1.0" +source = "git+https://github.com/synonymdev/rust-lightning?rev=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" +dependencies = [ + "bitcoin", +] + [[package]] name = "lightning-invoice" version = "0.33.3" @@ -2077,8 +2086,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=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bech32", "bitcoin", @@ -2104,8 +2112,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=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bitcoin", "chrono", @@ -2131,8 +2138,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=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "proc-macro2", "quote", @@ -2153,8 +2159,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=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2175,8 +2180,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=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bitcoin", "lightning 0.2.5", @@ -2199,8 +2203,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=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bitcoin", "bitcoin-io", @@ -2225,8 +2228,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=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bitcoin", "electrum-client 0.24.1", @@ -2248,8 +2250,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=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" dependencies = [ "bitcoin", ] @@ -2440,6 +2441,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" @@ -2665,6 +2674,14 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "possiblyrandom" +version = "0.2.1" +source = "git+https://github.com/synonymdev/rust-lightning?rev=996f5b000d575c8d54e627acc8b70810293011e8#996f5b000d575c8d54e627acc8b70810293011e8" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -3714,7 +3731,7 @@ 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", diff --git a/Cargo.toml b/Cargo.toml index aff8af778d..1bf27925c6 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,17 +33,19 @@ panic = 'abort' # Abort on panic default = [] [dependencies] -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 = "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"]} @@ -79,8 +81,9 @@ 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 = "996f5b000d575c8d54e627acc8b70810293011e8", 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 new file mode 100644 index 0000000000..c785ed5f9f --- /dev/null +++ b/FFOR.md @@ -0,0 +1,491 @@ +# 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 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 +and recovery are implemented together. + +## 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, witness acknowledgement type 55057 and fetch response type +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. +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 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. 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 +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 combined queues +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. 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 +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, 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. + +## 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. +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 +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 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 +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. +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. 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. + +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. 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 +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 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, +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. 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. + +## Private witness owner + +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 +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 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 +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. 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. + +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, 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. 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 +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. The opt-in runtime schedules this recovery and joins settlement outcomes only +from the native journal after Closed. + +## 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. 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. + +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; 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 +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, 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. 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 + +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/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/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/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 new file mode 100644 index 0000000000..d804af6436 --- /dev/null +++ b/src/ffor/mod.rs @@ -0,0 +1,16 @@ +//! Private witness recovery storage and bounded orchestration, not invoice authority. +//! +//! 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)] +mod witness_store; + +#[allow(dead_code)] +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 new file mode 100644 index 0000000000..ce2ea43d73 --- /dev/null +++ b/src/ffor/request_store.rs @@ -0,0 +1,330 @@ +//! 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. 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, +//! 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 +//! 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. +//! +//! 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}; +use bitcoin::secp256k1::PublicKey; +use lightning::ln::ffor::FFORReceiverError; +use lightning::util::persist::KVStoreSync; + +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 invoice::InvoiceProgress; +pub(super) use record::invoice::InvoicePolicy; +pub(super) use record::{RequestIntent, RequestPlan, StoredRequest}; + +const NAMESPACE: &str = "ffor_requests"; +const LEGACY_VERSION: u16 = 1; +const VERSION: u16 = 2; +const MAX_REQUESTS: usize = 64; +// 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)] +pub(crate) enum RequestStoreError { + InvalidIntent, + Conflict, + Missing, + MissingNative, + Corrupt, + Identity, + Capacity, + Entropy, + Storage, + Uncertain, + Unreserved, + Native(FFORReceiverError), + Payment(FFORPaymentError), +} + +struct PendingWrite { + key: String, + bytes: Vec, + 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], + node: PublicKey, + records: BTreeMap>, + confirmed: BTreeSet, + uncertain: Option, + uncertain_payment: Option, + instance: u64, +} + +impl RequestStore { + pub(in crate::ffor) fn open_bound( + seed: &[u8; 64], chain: [u8; 32], node: PublicKey, manager: Arc, + payments: 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)?; + 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 + || records.insert(key, bytes).is_some() + { + return Err(RequestStoreError::Corrupt); + } + } + Ok(Self { + manager, + payments, + storage, + envelope, + chain, + node, + records, + confirmed: BTreeSet::new(), + uncertain: None, + uncertain_payment: None, + instance: INSTANCES.fetch_add(1, Ordering::SeqCst), + }) + } + + 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)?; + 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()) + || 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()); + record.validate_identity(self.chain, self.node)?; + let bytes = self.envelope.seal(&key, record)?; + 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() + { + return Err(RequestStoreError::Capacity); + } + 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 + .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. + /// The same applies to an ambiguous exact Pending payment confirmation. + pub(super) fn recover_write(&mut self) -> Result<(), RequestStoreError> { + 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)) + }, + } + } + + fn ensure_certain(&self) -> Result<(), RequestStoreError> { + if self.uncertain.is_some() || self.uncertain_payment.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..d493bed804 --- /dev/null +++ b/src/ffor/request_store/envelope.rs @@ -0,0 +1,102 @@ +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, LEGACY_VERSION, 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(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) + } + + 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(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); + } + 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(), + &LEGACY_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 0000000000..d3ab4c16a0 Binary files /dev/null and b/src/ffor/request_store/fixtures/empty-manager.bin differ 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 0000000000..567667d409 Binary files /dev/null and b/src/ffor/request_store/fixtures/empty-monitor.bin differ diff --git a/src/ffor/request_store/fixtures/fixture.txt b/src/ffor/request_store/fixtures/fixture.txt new file mode 100644 index 0000000000..d0ed1e076a --- /dev/null +++ b/src/ffor/request_store/fixtures/fixture.txt @@ -0,0 +1,22 @@ +network=testnet +wallet_seed=5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b +channel_id=ea6bf814aca4995e5f93bd4c0b94110bd41dd3b5b406649b685973d40807fd9e +epoch_id=8b5a2bf651434435c61eab2e37d4ec0c234f12c54946d0ff14094ea2ab62edc5 +receiver=02aa9e69b48a9595b89706a0e44a66c5526ffa70897b2986e2e27e310dcf0f5ac7 +settlement=027f921585f2ac0c7c70e36110adecfd8fd14b8a99bfb3d000a283fcac358fce88 +funding_txid=9efd0708d47359689b6406b4b5d31dd40b11940b4cbd935f5e99a4ac14f86bea +funding_vout=0 +height=10 +client_request_id=request-fixture +local_request_id=9c51c5a1f066274f1ef4cbce6404ce90df2328f7aa4d689079244ee2067a10ef +amounts_msat=2000000 +minimum_payment_msat=2000000 +settlement_deadline=110 +voucher_expiry=154 +fee_base_msat=0 +fee_proportional_millionths=0 +claim_margin_blocks=20 +witness_peers=none +hash_chain=false +generator=ffor_export_node_request_fixture +base_revision=7ed8161b7 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 0000000000..1f639f055d Binary files /dev/null and b/src/ffor/request_store/fixtures/invoice/active-manager.bin differ 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 0000000000..89b08c14b1 Binary files /dev/null and b/src/ffor/request_store/fixtures/invoice/issued-manager.bin differ 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 0000000000..c1cd318d8d Binary files /dev/null and b/src/ffor/request_store/fixtures/invoice/monitor.bin differ 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 0000000000..dd1056f540 Binary files /dev/null and b/src/ffor/request_store/fixtures/invoice/route-announcement.bin differ 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 0000000000..d690057190 Binary files /dev/null and b/src/ffor/request_store/fixtures/invoice/route-update.bin differ 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 0000000000..e0b8bcba9c Binary files /dev/null and b/src/ffor/request_store/fixtures/invoice/witness-manifest.bin differ diff --git a/src/ffor/request_store/fixtures/pending-manager.bin b/src/ffor/request_store/fixtures/pending-manager.bin new file mode 100644 index 0000000000..b8d410a3a7 Binary files /dev/null and b/src/ffor/request_store/fixtures/pending-manager.bin differ diff --git a/src/ffor/request_store/fixtures/pending-monitor.bin b/src/ffor/request_store/fixtures/pending-monitor.bin new file mode 100644 index 0000000000..567667d409 Binary files /dev/null and b/src/ffor/request_store/fixtures/pending-monitor.bin differ diff --git a/src/ffor/request_store/invoice.rs b/src/ffor/request_store/invoice.rs new file mode 100644 index 0000000000..6040f0b7b8 --- /dev/null +++ b/src/ffor/request_store/invoice.rs @@ -0,0 +1,346 @@ +//! Native invoice issuance joined to protected storage and exact Pending payment confirmation. +//! +//! Order for one request: durable policy reservation, native preparation, native persistence, +//! exact invoice retention in the protected record, exact Pending payment confirmation, protected +//! confirmation marker, and only then a handle whose release rechecks everything under the payment +//! 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; the background +//! processor completes native persistence tokens and the runtime re-polls. Nothing here emits +//! payment events or credits a payment. + +use std::fmt; + +use lightning::ln::ffor::{ + FFORCommitmentError, FFORInvoiceIntent, FFORReceiverError, FFORReceiverRecoveryContext, + FFORStoredInvoice, FFORWitnessRouteEvidence, +}; +use lightning_invoice::Bolt11Invoice; + +use super::record::invoice::{InvoicePolicy, RetainedInvoice}; +use super::record::RetainedSelector; +use super::{RequestStore, RequestStoreError, StoredRequest}; +use crate::payment::store::PaymentDetails; + +/// Progress of one request's invoice after a successful owner step. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::ffor) enum InvoiceProgress { + /// Native retains the assignment but its manager write is not complete. Retry later. + AwaitingPersistence, + /// The exact invoice, its Pending payment and the confirmation marker are durable. + Retained, +} + +/// Opaque publication handle bound to one owner instance and one manager instance. +/// +/// No method returns invoice bytes. Release rechecks the protected record, native state, the +/// payment row and the actual monitor before publishing once into the caller's slot. +pub(in crate::ffor) struct ConfirmedInvoice { + owner: u64, + client_id: String, + selector: RetainedSelector, + context_digest: [u8; 32], + invoice_digest: [u8; 32], + stored: FFORStoredInvoice, + payment: PaymentDetails, +} + +impl fmt::Debug for ConfirmedInvoice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> 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 new file mode 100644 index 0000000000..2d606632c9 --- /dev/null +++ b/src/ffor/request_store/native.rs @@ -0,0 +1,137 @@ +//! Concrete native join. Historical correlation never substitutes for exact native intent checks. + +use std::sync::Arc; + +use bitcoin::blockdata::constants::ChainHash; +use lightning::ln::ffor::{FFORPeerConnection, FFORReceiverId}; + +use crate::types::DynStore; +use crate::Node; + +use super::{RequestIntent, RequestPlan, RequestStore, RequestStoreError, StoredRequest}; + +impl RequestStore { + /// 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 { + Self::open_bound( + seed, + ChainHash::using_genesis_block(node.config.network).to_bytes(), + node.node_id(), + Arc::clone(&node.channel_manager), + Arc::clone(&node.payment_store), + 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.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 + .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.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) + } + + pub(super) 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); + } + 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..cae666ba60 --- /dev/null +++ b/src/ffor/request_store/record.rs @@ -0,0 +1,435 @@ +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, 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; + +#[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, + invoice: InvoiceAllocation, +} + +impl StoredRequest { + pub(super) fn new(intent: RequestIntent, plan: RequestPlan) -> Result { + let record = Self { intent, plan, selector: None, invoice: InvoiceAllocation::Legacy }; + 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 + } + /// 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(); + 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); + } + 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); + } + } + 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(&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); + 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); + }, + } + self.invoice.encode(&mut out); + out + } + + pub(super) fn decode(bytes: &[u8]) -> Result { + let mut reader = Reader(bytes); + 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)?; + 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), + }; + 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, + invoice, + }; + 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/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 new file mode 100644 index 0000000000..5d174a33ba --- /dev/null +++ b/src/ffor/request_store/tests.rs @@ -0,0 +1,545 @@ +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::util::ser::Writeable; +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_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(); + 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()); + 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), + 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_eq!(store.recover_native(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); + 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)); +} + +#[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), + Arc::clone(&store.payments), + 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] = 3; + 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..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:?}"); +} 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 new file mode 100644 index 0000000000..7c6115a633 --- /dev/null +++ b/src/ffor/witness_owner.rs @@ -0,0 +1,138 @@ +//! Private orchestration of native-authorized witness provisioning and durable acknowledgement. +//! +//! 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. + +use std::sync::Arc; + +use fetch::FetchRequests; +use lightning::ln::ffor::FFORReceiverError; +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}; + +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 { + Native(FFORReceiverError), + Storage(WitnessStoreError), + Protocol(WitnessError), + KeyUse(WitnessKeyUseError), + Capacity, + Conflict, + Entropy, + StaleConnection, + UnknownRequest, + UnknownWitness, + Unregistered, +} + +/// 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 { + manager: Arc, + monitor: Arc, + store: Arc, + transport: Arc, + pending: PendingProvisions, + fetches: FetchRequests, +} + +impl WitnessOwner { + pub(crate) fn new( + manager: Arc, monitor: Arc, store: Arc, + transport: Arc, + ) -> Self { + Self { + manager, + monitor, + 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, 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) + .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)?; + // 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) + } + + /// 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..1a9da7a808 --- /dev/null +++ b/src/ffor/witness_owner/fetch.rs @@ -0,0 +1,320 @@ +//! 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::{ReceiptPageRetention, WitnessStorageBinding}; +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 }, +} + +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 }; + 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 } = &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); + } + 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)] +pub(super) 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..f05c231179 --- /dev/null +++ b/src/ffor/witness_owner/fetch/tests.rs @@ -0,0 +1,401 @@ +use std::sync::atomic::Ordering; + +use bitcoin::hashes::{sha256, Hash, HashEngine, Hmac, HmacEngine}; +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}; +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 { + 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() +} + +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]; + // 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.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)); + 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.disconnect(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()); +} + +#[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_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 0000000000..37064ef7cd Binary files /dev/null and b/src/ffor/witness_owner/fixtures/active-manager.bin differ diff --git a/src/ffor/witness_owner/fixtures/active-monitor.bin b/src/ffor/witness_owner/fixtures/active-monitor.bin new file mode 100644 index 0000000000..94bc9622d5 Binary files /dev/null and b/src/ffor/witness_owner/fixtures/active-monitor.bin differ 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..a954e8a68e --- /dev/null +++ b/src/ffor/witness_owner/pending.rs @@ -0,0 +1,310 @@ +//! Bounded transient correlation. This table carries no native phase or persistence authority. + +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; +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; + +/// 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], +} + +#[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, + 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, + native: None, + }; + if let Some(index) = previous { + self.entries[index] = entry; + } else { + self.entries.push(entry); + } + self.encoded_bytes = total + bytes; + 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) + } + + 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_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 + .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..010de64d00 --- /dev/null +++ b/src/ffor/witness_owner/provisioning.rs @@ -0,0 +1,288 @@ +//! 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, NativeAttempt}; +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, + /// Both historical promises are retained and the current native requirement is complete. + AcknowledgementRetained, +} + +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 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( + &context.channel_id(), + &context.settlement_node_id(), + context.epoch_id(), + ) + .map_err(WitnessOwnerError::Native)?; + 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.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.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 => candidate + .stage_excluding(epoch, source, manifest, |id| fetches.contains_request_id(id))?, + Attempt::Retry => candidate + .restart_excluding(epoch, source, manifest, |id| fetches.contains_request_id(id))?, + }; + 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_attempt( + &active, + &native_attempt, + pending.provision(), + |provision| { + // This is the sole nested native -> transport lock acquisition. No owner/store reentry. + transport.enqueue_provision(witness, connection.transport(), 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), + } + } + + 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 { + 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/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 new file mode 100644 index 0000000000..1430e26a3a --- /dev/null +++ b/src/ffor/witness_owner/tests.rs @@ -0,0 +1,483 @@ +//! 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::{BaseMessageHandler, 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::ffor::FforSetupAdapter; +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 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( + 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), + 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 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!( + 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::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)); +} + +#[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::StaleConnection))); + let stale = h.receive(w, &ack); + 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)); + 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)) + ); + 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)); +} + +#[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::AwaitingPersistence)); + h.persist_manager(); + 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()) + ); +} + +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.rs b/src/ffor/witness_store.rs new file mode 100644 index 0000000000..b4d1070131 --- /dev/null +++ b/src/ffor/witness_store.rs @@ -0,0 +1,608 @@ +//! Immutable per-epoch witness secrets and monotonic provisioning promises, sealed before use. +//! +//! 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 +//! native claim reconciliation and deletion are deliberately absent. Retained acknowledgements are historical promises, not current +//! provisioning authority or invoice readiness. +//! 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 +//! 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. + +use std::collections::{BTreeMap, BTreeSet}; +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, CheckedFetchPage, EncryptedRecord, SignedManifest, +}; + +use crate::types::DynStore; + +mod binding; +mod envelope; +mod receipt; +mod record; +pub(super) use receipt::ReceiptPageRetention; +use receipt::{ReceiptBook, ReceiptRetention}; +use record::ReceiptAllocation; + +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"; +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, + 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)] +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 + // for creation, never for an update to already retained recovery material. + previous: Option>, +} + +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, + receipt_records: BTreeMap>, + receipt_confirmed: BTreeSet, + receipt_reservations: BTreeMap, +} + +pub(super) struct WitnessSecretStore { + storage: Arc, + wrapping_key: WrappingKey, + receipt_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 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, 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); + } + 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, + receipt_key, + state: Mutex::new(StoreState { + records, + confirmed: BTreeSet::new(), + uncertain: None, + receipt_records, + receipt_confirmed: BTreeSet::new(), + receipt_reservations, + }), + }) + } + + /// 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, 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)?; + 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())?; + record.receipt_allocation = ReceiptAllocation::Pending( + receipt.len().try_into().map_err(|_| WitnessStoreError::Capacity)?, + ); + let bytes = self.wrapping_key.seal(binding, &record)?; + // 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); + } + state.receipt_reservations.insert(key.clone(), receipt.len()); + 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) + } + + /// 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) + } + + /// 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. + 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, Namespace::Secrets, key, bytes)?; + Ok(record) + } + + 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)?; + if &actual != expected { + return Err(WitnessStoreError::Conflict); + } + let mut record = self.wrapping_key.open(binding, &actual)?; + if !state.confirmed.contains(&key) { + self.confirm_write(state, Namespace::Secrets, key.clone(), actual)?; + } + let receipts = match record.receipt_allocation { + ReceiptAllocation::Pending(_) => { + self.finish_initialization(state, binding, &mut record)?; + Some(self.load_receipt_locked(state, binding, &record)?) + }, + ReceiptAllocation::Reserved(_) => { + Some(self.load_receipt_locked(state, binding, &record)?) + }, + ReceiptAllocation::Legacy => None, + }; + Ok((record, receipts)) + } + + 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, 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() { + return Err(WitnessStoreError::Corrupt); + } + self.confirm_write(&mut state, Namespace::Receipts, binding.storage_key(), bytes)?; + } + 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( + &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, receipts) = self.load_with_receipts_locked(&mut state, binding)?; + let receipts = receipts.ok_or(WitnessStoreError::Unreserved)?; + receipts + .get(witness, slot)? + .map(|receipt| { + record.decrypt_record(witness, receipt).map_err(|_| WitnessStoreError::Corrupt) + }) + .transpose() + } + + fn confirm_write( + &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. + let previous = state + .uncertain + .as_ref() + .and_then(|pending| pending.previous.clone()) + .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(()) + } + + /// 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. + 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.namespace, &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) if pending.previous.is_none() => {}, + Err(error) => return Err(error), + } + let key = pending.key.clone(); + let bytes = pending.bytes.clone(); + let namespace = pending.namespace; + self.confirm_write(&mut state, namespace, key, bytes) + } +} + +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 ensure_certain(state: &StoreState) -> Result<(), WitnessStoreError> { + if state.uncertain.is_some() { + Err(WitnessStoreError::Uncertain) + } else { + Ok(()) + } +} + +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 { + WitnessStoreError::Storage + } + }) +} + +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) +} + +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)] +pub(in crate::ffor) 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..1468f9bf99 --- /dev/null +++ b/src/ffor/witness_store/envelope.rs @@ -0,0 +1,145 @@ +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::{WitnessStorageBinding, WitnessStoreError, MAX_RECORD_BYTES, SCHEMA_VERSION}; + +const HEADER_LENGTH: usize = 4 + 2 + 32; + +pub(super) struct WrappingKey { + key: Zeroizing<[u8; 32]>, + receipts: bool, +} + +impl WrappingKey { + pub(super) fn derive(seed: &[u8; 64]) -> Self { + 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 { key: Zeroizing::new(Hmac::from_engine(expand).to_byte_array()), receipts } + } + + pub(super) fn seal( + &self, binding: &WitnessStorageBinding, record: &StoredWitnessEpoch, + ) -> Result, WitnessStoreError> { + self.seal_plaintext(binding, &record.encode()) + } + + pub(super) fn seal_plaintext( + &self, binding: &WitnessStorageBinding, plaintext: &[u8], + ) -> Result, WitnessStoreError> { + 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()); + self.check_size(&bytes)?; + 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 { + 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(|m| m.cipher_format.as_str()) + != Some("ChaCha20Poly1305") + { + return Err(WitnessStoreError::Corrupt); + } + let aad = self.associated_data(key, header); + let (plaintext, version) = StorableBuilder::new(EnvelopeNonce([0; 12])) + .deconstruct(storable, &self.key, &aad) + .map_err(|_| WitnessStoreError::Corrupt)?; + let plaintext = Zeroizing::new(plaintext); + if version != i64::from(SCHEMA_VERSION) { + return Err(WitnessStoreError::Corrupt); + } + Ok((digest, plaintext)) + } + + 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]) { + 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..196c94dcad --- /dev/null +++ b/src/ffor/witness_store/receipt.rs @@ -0,0 +1,268 @@ +//! 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, WitnessKeyUseError, 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, +} + +/// 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], + 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_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 { + 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::ReceiptConflict), + 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..30a07ffea4 --- /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::ReceiptConflict) + ); + 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::InvalidReceipt) + ); + 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 new file mode 100644 index 0000000000..9b2cf3f2f5 --- /dev/null +++ b/src/ffor/witness_store/record.rs @@ -0,0 +1,499 @@ +use std::fmt; + +use bitcoin::secp256k1::{Message, PublicKey, Secp256k1, SecretKey}; +use lightning::ln::ffor::FFORWitnessDecryptionError; +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}; + +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; + +#[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(in crate::ffor) witness: PublicKey, + pub(in crate::ffor) retention_until: u32, + 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]>, + manifest: SignedManifest, + acknowledgement: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct RetainedAcknowledgement { + request_id: [u8; 16], + retention_until: u32, +} + +/// 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 { + 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, + acknowledgement: None, + }); + } + Ok(Self { + binding_digest: binding.digest(), + encryption_secret, + witnesses, + receipt_allocation: ReceiptAllocation::Legacy, + }) + } + + /// 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() + .find(|entry| entry.policy.witness == witness) + .map(|entry| &entry.manifest) + } + + pub(super) fn policies(&self) -> Vec { + 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()) + } + + /// 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( + &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 + + 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)); + 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()); + 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 + } + + pub(super) fn decode( + binding: &WitnessStorageBinding, bytes: &[u8], + ) -> Result { + let mut reader = Reader(bytes); + let version = u16::from_be_bytes(reader.array()?); + if !matches!(version, LEGACY_RECORD_VERSION | RECORD_VERSION | RESERVED_RECORD_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 receipt_allocation = read_allocation(version, &mut reader)?; + 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, + }; + 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()?); + 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); + } + 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), + } +} + +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/record/key_use.rs b/src/ffor/witness_store/record/key_use.rs new file mode 100644 index 0000000000..762c087a7a --- /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) 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..f6a4749587 --- /dev/null +++ b/src/ffor/witness_store/record/key_use/tests.rs @@ -0,0 +1,212 @@ +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], + receipt_allocation: super::super::ReceiptAllocation::Legacy, + encryption_secret: Zeroizing::new([44; 32]), + witnesses: vec![WitnessKeys { + acknowledgement: None, + 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 new file mode 100644 index 0000000000..b3b3fcb0b4 --- /dev/null +++ b/src/ffor/witness_store/tests.rs @@ -0,0 +1,909 @@ +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 lightning_ffor::witness::{ + Acknowledgement, AcknowledgementResult, CheckedAcknowledgement, PendingProvision, Provision, + WitnessConnection, +}; +use proptest::prelude::*; + +use super::*; +use crate::io::test_utils::InMemoryStore; + +type ReadHook = Box; + +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, + pub(in crate::ffor) read_hook: Mutex>, +} + +impl TestStore { + pub(in crate::ffor) 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), + read_hook: Mutex::new(None), + }) + } + pub(in crate::ffor) fn bytes(&self, key: &str) -> Vec { + KVStoreSync::read(&self.inner, PRIMARY_NAMESPACE, SECONDARY_NAMESPACE, key).unwrap() + } + pub(in crate::ffor) 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")); + } + 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<()> { + 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)?; + } + 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 }) + } +} + +pub(super) 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) +} + +pub(super) fn binding(epoch: u8) -> WitnessStorageBinding { + let (identity, setup, activation, ack) = evidence(epoch, 2); + WitnessStorageBinding::new(identity, setup, &activation, &ack).unwrap() +} + +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 }, + ] +} + +pub(super) 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 = 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; + 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), 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), 7); +} + +#[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), 3); +} + +#[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), 6); + } +} + +#[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 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 { + break; + } + let key = format!("f{index:063x}"); + assert_ne!(key, binding.storage_key()); + let count = remaining.min(MAX_RECORD_BYTES); + store.state.lock().unwrap().records.insert(key, vec![9; count]); + remaining -= count; + } + assert_eq!(remaining, 0); + 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), 5); +} + +#[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), 5); + 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_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), 5); +} + +#[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), 4); + 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()); + 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] +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()); + 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] +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()); + 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); + assert!(WitnessSecretStore::open(&[5; 64], backend.clone()).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), 3); +} + +#[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(); + 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]); + } + 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(); + 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]); + } + 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), 3); +} + +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) { + let canonical = record.encode(); + let repeated = StoredWitnessEpoch::decode(&binding, &canonical).unwrap(); + prop_assert_eq!(&*repeated.encode(), &*canonical); + } + } + + #[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()); + } +} + +#[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()); +} diff --git a/src/lib.rs b/src/lib.rs index 5b51005963..75a233c79e 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; @@ -126,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; @@ -157,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; @@ -219,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. @@ -778,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) { @@ -1051,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 fc206ec4da..03fb24b26f 100644 --- a/src/message_handler.rs +++ b/src/message_handler.rs @@ -5,117 +5,212 @@ // 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, installed only when the builder's +//! offline-receive configuration is set. + +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, ErrorAction, 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::setup::FforSetupAdapter; +use ffor::{FforFrame, FforReceiverTransport}; + +/// Independent LSPS and connection-scoped FFOR messages retain their own queue ordering. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum NodeCustomMessage { + Liquidity(RawLSPSMessage), + Ffor(FforFrame), +} -pub(crate) enum NodeCustomMessageHandler +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) struct NodeCustomMessageHandler> where - L::Target: Logger, + H: Deref, + H::Target: CustomMessageHandler, { - Ignoring, - Liquidity { liquidity_source: Arc> }, + liquidity: Option, + ffor: Option>, + ffor_setup: 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, ffor_setup: None } } pub(crate) fn new_ignoring() -> Self { - Self::Ignoring + Self { liquidity: None, ffor: None, ffor_setup: None } + } + + #[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 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); + 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) => { + if let Some(setup) = self.ffor_setup.as_ref() { + if matches!(message.type_id(), 55003 | 55047 | 55049 | 55053) { + return setup.handle(sender, &message).map(|_| ()).map_err(|error| { + LightningError { + err: format!("FFOR receiver: {}", 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(()), + } }, } } - 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)> { + 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 { - 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(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); + } + 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..fe0920ac2d --- /dev/null +++ b/src/message_handler/ffor.rs @@ -0,0 +1,389 @@ +//! 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. An outbound enqueue callback checks its token and queue capacity atomically; +//! 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; the builder installs this +//! transport only with the builder's offline-receive configuration. + +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; +use lightning::util::ser::{LengthLimitedRead, Writeable, Writer}; +use lightning_ffor::wire::{Message, MAX_MESSAGE_LEN}; +use lightning_ffor::witness::{Acknowledgement, FetchResponse, Provision, SignedFetch}; +pub(crate) use setup::FforSetupAdapter; + +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 bytes, including the message type. Parsing grants no protocol authority. +#[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 { + // 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( + 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..])?; + 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); + } + 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(()) + } + + // 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, + 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)] +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, + queue: VecDeque, + outbound: 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); + 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 and outbound queue sharing one bounded capacity budget. +/// 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) { + 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(())), + native_connection, + messages: 0, + bytes: 0, + }, + ); + } + } + + pub(crate) 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); + } + + 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 state.at_capacity(peer_state, 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(()) + } + + /// 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)?; + 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 { + 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()?; + state.remove_accounting(&message); + 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) + } +} + +#[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(), + action: ErrorAction::IgnoreAndLog(Level::Debug), + } +} + +#[cfg(test)] +mod tests; diff --git a/src/message_handler/ffor/setup.rs b/src/message_handler/ffor/setup.rs new file mode 100644 index 0000000000..f38f2f1308 --- /dev/null +++ b/src/message_handler/ffor/setup.rs @@ -0,0 +1,111 @@ +//! Private receiver bridge. The concrete native manager owns lifecycle and persistence authority. +//! The builder installs this bridge only with its offline-receive configuration; 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::{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, +} + +#[allow(dead_code)] +impl FforSetupAdapter { + pub(crate) fn new( + manager: Arc, monitor: Arc, + transport: Arc, + ) -> Self { + Self { manager, monitor, transport } + } + + pub(crate) 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)?; + let enqueue = |wire: &[u8]| { + // Native -> transport is the only nested lock order. No I/O or manager reentry. + // 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( + &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/ffor/tests.rs b/src/message_handler/ffor/tests.rs new file mode 100644 index 0000000000..114bfff286 --- /dev/null +++ b/src/message_handler/ffor/tests.rs @@ -0,0 +1,388 @@ +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 { + 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); + FforFrame::read(55057, &mut &wire[2..]).unwrap() +} + +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!(queued.len() <= MAX_QUEUED_MESSAGES); + assert!(state.bytes <= MAX_QUEUED_BYTES); + assert_eq!(state.bytes, queued.iter().map(|message| message.frame.0.len()).sum::()); + for (key, peer) in state.peers.iter() { + 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); + assert!(peer.bytes <= MAX_PEER_BYTES); + assert!(queued.iter().all(|message| message.connection == peer.connection)); + } +} + +#[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_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(); + 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..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 { + 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)); }, + 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/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..3d41a597f9 --- /dev/null +++ b/src/message_handler/tests.rs @@ -0,0 +1,461 @@ +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::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::*; + +use super::*; + +mod setup; + +#[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() +} + +// 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()); + 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_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(); + 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(); + 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(); + 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, 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, 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,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); + prop_assert_eq!(message.encode(), data); + } + } +} diff --git a/src/message_handler/tests/setup.rs b/src/message_handler/tests/setup.rs new file mode 100644 index 0000000000..9214550e7e --- /dev/null +++ b/src/message_handler/tests/setup.rs @@ -0,0 +1,249 @@ +//! 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, CloseAck, 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 { + 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; + 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(&node.chain_monitor), + 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), + 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: {}", 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()); +} 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)?) + } +} 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, >; diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 241ee1cde5..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 { @@ -2424,17 +2430,65 @@ 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(())); @@ -2516,7 +2570,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 +2580,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) ); }