Skip to content

Wrapped receive: one hash, an LDK invoice on the phone plus a Spark invoice, claimed on PaymentClaimable (API shape; we have the diff) #112

Open

Description

@hash-money

What we are building. Our LSP wraps the phone's invoice so a receive settles whether or not the phone is online when it is paid. At invoice time the phone picks P, H = sha256(P), and creates two invoices for H: one on its own LDK node, one on Spark that settles through the operator shares as an ordinary Spark receive. The payer gets the LSP's invoice for H (ldk-node receive_for_hash_with_min_final_cltv_expiry_delta, merged as lightningdevkit/ldk-node#1137). When the payment arrives the LSP pays the phone's invoice if the phone is connected, otherwise the Spark invoice, and learns P only from that settlement; it never holds P ahead of time. The Spark side needs a caller-supplied preimage on an ordinary share-split invoice: breez/spark-sdk#1250 (open; the maintainer asked about the use case on 2026-10-23 and we answered the same day with the numbers below).

Measured (mainnet, our LSP, 1,000-sat receives, three runs per case, 2026-10-23, running against the fork in this issue):

Case Result
Phone online, channel with inbound Lightning leg into the phone's channel, fee 0, payer-visible settle 6 s; no Spark payment
Phone off, has a channel Spark leg, 3–4 sats routing fee, payer 8–13 s; the phone claims the transfer on its next start in 10–11 s
Phone off, no channel Spark leg only, 3-sat fee, payer 13–16 s, claim on next start 10–11 s (4/4); the sweep opening the channel later was not exercised in this matrix
Leg cannot be paid Inbound failed back the same second; payer refunded (Spark refund lands after ~6 min)
LSP crashed between leg and claim Recovered on restart from its payment store, claimed 7–14 s after the kill

Why not a plain Spark invoice: below the trusted limit every receive becomes custodial until the sweep, and above it every receive costs a channel; the sweep itself needs the phone online, so the online case would pay twice (Spark leg + sweep) for a worse custody story. The wrapped receive keeps the common case in the user's channel from the first hop and uses Spark only when the phone is actually offline.

Why we are asking rather than doing it in our wrapper. Everything the phone has to do sits behind orange-sdk's boundary (master a3aa1c5): TrustedWalletInterface is sealed (orange-sdk/src/trusted_wallet/mod.rs:47, 193-203) and the Spark implementation builds the Breez request with payment_hash: None (trusted_wallet/spark/mod.rs:148); the Lightning side only calls the plain receive* / *_via_jit_channel variants (lightning_wallet.rs:251-284), so ldk-node's receive_for_hash (pinned 0cea341, src/payment/bolt11.rs:614) is not reachable and Wallet exposes no node handle; PaymentClaimable is logged as an ldk-node bug (event.rs:610-615), so nothing can call claim_for_hash. Today we carry a fork for it.

The diff (emergent-money/orange-sdk, branch lab/wrapped-receive, commit 3fd6c532, one commit on master, +800/−26 across lib.rs, event.rs, store.rs, lightning_wallet.rs, trusted_wallet/{mod,spark,cashu,dummy}.rs and the FFI wallet; Cargo.toml also repoints breez-sdk-spark at our spark-sdk fork a99d0ecc for the preimage field, so the PR waits on #1250 or carries that pin until it lands):

pub struct WrappedReceive {
    pub payment_hash: PaymentHash,
    /// Invoice on the phone's own node for `payment_hash`; `None` when the phone has no
    /// inbound for the amount (the LSP then goes straight to the trusted leg).
    pub lightning_invoice: Option<Bolt11Invoice>,
    /// Trusted-tier invoice for the same hash, settling without the phone.
    pub trusted_invoice: Bolt11Invoice,
}
impl Wallet {
    pub async fn get_wrapped_receive(&self, amount: Amount) -> Result<WrappedReceive, WalletError>;
}
// TrustedWalletInterface: Spark implements it; Cashu returns UnsupportedOperation.
fn get_bolt11_invoice_with_preimage(&self, amount: Amount, preimage: [u8; 32]) -> ...;

orange-sdk generates P, persists H -> P in its store, and on PaymentClaimable for a known H calls claim_for_hash; an unknown H is failed with fail_for_hash (claim_for_id / fail_for_id plus manually_handle_unknown_bolt11_payments once orange-sdk moves to an ldk-node with #948, as our LSP already does). Fixed amounts only; P never leaves orange-sdk. get_single_use_receive_uri and the rebalancer are untouched; a trusted leg paid above trusted_balance_limit is the same state as today's LSP-unreachable fallback (lib.rs:1170-1185) and the rebalancer handles it. We would open the PR from that branch, rebased, with tests, if the shape is acceptable.

Questions. (1) Is this a shape you would take, or would you rather see thin passthroughs (receive_for_hash, claim_for_hash, an Event::PaymentClaimable, and the trusted preimage method) with P kept in our wrapper? (2) If this does not belong in orange-sdk, say so and we will keep carrying the fork.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions