Skip to content

feat(liquid): port Wasabi wallet to Liquid - #1

Draft
Abdullah1738 wants to merge 303 commits into
masterfrom
liquid
Draft

Abdullah1738 wants to merge 303 commits into
masterfrom
liquid

Conversation

@Abdullah1738

Copy link
Copy Markdown
Owner

Long-running integration branch for the Wasabi-on-Liquid port.

Current scope:

  • ordinary noncustodial multiasset Liquid wallet
  • confidential address, asset, transaction, PSET, recovery, and signing support
  • sponsor-free L-BTC-only CoinJoin after the ordinary-wallet gates close

Explicitly excluded from this branch until separately audited prerequisites are satisfied:

  • shared fee sponsorship
  • USDt CoinJoin
  • combined-asset CoinJoin rounds

This draft is an integration and CI surface. It does not claim release or production readiness.

The Liquid wallet home rendered off-Wasabi: bare underlined Send/Receive
hyperlinks docked bottom-center, raw unstyled balance rows, no use of
ContentArea.TopContent or the balance tile. Restyle LiquidWalletView to the
Fluent pattern: ContentArea StretchTopContent, Send/Receive as function
buttons (wallet_action icons) top-right, and a BALANCE TileControl
(CopyableItem + PrivacyContentControl) mirroring the pegged row. Add a
presentation-only PeggedBalanceRow projection + tests. Multi-asset rows and
the History surface are unchanged.
…ition race

Opening a Liquid wallet could fail once with 'node generation changed during
the acquisition' when a testnet block landed mid-acquisition (a transient
race), and a failed open then left the session reporting 'already open or
opening' until restart. OpenWalletAsync is now a thin resilience wrapper over
OpenWalletCoreAsync: RunWithTransientGenerationRetryAsync retries only the
exact transient acquisition message (InvalidOperationException /
ElementsRpcException), bounded to 2 extra attempts with 150ms backoff; all
other rejections surface unretried and the generation fence stays fail-closed.
The failed-open reservation release was already in the provider's catch; a
regression test pins it. 8 new facts.
- Replay payload v3 carries InternalIndexHighWater in authenticated
  plaintext; legacy v1/v2 import internal high-water as 0
- Thread the value through replay open result, persistence handoff,
  load/save results, refresh state capture, state owner, and the
  refresh save fence
- Generic and fenced saves carry both high-waters forward and reject
  either moving backward on a readable current state
- Add LiquidWalletInternalIndexAllocator: generation-fenced, durable
  branch-1 index reservation (no reuse after reopen, gaps allowed),
  exhaustion at 0x7fffffff, no process-local state, no key derivation,
  address generation, send, native, RPC, or UI behavior
…surplus

- New public LiquidWalletUiChangeDestination carries the wallet-owned
  branch-1 confidential change address as a public-safe value
- Send execution scope reserves one durable branch-1 index lazily via
  LiquidWalletInternalIndexAllocator, derives the branch-1 spend script
  from the descriptor account xpub, blinds it with SLIP-77, and builds
  the confidential address with the receive-time factory; cached so both
  facade calls of one send observe the same address
- Facade CreateDestinationBatch appends one change destination per asset
  whose selected total exceeds destination-plus-fee; when no change
  destination is supplied the batch is the byte-identical one-destination
  batch, preserving every pre-existing fail-closed exception ordering
- Executor threads the reserved address into both facade calls; exact
  plan validator balances per asset. No native/FFI/RPC/validator/plan/
  wire/refresh/CoinJoin changes. Unit/direct-executor evidence only
- Replace the free-text 64-hex asset-id TextBox with a ComboBox bound
  from the wallet's balance snapshot (pegged asset first, then issued
  in canonical order), each option showing asset id hex and atomic-unit
  balance with an L-BTC marker on the pegged row
- SelectedAsset drives Recipient.AssetIdHex (the property the plan/sign
  path already consumes); default selection is the pegged asset, empty
  balance set leaves the dropdown empty with no fabricated asset
- Host LiquidSendRecipientView inside LiquidSendView, removing the
  duplicated inline recipient TextBoxes
- Dispatcher-deferred reseed keeps the selection stable across balance
  refreshes (a bound ComboBox clears SelectedItem when ItemsSource is
  replaced); empty initial option set avoids the null-initial OAPH race
- Headless AvaloniaFact evidence renders the real send view and asserts
  the binding, default selection, selection-driven AssetIdHex, and
  reseed-on-refresh behavior. Presentation-only; no facade/plan/sign/
  RPC/broadcast change
- Add LiquidAmountDisplay helper: the pegged asset (L-BTC, protocol-fixed
  1e8 precision) renders as the L-BTC decimal form with Wasabi's
  conventional fixed eight-fraction-digit grouping; an issued asset (no
  known precision, no metadata) stays as the raw atomic-unit count — never
  scaled. One shared helper, used by every display surface
- Wallet home balance tile + per-asset rows now bind BalanceDisplayText;
  fixes the prior mislabel that printed the raw atomic integer with a
  literal ' L-BTC' suffix
- Send asset-picker options and the built spend plan (explicit fee,
  destinations, selected totals) render the pegged-aware display via new
  LiquidSpendPlan{Destination,AssetAmount,Item}ViewModel wrappers
- Additive IsPeggedAsset flag on the spend-plan destination projection so
  the presentation layer can pick its display convention; frozen
  property-set test updated for the additive surface
- History asset-change rows already formatted the pegged amount as the
  signed L-BTC decimal via the core DisplayAmount projection
- Headless AvaloniaFact evidence asserts the exact pegged decimal string
  and the issued atomic-unit string for the balance row and the spend-plan
  asset-amount wrapper. Presentation-only; no facade/plan/sign/RPC/
  broadcast change
…lay payload

Add a durable, authenticated receive-label store keyed by the external
(branch-0) receive derivation index. Labels persist in the sealed replay
payload as a new payload version 4; legacy v1/v2/v3 payloads open and import
an empty label map. The map is canonically ordered by ascending index,
duplicate-rejected, bounded (entry count, per-label and aggregate UTF-8), and
byte-exact under the fixed-time canonicality re-check.

SetLabels is generation-fenced and fail-closed: it loads the current state,
applies the set (removing the entry when empty), and saves under the exact
captured generation+1, rejecting a concurrent generation change. High-waters,
output state, and confirmations are untouched. The published receive material
rebinds NextReceiveLabels from the committed state's durable label map.

No native/RPC/send/sign/CoinJoin change; labels carry no key material.
Thread the published NextReceiveLabels into the Fluent model (read path) and
expose a narrow public SetNextReceiveLabelsAsync command on the application
client that resolves the open session, reads the current next-receive index,
and invokes the generation-fenced receive-label command service (write path).
The Receive view shows the existing durable label joined as Wasabi's
comma-separated convention and saves the edited set (empty clears) through the
durable v4 store, not a process-local dictionary. Fail-closed: command
rejections surface as-is. SaveLabel uses the main-thread output scheduler so
the CanExecute completion reaches the bound button on the UI thread.

No v4 payload/replay/fence/allocator/command-service core change; no native,
RPC, send/sign, CoinJoin, or address-derivation change; labels carry no key
material.
…end flow

Two additive facade projections into the send flow.

Change attribution: the spend-plan destination projection gains an additive
IsWalletOwnedChange flag, true exactly when the destination's canonical address
text ordinal-matches the change address the facade was given for that plan
(attribution of already-composed change only; never alters composition). A
'change' tag shows on the matching row. No flag is fabricated when no change
destination is supplied.

Visual coin control: the raw free-text outpoint field is replaced by a checkable
list of the wallet's selectable outputs. Every row defaults to selected,
preserving the landed empty-field semantics (an empty selection meant every
spendable outpoint funded the plan). Checked rows drive the exact selected-
outpoint hex set the plan/sign path consumes. Never fabricates outputs, never
admits non-wallet outpoints, never bypasses exact-plan validation.

No native/RPC/send-sign/CoinJoin/address-derivation/exact-plan change.
…t asset

Each wallet-home balance row gains a Send action that navigates to the send
flow with that row's asset pre-selected in the asset picker. The recipient
holds the wanted asset id and consumes it in the deferred reseed once the
matching option arrives (the initial option emission is scheduler-deferred);
the held selection then survives later refreshes. The default reseed semantics
for the plain top-level Send path (pegged-first) are unchanged, and the picker
never fabricates an option for an empty balance set.

Presentation-only: the affordance only chooses which asset the picker holds. It
does not pre-fill amount or address, bypass validation, or touch the plan
builder, change composition, signing, RPC, native, CoinJoin, address
derivation, or exact-plan validation.
The two stalling-stream idle-timeout tests raced a short idle timeout against
a short total-request timeout (75ms vs 500ms; 100ms vs 300ms). Under parallel
CPU load the total timeout could win the race and surface the wrong failure
kind/message, making both tests timing-flaky (they passed only in isolation,
which is why the suite excluded one).

The stream never yields data, so the idle timeout is the intended winner.
Widen the margin — keep the idle timeout short but push the total-request
timeout to 10s — so the idle timer always fires first regardless of scheduler
jitter. The assertions (Timeout kind + 'idle timeout' message) are unchanged;
the test intent is fully preserved. The full Liquid filter now runs with no
exclusion: 1209/1209 Debug + 1209/1209 Release (the previously excluded test
now passes in the parallel run).
…ck generation fence

Surfaced by live testnet QA: against the official 23.3.3 node (which lacks the
fork-only getnodegeneration RPC), every wallet refresh threw 'node generation
changed during the acquisition'. The fallback generation observation pinned
ChainstateRevision to 0, so any ~1-minute Liquid block landing between the
before/after observations read as 'same revision, different tip' and threw.

The fallback observation's ChainstateRevision now proxies the observed block
height, so the existing fences tolerate forward-only tip movement (a new block
advances the height and therefore the revision) while still rejecting a height
rollback (the revision regresses) and a same-height tip identity change (the
revision is unchanged but the best-block hash differs). The all-zero startup-id
sentinel is retained: restart detection is genuinely unavailable without
getnodegeneration, and the doc comments say so rather than overclaiming. The
getnodegeneration path is unchanged. The deterministic every-refresh false
positive is gone; the residual mid-refresh throw is now the rare, retryable
transient race the refresh path already handles.

Mutation evidence: restoring the constant revision 0 turns the new
forward-progress test RED with the exact live error; rollback and same-height
identity-change cases stay fail-closed even under the probe.
Surfaced by live testnet QA: every wallet OPEN consumed one external receive
index and durably advanced the high-water (Allocate did allocatedIndex =
highWater; highWater+1 and persisted it). So a label set on a receive address
was durable in the store but never shown again — NextReceiveLabels always
pointed at a fresh unlabeled address after reopen, and the address pool burned
on every app start. Stock Wasabi advances the receive index only on explicit
issuance (getnewaddress / NextReceiveAddress), never on open.

AllocateWithFirstOpenInitialization now PEEKS: it loads the persisted state and
returns the next-receive allocation at the CURRENT external-index high-water
with no +1 and no save, so the next-receive address is deterministic across
opens that observe no intervening issuance or received funds. The
genuinely-absent genesis case still seals LiquidWalletState.Empty at generation
0 and presents index 0 without advancing. Allocate (durable issuance) is
unchanged — there is no production caller that issues addresses beyond open, so
no new public issue API is added. The index-exhaustion fence is still applied
on the peek path; all fail-closed surfaces (absent-file genesis, corrupt-frame,
wrong-key, orphaned .new/.old, generation fence) are preserved exactly.

Mutation evidence: restoring the open-path Allocate turns the new
stable-across-opens test RED (Expected 1, Actual 2) and the genesis test RED.
…ence trips

Live testnet QA surfaced this: the refresh-observation generation fence requires
an exact before/after chain view across its multi-RPC acquisition (correctly — a
block landing mid-acquisition could hide a wallet transaction seen only at the
new tip). But the acquisition ran exactly once and the transient propagated, and
on a ~1-minute-block chain the ~60s candidate fetch window makes a mid-window
block nearly certain, so refresh was structurally unusable on a live chain.

The refresh command service now treats that specific fence trip
("node generation changed during the …") as a retry signal, not a hard failure:
it re-acquires the whole observation (candidate re-discovery + raw fetch at the
new tip, internally consistent per attempt) up to 6 attempts with 400ms
cancellation-aware backoff. Only ElementsRpcException carries the retryable
message; rollback/restart/inconsistency fences and generic RPC failures remain
fatal on the first attempt. The fence itself, accepted-send semantics, the
commit path, and the session/active-wallet fences are unchanged and run once.

Also fixed the review-surfaced disposal leak on the same path: the
final-observation fence block moved inside the try whose catch disposes fetched
raw transactions, so a final-fence trip (or a final status-fetch RPC failure)
now zeroes those buffers instead of abandoning them.

Mutation evidence: removing the message predicate (catching all
ElementsRpcException) turns the non-retryable test red (6 calls instead of 1).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant