Skip to content

staticaddr: support multiple receive and change addresses - #1218

Open
hieblmi wants to merge 37 commits into
masterfrom
multi-address-core
Open

hieblmi wants to merge 37 commits into
masterfrom
multi-address-core

Conversation

@hieblmi

@hieblmi hieblmi commented Aug 28, 2026 •

Copy link
Copy Markdown
Collaborator

This is PR 1 of 3 in the Static Address multi-address stack.

Static Address previously treated one legacy/root address as the owner of every
deposit. This PR introduces fresh receive and operation-specific change
addresses while preserving the address parameters that own each deposit.

Loop-ins and withdrawals can consequently spend deposits received across
multiple derived addresses, with each input signed and proven using its actual
address parameters.

Key Changes

  • Derive fresh static receive and change addresses from dedicated key families.
  • Persist each deposit's owning static-address parameters and restore them
    across restarts.
  • Backfill pre-migration deposits and fractional loop-ins to the legacy/root
    address.
  • Create cooperative signing sessions from each deposit's actual keys.
  • Send per-deposit address proofs to the server for loop-ins and withdrawals.
  • Generate and persist operation-specific change addresses for fractional
    loop-ins and partial withdrawals.
  • Rebuild and maintain an active address index during startup.
  • Import only missing wallet scripts and narrowly recognize duplicate-import
    errors for the expected Taproot output key.
  • Resolve requested funding addresses through the active script index instead
    of reconstructing and scanning every persisted address.
  • Track expiry sweeps by the spent deposit outpoint so unrelated transactions
    paying the same destination script cannot finalize a deposit.
  • Keep loop static deposit as a helper that creates a fresh address and
    prints an lncli sendcoins command for manual funding.
  • Include each deposit's receiving address in RPC and CLI listings.

RPC and Compatibility Changes

NewStaticAddress now derives a fresh receive address on every request and
never funds it. Callers must not assume repeated requests are idempotent or
return the same address.

Wallet funding is handled by the separate FundStaticAddress RPC, exposed as
POST /v1/staticaddr/fund. It creates and funds a fresh address when
send_coins_request.addr is empty, or funds a known existing static address
when it is set. The CLI does not call this funding endpoint: loop static deposit calls NewStaticAddress and prints an lncli sendcoins command
instead of broadcasting a transaction. loop static new keeps its address-only
output.

Funding requires wallet:fund in addition to swap:execute and loop:in,
or an explicit URI grant for FundStaticAddress. Existing execute/in and
NewStaticAddress URI macaroons cannot authorize funding. The default local
Loop macaroon is regenerated on startup when permissions change; distributed
or custom funding macaroons must be explicitly updated.

Because address creation mutates wallet and database state, the RPC permission
changes from swap:read to swap:execute. Operators using custom scoped
macaroons must rebake them accordingly.

StaticAddressSummaryResponse.static_address remains populated with the
legacy/root address for wire compatibility, but is deprecated and must not be
treated as the current receive address. Call NewStaticAddress to derive a
fresh address.

Database and Recovery

The database now records:

  • The static-address owner of every deposit.
  • The generated change address associated with a fractional loop-in.

Migrations backfill existing records using the legacy/root address. Store
reconstruction restores deposit ownership and loop-in and withdrawal change
metadata after restart. Recovery ranks retry ownership by persisted swap
order, keeps ambiguous deposits locked without blocking startup, and retains
every known original input for pending monitors. Cooperative signing rejects
incomplete original-input snapshots rather than calculating change from a
partial historical join.

Stacked PRs

  1. This PR — multi-address persistence, derivation, signing, RPC, and CLI core.
  2. #1215 — withdrawal replacement hardening
    validates and follows the transactions that actually replace multi-address
    withdrawals.
  3. #1217 — loop-in HTLC recovery
    persists the confirmed HTLC output so recovery can rebuild the exact
    server-published transaction.

The address startup and lookup hardening previously isolated in
#1214 has been folded into
this PR, and #1214 is now closed.

Testing

Multi-address integration tests are in
lightninglabs/nautilus#1660,
at 37163f3a.
Its Loop client, looprpc, and swapserverrpc modules all pin this PR's
head 226f0b8e.
Full integration CI run.

Final local validation:

  • Race tests across all static-address packages, loopd, loopdb, liquidity,
    CLI, root package, sweepbatcher, and utils.
  • SQLite/PostgreSQL ownership-recovery and change-validation regressions.
  • Fresh Nautilus server/client builds and full integration suite compilation.
  • Integration cases: offline funding RBF recovery, live early rejection,
    withdrawal RBF, and fractional multi-address loop-in with change.
  • Compatible pinned lint, module checks, docs and SQL generation checks.
  • Commit-message lint and signed commits across the complete PR range.
  • git diff --check.

Release Notes

Release notes document the new address behavior, RPC permission change,
deprecated summary field, migration compatibility, and address lookup
hardening.

@hieblmi hieblmi mentioned this pull request Aug 28, 2026
1 task done
@hieblmi hieblmi self-assigned this Aug 28, 2026
@hieblmi
hieblmi requested a review from starius August 28, 2026 13:56
@lightninglabs lightninglabs deleted a comment from lightninglabs-gateway Bot Aug 28, 2026
@hieblmi
hieblmi force-pushed the multi-address-core branch 4 times, most recently from a1b5ec5 to c74ac6c Compare August 31, 2026 10:37
@hieblmi
hieblmi force-pushed the multi-address-core branch 6 times, most recently from ee1bbba to 5b4bd5b Compare September 3, 2026 06:16
Comment thread docs/release-notes/release-notes-next.md Outdated
@hieblmi
hieblmi requested a review from starius September 3, 2026 08:09
@litbot-9000

Copy link
Copy Markdown
Collaborator

@starius: review reminder

Comment thread swap/keychain_test.go Outdated
Comment thread swap/keychain.go Outdated
Comment thread swap/keychain.go
Comment thread loopd/swapclient_server.go Outdated
Comment thread staticaddr/address/sql_store.go Outdated
Comment thread staticaddr/address/interface.go Outdated
Comment thread staticaddr/address/manager.go Outdated
Comment thread looprpc/perms.go
Comment thread loopd/swapclient_server.go Outdated
Comment thread loopd/swapclient_server.go Outdated
@hieblmi
hieblmi force-pushed the multi-address-core branch 2 times, most recently from 2f594a8 to 205077e Compare September 17, 2026 08:14
@hieblmi
hieblmi requested a review from starius September 17, 2026 08:35
@litbot-9000

Copy link
Copy Markdown
Collaborator

@starius: review reminder

@hieblmi
hieblmi force-pushed the multi-address-core branch 4 times, most recently from ad14247 to 5b76079 Compare September 28, 2026 14:40
Create a fresh static address for fractional loop-in change and send its
descriptor to the server. Reconstruct signed HTLCs with the persisted
parameters and verify cooperative batch change by output script.
Multi-address loop-ins sign and construct transactions from the
parameters attached to each deposit and their dedicated change address.
The legacy root address fields therefore became write-only, but
populating them could still abort signing, sweep handling, or recovery
when the root lookup failed.

Remove those fields and lookups, select the FSM from the protocol
version persisted with the swap, and set that version before
constructing new state machines. Keep the root-parameter lookup used by
autoloop expiry calculation and add regression coverage for recovery and
unsupported persisted versions.
Create a fresh static address for partial-withdrawal change and identify
it in the confirmed transaction through its active change-family script,
without assuming output order or count. Record withdrawn and change
amounts by script identity.

Keep all withdrawal outputs in the PSBT without separate signing
metadata while preserving full-withdrawal behavior.
Let loop static deposit create an address and print its lncli sendcoins
command. Add wallet funding for new or existing owned addresses,
validating arguments before issuance.

Preserve addresses and error causes for ambiguous funding outcomes,
including transport failures. Advise checking wallet transactions before
retrying; cover both funding paths and update RPC and CLI docs.
Include the owning static address in every deposit RPC response and CLI
listing. Users can distinguish deposits created by different receive and
change addresses without reconstructing scripts externally.

Calculate blocks until expiry from each deposit owner instead of the
legacy root address, and reject deposits whose owning parameters are
missing. Centralize deposit response conversion and update generated RPC
artifacts, regression coverage, and command replay fixtures.
The CLI previously recognized an uninitialized static-address seed by
searching arbitrary gRPC error text. Any wrapping or wording change
could suppress the L402 backup warning before a user funded a newly
derived address.

Map ErrNoStaticAddress to codes.NotFound at the RPC boundary and
classify that status in the CLI. Retain compatibility with older daemons
only for an exact Unknown-status message, avoiding the broad substring
match, and cover both sides with regression tests.
A static-address account can now receive deposits across multiple
derived addresses, so the singular summary field can no longer describe
the current receive address. Removing or repurposing field 1 would break
existing clients.

Keep the wire value as the legacy/root derivation address, formally
deprecate it, document the expiry as the shared CSV delay, and direct
CLI users to derive a fresh receive address. Rename the server locals to
make the compatibility behavior explicit and regenerate protobuf and
Swagger artifacts.
@hieblmi
hieblmi force-pushed the multi-address-core branch from 8400ee7 to 215ef7b Compare October 27, 2026 14:33
Keep pending deposits owned while historical HTLC confirmations recover.
Release only the calling swap's claims, after an unspent original or
confirmed conflict rules out a confirmed HTLC. Retry temporary wallet
failures and finish old swaps after their deposits are reused.

Claim ownership before initialization. Before startup releases a stale
legacy mapping, recover its unique pending or paid owner and fail closed
on ambiguous claims. Preserve current mappings during valid retries.

Persist unconfirmed funding parents before signing so confirmed wallet
replacements can prove a conflict after restart. Missing history or an
absent legacy snapshot never proves the old HTLC cannot confirm.

Retain replacement expiry-sweep tracking and regression coverage for
crashes, funding replacement, reuse and delayed notifications.
Cover per-deposit address ownership and operation-specific change
outputs across the shared SQL persistence boundary. Reconstruct the
deposit, loop-in, and withdrawal stores to verify ownership and change
metadata survive restart.
The sweep request handler only compared the number of prevouts the
server sent against the number of sweep inputs. A list of the right
length could still contain duplicate outpoints or reference outpoints
the sweep doesn't spend. The prevout fetcher then returns nil for an
input and NewTxSigHashes panics on the nil dereference, taking down
loopd on a malformed server request.

Reject duplicate prevouts while building the prevout map and require a
prevout for every sweep input before computing sighashes.
Use each selected deposit's address parameters when checking quote
expiry. Also check the actual server HTLC expiry against every deposit's
CSV expiry and safety buffer before sharing signatures. Reject unsafe
contracts, abandon their server attempt, and cancel their invoice.
Rename the legacy key family and address parameter alias, reconcile root
terminology, document script map keys and consolidate wallet UTXO
listing. Keep key-family values and underlying script parameters
unchanged.

Share address decoding and script reconstruction, convert identical SQL
rows directly, and use per-deposit expiry for autoloop. Clarify method
and test invariants, include scripts in missing-parameter errors,
preallocate store results and update CLI and RPC documentation.
Keep address creation separate from wallet funding. Require wallet:fund
for the funding RPC. Keep static deposit as a CLI helper that creates an
address and prints an lncli sendcoins command for manual funding.

Report funding timeouts and cancellations as unknown outcomes, retaining
the address and advising callers to inspect wallet transactions first.
Require one legacy address when deposits exist before migration 24
assigns ownership. Use a regular CHECK-constrained guard table on both
backends without an additional PostgreSQL TEMPORARY privilege.

Cover backfills, ambiguous ownership, restricted roles, rollback and
retry. Document recovery only for a verified rolled-back dirty migration
24, retaining the migration framework's existing dirty-version behavior.
Encode each persisted P2TR script using an explicit network in a free
helper. Address rendering does not need the manager or reconstruction of
the signing keys and script tree, and does not imply an ownership check.
Quote selected deposits with one exact active-set lookup, retaining
presence, state, duplicate and expiry checks without rendering history.

Refresh deposits before taking the unspent response snapshot and filter
against active Deposited records. Historical database rows cannot revive
outputs removed during reconciliation or return stale confirmations.
Keep the lowest-ID active address alongside the script index. Publish it
only after successful activation and update it under the index mutex.

Serve public root lookups from the cache with independent parameter,
script and public-key copies. Cover restart recovery, failed imports and
caller mutation so caching preserves readiness and value ownership.
Read wallet UTXOs outside the map lock, then match their scripts
directly against the active address index under a short lock. This
removes the full-index allocation and copy on each poll while
preserving confirmation bounds and allowing address activation
during the wallet RPC.

Test filtering across addresses and verify wallet RPCs do not hold
the map lock.
Load persisted addresses in bounded pages using an ascending ID cursor
during startup and root recovery. Read wallet watches once and publish
the complete runtime index only after every page and import succeeds.
The existing bulk-read API remains available for compatibility.

Test sparse IDs, page boundaries, failed-page recovery and restart
behavior, including the SQL query on SQLite and PostgreSQL.
Replace the issuance mutex with a context-aware gate so queued callers
can cancel without disturbing the current issuer. Keep root creation,
recovery and derived issuance serialized and retryable. A private mutex
now explicitly guards the active script index and root.

Test canceled root and derived waiters, subsequent progress and
concurrent first callers sharing one root.
Remove the blank line before the closing brace flagged by CI. This is
formatting only; test behavior is unchanged.
Compare lnd counters with persisted address and HTLC indices, verify
wallet identity and advance only lagging families before activation.
Preserve equal or ahead counters without creating addresses.

Allow restoration beyond the manager deadline with RPC timeouts,
cancellation and progress logs. Finish listener initialization before
shutdown teardown to avoid leaking REST or racing wait groups. Cover
restoration, interrupted restarts and shutdown order.
A restored wallet may not have derived a static address key. Pubkey
lookup then misses and can fall back to locator (0, 0), producing an
invalid expiry signature.

Pass the deposit locator and omit PubKey so lndclient sends the locator
over the wire. Cover root, receive and change deposits.
Document fresh receive-address derivation, lazy seed initialization,
funding-address lookup hardening, and the swap:execute permission
required by address creation. Regenerate the CLI, gRPC, Swagger, and
man-page documentation and add feature, breaking-change, and recovery
release notes.
Cost polling can retain a fee share before discovery stores another
static deposit from the same transaction. Different scripts caused the
first deposit to receive the entire fee permanently.

Recognize every owned static output through the active address index
before allocating the fee. Preserve value-based shares, remainder
placement, and existing change and unknown-fee behavior.
Recognize legacy root change alongside derived change and preserve
accounting when both outputs pay the root. Reuse the persisted change
address across fee bumps and restarts while retaining old transactions
and creating fresh signing sessions.

Compute withdrawal values once. Cover legacy and derived change, root
destinations, repeated fee bumps, full withdrawals and unrelated
outputs.
Legacy deposit ownership mappings can omit inputs from a partial swap.
Refuse cooperative sweep signing when its original deposit snapshot is
incomplete, so missing inputs cannot hide change owed to the client.

Validate the requested swap as well as swaps discovered by the batched
input lookup. Cover complete, partial, and unmapped legacy joins with
real store regressions.
@hieblmi
hieblmi force-pushed the multi-address-core branch from 215ef7b to 226f0b8 Compare October 27, 2026 15:54
@hieblmi
hieblmi requested a review from starius October 28, 2026 09:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants