Skip to content

assets/reservation: Add client-initiated reservation purchases - #1229

Open
hieblmi wants to merge 23 commits into
lightninglabs:masterfrom
hieblmi:hai/asset-reservations-stepwise
Open

hieblmi wants to merge 23 commits into
lightninglabs:masterfrom
hieblmi:hai/asset-reservations-stepwise

Conversation

@hieblmi

@hieblmi hieblmi commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

What this adds

This PR lets a client buy and manage an asset reservation before starting a
swap. A reservation sets aside a fixed asset amount in an on-chain output
for that client, for a limited time. Spending uses a shared client/server key;
a timeout path allows unused funds to be reclaimed.

The client pays the quoted service fee upfront to obtain the reservation.
The asset principal is paid for later, when the client executes the swap.
Under this contract, the prepayment covers the later swap's service fee and
is credited once. Routing and miner fees are separate, and the prepayment is
not refunded if the reservation goes unused.

The purchase flow validates and saves a quote, checks whether the estimated
main payment can reach its destination, asks for approval, pays the BTC prepay
invoice, and verifies the delivered reservation before marking it Ready.
A successful probe reports current reachability; it does not reserve Lightning
liquidity for the future swap. The prepay routing-fee estimate is derived from
the main probe, so purchasing requires only one probe.

Purchase flow

flowchart TD
    Q["RequestQuote: request and validate a quote<br/>Save the purchase ID, keys, and agreed terms"]
    P["ProbeRoutes: probe the estimated main payment<br/>Save reachability and routing-fee estimates"]
    A["AwaitApproval: show the quote and probe result"]
    PAY["PayPrepay: save payment intent<br/>Pay and track the BTC prepay invoice"]
    WAIT["WaitForDelivery: obtain the funding outpoint and asset proof"]
    VERIFY["VerifyReservation: check the asset, amount, keys, proof,<br/>confirmations, and remaining lifetime"]
    READY["Ready: verified reservation available for a later swap"]
    CANCEL["CancelPrepay: resolve any payment before cancellation"]
    CANCELED["Canceled"]

    Q --> P
    Q -->|Explicit --skip_probe| A
    P --> A
    A -->|Approve exact quote and routing cap| PAY
    A -->|Retry probe| P
    A -->|Cancel or quote expires| CANCEL
    PAY -->|Payment settled| WAIT
    WAIT --> VERIFY
    VERIFY -->|Checks pass| READY
    CANCEL -->|Unpaid cancellation confirmed| CANCELED
    CANCEL -->|Payment settled instead| WAIT
Loading

Normal approval requires a successful main-payment probe. --skip_probe
explicitly permits purchase without one; quote validation and payment limits
still apply. Declining the prompt leaves the purchase saved and unpaid.

The daemon saves progress throughout the purchase and resumes unfinished work
after a restart. It records the exact payment before sending and resolves an
uncertain result by tracking that payment. Delivery is verified locally, and
ready reservations remain monitored for spending and expiry. Recovery retains
the original lifetime rather than starting a new clock.

Using the reservation in a swap

A later asset Loop Out will select the funded reservation by its txid:vout,
check that enough time remains, and obtain a fresh execution quote. The client
will pay the main Lightning invoice to receive the reserved assets on-chain
through the swap protocol. The saved prepay credit avoids charging the service
fee twice; the BTC-to-asset conversion price can change before execution and
requires fresh approval.

This PR implements reservation purchase through Ready. Executing the later
asset swap is follow-up work. It includes persistent terms and state history,
payment recovery, proof and confirmation checks, authenticated RPCs, and
loop asset reservation buy, list, get, probe, and cancel. Purchases
require --experimental and a configured tapd.

Extending this to BTC reservations

The same quote, approval, prepay, recovery, and readiness flow can support BTC
reservations. A follow-up would add BTC-denominated terms and a Bitcoin funding
verifier in place of asset proofs and conversion checks, then connect ready
reservations to Instant Out. BTC reservation purchases are not enabled here.

Earlier work and dependency

This replaces the reservation-buying approach in:

This branch includes the shared asset tooling from
#1225. The reservation-specific
commits begin after 70ec06b9; the prerequisite remains in the diff until the
shared tooling is merged and this branch is rebased.

Validation

  • go test ./assets/reservation ./assets/payment ./loopdb ./loopd ./cmd/loop -count=1
  • go test ./... -count=1 in both looprpc and swapserverrpc
  • go test -race ./assets/reservation -run '^TestClientManagerCancelsCreation$' -count=1
  • make commitmsg-lint range=70ec06b9..HEAD
  • git diff --check 70ec06b9..HEAD

The documented real-node purchase run predates the current single-probe flow.
A fresh real-node run of that flow, broader node-failure testing, and the
PostgreSQL/Neutrino variants remain outstanding. Reorg recovery remains
follow-up work; this feature is experimental.

Pull Request Checklist

  • Added entries to docs/release-notes/release-notes-next.md.

The client and server need to derive the same asset HTLC. Add a shared
kit that preserves the legacy scripts, keys, and packet layout, and
require callers to select the contract policy explicitly.

Select the input from the proof's outpoint, check its anchor and the
PSBT's prevout metadata, and verify the returned signature before
constructing a witness. Tests pin the legacy vectors and exercise
success and timeout spends.
Add the legacy two-party asset deposit kit to Loop so client and server
can share address construction, proof checks, and timeout signing. Add
the OP_TRUE virtual-packet helper alongside it.

For timeout spends, find the input that spends the proof's outpoint,
require prevout metadata for every input, and check the asset anchor and
returned signature. This removes the assumption that the deposit is the
first input.
Reject deposit delays outside BIP68's block-based range so sequence
flags cannot change or disable the timeout.

Build sweep outputs from the destination address, normalize OP_TRUE
script keys, and fill every input witness before validating the virtual
transfer. Reject unsupported address versions and duplicate anchor
inputs. Tests cover multiple inputs and the resulting output layout.
A cooperative MuSig2 spend needs the output's full Taproot root,
including the timeout sibling. Return that root so the spend can
reproduce the funding output's tweak.

Add separate helpers for checking a commitment against an expected
amount and for asking tapd to verify a full proof file against an
expected outpoint. Keep the old VerifyProof entry point as a deprecated
wrapper.
Update Taproot Assets to v0.8.3 and taprpc to v1.3.3. Align the LND
dependency at v0.21.3-beta, raise the Go build requirement to 1.25.13,
and refresh the main and client RPC module dependencies.

Add regression tests at the shared proof helpers to check that proofs
with missing keys return errors instead of panicking.
Define the reservation purchase flow, its costs, and its acceptance
criteria before building asset Loop Out on top. The plan uses held
prepays, durable payment and funding records, and recovery through named
FSM actions.

Lay out the implementation as small commits and keep the previous
prototype's databases outside this experiment.
Define quote, status, list, proof, and unpaid-cancellation calls for
asset reservations. A client-generated ID identifies the same purchase
across retries, including before its funding outpoint exists.

Specify authenticated ownership, immutable quote terms, payment details,
and public status fields. Generate the protobuf bindings and document
the purchase API.
Define a reservation's quoted asset amount, fee, confirmation depth, and
usable lifetime. Validate positive amounts, storage bounds, overflow,
and block-based CSV limits.

Check that the quoted lifetime values fit together without imposing a
fixed fee or minimum lifetime policy. Test structural boundaries;
approval and persistence follow in later commits.
Store a reservation's agreed terms separately from its ordered state
history. Constrain identifiers, amounts, fees, and lifetime values, and
make repeated inserts preserve the existing purchase.

Allow a purchase row to exist before the server supplies its quote, with
the fee and lifetime unset. Require those terms to form a complete set
once supplied.

Add schema tests for rollback, duplicate IDs, quoted fees, and invalid
records. The shared database test helper selects SQLite or PostgreSQL
through the existing build tag.
Add a typed SQL store that saves a purchase and its first state in one
transaction. An identical create request returns the saved progress; a
reused ID with different terms or keys fails. Updates preserve the
agreed terms.

Use the SQLite and PostgreSQL database abstraction and test concurrent
creation, failed writes, invalid records, and reopening a SQLite
database.
Calculate the timeout and execution cutoff from the first funding
confirmation so extra confirmations and restarts cannot extend a
reservation's life.

Test deadline calculations and signed-height boundaries. Keep lifetime
arithmetic separate from proof, confirmation, and spend checks.
Store the quote, main probe, SkipProbe preference, prepay routing cap,
payment identity and result, outpoint, credit, and proof. Read the
agreed amounts from the saved quote. Enforce unique funding outpoints
and test the transition from an unquoted row.
Define separate Buy and Approve calls so callers can inspect the quote
and main probe before authorizing payment. Buy can request skipped
probing; Approve separately permits buying without a successful probe.

Approving the exact quote hash accepts its asset fee and BTC prepay
amount. The caller supplies the prepay routing cap and SkipProbe choice,
which are saved with the transition that authorizes payment.

Add get, list, probe-retry, and cancellation calls with purchase-ID and
outpoint selectors. Report the probe outcome and estimated routing fees,
assign swap read and execute permissions, and generate the bindings.
Retain the immutable quote, main probe, fee estimates, skip preference,
prepay routing cap, payment identity, and delivery facts. Save progress
and state history in one transaction, and prevent changes to payment
choices once the purchase leaves approval.

Check the quote hash, routing cap, and probe choice on approval. The
hash binds consent to both quoted amounts. Read those amounts from the
quote and payment choices from the reservation during recovery. Require
a successful main probe unless the caller permits buying without one.

Estimate prepay routing fees from the main fee and BTC amount ratio,
rounding up and ignoring fixed hop fees. Test persistence, conflicting
updates, quote consent, routing caps, outpoints, and fee arithmetic.
Coordinate quote requests, one main-payment probe, approval, prepay
payment, and reservation verification through a persistent state
machine. Save the probe result and estimate the prepay routing fee from
it. Honor a saved skip request; an explicit probe retry clears it.

Save the prepay routing cap and SkipProbe choice with the transition to
PayPrepay. That state records consent. If the write fails, recovery
waits for approval; if it succeeds, recovery uses the saved choices.
Save the exact payment plan before sending and look up the original
payment to resolve an uncertain result. If settlement wins a
cancellation race, continue delivery.

Verify the proof and funding before Ready, retain the original CSV
clock, and stop node calls after a failed database write. Test recovery,
consent, payment limits, and failed writes with SQL and node doubles.
Restore unfinished reservations and start one FSM worker for each.
Route approval, cancellation, probe retries, and wake requests through
that worker so its actions run in sequence. Periodic OnRecover events
resume purchases and monitor ready reservations until expiry.

Before starting a new worker, save the reservation ID, client key,
requested asset and amount, and SkipProbe choice. Repeated creation
requests return the saved reservation; conflicting assets or amounts
are rejected. Apply MaxActiveReservations only to new purchases, so
lowering the limit cannot prevent recovery.

Keep one pending command per worker and return ErrReservationBusy
when its queue is full. Bound worker attempts with CallTimeout, return
saved snapshots to callers, and cancel and join workers on shutdown.
Creation and its snapshot read share one timeout and stop when either
the caller or the manager cancels.

Test concurrent duplicate requests, recovery above
MaxActiveReservations,
shutdown during quote retrieval, key derivation, and snapshot reads,
and SkipProbe across restarts.
Bind the deposit proof to its amount, scripts, outpoint, and local
co-signing key before calling tapd. Record verified issuance metadata
so tapd v0.8.3 can verify assets new to the client, then verify the full
history. This does not import wallet assets. Check the proof's block
against LND and observe the exact confirmed output.

Use LND confirmation, spend, and block notifications to watch deposits
and restore watches after restart. As with Instant Out, Ready means no
spend is known; it does not wait for a synchronous unspent-output check.
Test proof binding, issuance insertion before history verification,
rejected proofs, repeated verification, and watch recovery.
Add the serialized receiving RFQ to the reservation quote. The client
needs it to check that the prepay invoice's BTC amount and route hint
match the agreed asset fee and conversion edge before approval.

Carry the public PeerAcceptedBuyQuote and regenerate the protobuf
binding.
Calculate a BTC invoice amount that converts to exactly the requested
asset units, and reject rates or RFQ capacities that cannot carry it.
Bind the signed invoice to the asset, payee, payment secret, receiving
edge, and expiry.

Validate each live HTLC's identity and asset records, then require their
total to equal the agreed amount. Return every part's expiry for
settlement checks. Keep receipt verification tied to the original quote
so an expired quote does not invalidate an earlier settlement.
Add the LND payment adapter for reservation purchases. Validate both
invoices and probe the estimated main payment through the invoice-based
fee estimator. Prepare the prepay plan for the FSM to save before
sending it.

Enforce approved fees, payment lifetime, route CLTV limits, and enough
BTC in one channel after its reserve, even when probing is skipped. Send
one part to avoid conversion rounding across shards. Resolve uncertain
results by tracking the saved hash on the saved node, and validate the
settled amount and routing fee.
Connect the purchase manager to loopd's database, configured tapd,
server connection, and authenticated LND services. Start recovery during
daemon initialization and wait for saved purchases before serving calls.
Use the proof adapter to record issuance metadata before verifying
reservation proofs through tapd.

Implement quote approval, outpoint selection, and paginated status. Pass
the skip preference on quote and approval requests to the manager.
Enable purchases when experimental features and tapd are active. Test
separate approval, explicit consent to skip probing, and disabled-
service responses.
@hieblmi hieblmi self-assigned this Sep 15, 2026
@hieblmi hieblmi added the asset loop out Taproot Asset Loop Out work label Sep 15, 2026
Add buy, list, get, probe, and cancel under loop asset reservation.
Print the purchase ID before dispatch so a lost reply can be retried,
and accept canonical outpoints once funding is known.

Show main-payment reachability, the quote, and the routing cap before
approval. Display the full prepaid service fee in asset units and as a
percentage, including the transport minimum that enables smaller swaps.
Document that the fee is fully charged and the main payment remains the
principal. Estimate prepay routing fees from the main probe. Keep the
conventional routing limit unless the caller sets another limit.

Normal purchases require a successful main probe. The --skip_probe flag
skips probing for new purchases and accepts a failed result on resume.
It preserves quote approval and payment limits; --yes only skips the
prompt. After approval, wait for verified delivery while the daemon
continues recovery.
Record the September 10 real-node purchase run with tapd v0.8.3, LND
v0.21.3-beta, and a SQLite client. It covered a BTC-only payer, both
probes, daemon restarts, proof rejection, unfundable-purchase
cancellation, and the 1,440-block CSV sweep. Mark that run as predating
the current single-probe flow.

List the remaining node-failure tests and client database and backend
variants. Asset swap execution remains outside this case.
@hieblmi
hieblmi force-pushed the hai/asset-reservations-stepwise branch from 8d8a932 to eecb709 Compare September 16, 2026 13:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

asset loop out Taproot Asset Loop Out work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants