Skip to content

4762ecf6 - Software-to-BitBox migration wizard with balance transfer - #884

Open
TaprootFreak wants to merge 26 commits into
stagingfrom
feature/bitbox-migration
Open

4762ecf6 - Software-to-BitBox migration wizard with balance transfer#884
TaprootFreak wants to merge 26 commits into
stagingfrom
feature/bitbox-migration

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What

Adds an in-app migration wizard that moves a user from the software wallet to a BitBox hardware wallet in one guided flow, including the wallet balance:

  1. Pair the BitBox — existing pairing sheet, but the wallet is acquired as an uncommitted draft: no row is persisted and the current wallet is not switched at this point. The sheet closes itself once pairing finishes.
  2. Link & register the new address — the wizard first verifies the software wallet's own registration, then authenticates the BitBox address with a freshly minted bearer of the current session (POST /v1/auth; the API's OptionalJwtAuthGuard attaches the address to the same account), and registers it in the share register through a dedicated register step page (EIP-712 signed on the device, mirroring the KYC link-wallet step).
  3. Transfer the full REALU balance — the gasless wallet-to-wallet transfer (EIP-7702), reusing SendProcessCubit unchanged: the software wallet stays the app's current wallet throughout the wizard, so the existing software-only signing gate holds. The wallet row for the BitBox is persisted (deduplicated by normalized address) before any funds move.
  4. Settle, then switch — after the transfer is accepted, the wizard polls a fresh fail-loud balance read and only switches wallets once the source balance reaches zero; a balance that dropped but is nonzero triggers a remainder transfer, and a settling timeout fails closed into a resumable state without switching. The final switch swaps the session token/signature atomically for the new identity and reloads the app state.

Entry point: Settings → "Move to BitBox", visible only for software wallets (same local wallet-mode gate as the seed-backup tile).

Why

Users who started on a software wallet had no in-app path to a hardware wallet — upgrading meant manual re-onboarding and moving funds by hand.

Design notes

  • Resumable at every step. The server-side account link and registration are idempotent, the BitBox wallet row is persisted before the balance transfer, and a zero balance skips the transfer step. An interruption at any point — including a settling timeout or the app being closed mid-flight — leaves the funds either on the still-active software wallet or on an address the app already knows; re-opening the wizard completes the move.
  • The software wallet row is intentionally kept after the move (the seed remains available as a backup); no auto-delete.
  • API is decision authority. Registration routing renders the getRegistrationInfo states 1:1; a pending/manual-review registration stops the wizard before funds move. The only local gates are wallet-mode visibility and BitBox connectivity (physical capabilities). A linking response that does not attach to the account is treated as a retryable link failure, never silently accepted.
  • Auth/session hardening shipped alongside (required for the wizard's dual-identity window): the session auth token is address-bound with a late-commit guard, getAuthToken builds message and request from an atomic wallet snapshot with a bounded identity-change retry, the cached signature is scoped to the exact signed message, and buildSignMessage now mirrors the API's environment-scoped auth message ([dev]_ prefix on testnet) — auth against the dev API had been broken since the API introduced env-scoped sign messages.
  • BitBox addresses are EIP-55-normalized at every write path and the dedup lookup compares normalized values (the SDK's casing is not guaranteed consistent).
  • Deliberate product note: the wizard's transfer step is reachable without the insider unlock that gates the general Pay/Send actions (c23c74f4 - Gate Pay and Send behind a hidden insider unlock #885). It is a tightly-bound self-transfer (recipient fixed to the freshly paired device, amount fixed to the full balance), not the general send surface — flagged here so the gating decision is explicit.

Tests

Full local gate run (codegen chain → flutter analyzeflutter test --exclude-tags golden) on the rebased head: analyze clean, 5365 tests passed. New coverage spans the service layer (linked auth incl. header/body assertions, snapshot retry and identity-flap exhaustion, address-bound token cache, message-scoped signature cache, fail-loud balance fetch, wallet draft/persist dedup incl. casing), the complete wizard state machine (every failure branch, settling outcomes, close-mid-flight guards, single-flight retry, zero-balance and already-registered re-entries), the register step cubit, page-manager routing and PopScope matrix, settings-tile visibility per wallet mode, and responsive matrix + surface-catalog entries for every new sticky-CTA view.

@TaprootFreak
TaprootFreak force-pushed the feature/bitbox-migration branch from 2cf15fc to 64267a3 Compare August 3, 2026 16:21
- authenticateLinkedAccount: POST /v1/auth for a new address carrying the
  current session's bearer token, so the API links the address to the same
  account (OptionalJwtAuthGuard); 409 surfaces as AddressAlreadyLinkedException
- bearerTokenOverride on authenticatedGet/Put/Post: explicit-token calls skip
  the 401 refresh (a refresh would mint a token for the wrong identity)
- acquireUncommittedBitboxWallet/persistBitboxWallet: uncommitted draft with
  id-0 sentinel, idempotent persist deduplicated by address, no current-wallet
  switch (mirrors the software-wallet draft/commit pair)
- getRegistrationInfoWith/registerWalletFor: registration flow in an explicit
  token+account context for the migration wizard
- buildSignMessage now mirrors the API's environment-scoped auth message
  ([dev]_ prefix on testnet) — auth against the dev API was broken since the
  API introduced env-scoped sign messages
Settings entry (software wallets only) opening a guided wizard: pair the
BitBox as an uncommitted draft via the existing connect sheet, link the new
address to the current account and register it in the share register
(one-tap, EIP-712 on the device), transfer the full REALU balance through
the unchanged gasless SendProcessCubit (software wallet stays current
throughout), then persist-deduplicated wallet row switch + session swap.
Wizard is resumable at every step; a missing balance read fails loud
instead of silently skipping the transfer.
…test suite

A definitive (non-retryable) SendProcessFailure inside the embedded transfer
flow left the user stuck: disabled retry button while the transferring state
also blocked the system pop. The wizard cubit now leaves the transfer flow
into its own retryable failure state, and retrying runs a fresh
persist-and-prepare pass (balance re-read) instead of blindly re-sending the
dead intent. The register retry now restores the stored RegisterReady state
first — the previous pending-retry closure was a no-op against the state
guard. Success-branch context use moved ahead of the await branch
(use_build_context_synchronously).

Adds the full wizard test suite: cubit state machine incl. every failure
branch and the zero-balance / already-registered skips, page-manager routing
per state, embedded-transfer listener behaviour, settings tile visibility,
responsive matrix tests and surface-catalog entries for every sticky-CTA
view.
mocktail's registerFallbackValue takes no type parameters; the explicit
arguments were an analyzer error (wrong_number_of_type_arguments_method).
…test

The cancelPairing test asserted before the stream listener microtask ran;
the sheet test tapped the ConnectBitboxView cancel button, which pops via
go_router, without a GoRouter in the tree — mount the manager on a
single-entry GoRouter stack (same pattern as connect_bitbox_view_test).
- BalanceService.fetchBalance: fresh fail-loud read for money-moving flows —
  throws on transport/non-200/parse instead of serving the stale cache the
  polling path may hold
- session auth-token slot is now address-bound and getAuthToken discards a
  late auth response when the active wallet changed mid-flight, closing the
  race where a stale software refresh could overwrite the migrated BitBox JWT
- signature cache is scoped to the exact signed message, so legacy testnet
  signatures invalidated by the environment-prefixed sign message fall back
  to a fresh sign instead of looping on rejected auth
- @no-integration-test annotations on the BitBox address acquisition paths
- import order fix in the registration service test
- register step is its own page + cubit (MigrateRegisterCubit, mirror of the
  KYC link-wallet step) per the multi-step rule; parent keeps routing and
  exposes draftAccount/linkedJwt fail-loud
- linking now pre-checks that the software wallet is registered, always mints
  a fresh JWT (refreshAuthToken) so an expired bearer can never silently
  sign the BitBox address up as a separate account, and treats
  newRegistration-after-link as a retryable link failure
- transfer success no longer completes the wizard directly: a settling poll
  re-reads the fresh source balance (fail-loud fetchBalance) and only
  switches wallets at zero, re-prepares a remainder transfer if the balance
  dropped but is nonzero, and fails closed into a resumable timeout state
  otherwise
- embedded transfer failure branch extracted as MigrateTransferFailureView
  with surface-catalog entry and full responsive-matrix coverage (workaround
  comment removed)
- pairing sheet closes itself on finish; retry() is single-flight with an
  immediate busy state; intro balance punctuation moved into the template
…ites

The address-bound token slot change had not been carried into the brokerbot
and buy-payment-info suites.
Five blockchain-api suite call sites were missed by the address-binding
change; ClientException has no const constructor.
Settling remainder case now stubs a genuinely lower follow-up balance,
S.current is only read after localization is pumped, the PopScope matrix
targets the outer manager scope (the register step page nests its own),
the malformed-JSON brokerbot group gets the wallet stub its sibling setup
already had, and the auth-service suite binds cached tokens to the stub
wallet address and instruments saveSignature calls explicitly.
- prepare path now uses the fail-loud fetchBalance (a swallowed refresh error
  can no longer surface a stale zero and finish the wizard with funds left)
- settling follow-ups and retry actions run through a guarded wrapper that
  re-installs the retry on failure instead of stranding a busy state; the
  settling timer survives fallible follow-ups
- close() invalidates the settling generation and finishMigration re-checks
  after every await, so a wizard closed mid-flight cannot half-execute the
  identity switch
- getAuthToken builds message and request from an atomic snapshot, commits
  only against it and gives up after three identity changes (closes the
  A-B-A cache poisoning window)
- BitBox addresses are EIP-55-normalized on every write and the dedup lookup
  compares normalized (the SDK's casing is documented as inconsistent)
- register failure surface is public with catalog + matrix coverage; register
  step receives account/bearer via constructor instead of live parent reads;
  misleading injection comment corrected
fromHex rejects mixed-case input with an invalid checksum by design;
uniform-case input skips the check.
healCurrentBitboxAddress now EIP-55-normalizes like the create/acquire
paths (with a lowercase-device-address test); the DfxKycService injection
comment no longer claims it is the smallest registered auth service; the
close() comment states the actual guarantee (no rollback of completed
side effects, later re-entry completes the move).
- the auth snapshot path no longer falls back to a potentially locked stale
  account object: an identity change during unlock now throws a file-private
  marker that the bounded retry loop catches with a fresh snapshot
- finishMigration passes the signed message scope to saveSignature so the
  cached BitBox signature survives the message-scoped cache check
- all three BitBox address write paths validate and normalize on the
  lowercased raw address, so mixed-case device output is normalized instead
  of rejected as unavailable
The stubs' reported wallet address and the account's actual address are
compared since round 3 — bind the stub account to the fixture address, use
a checksum-neutral digits-only fixture in the wire-surface group, and give
the hanging-account timeout test the account's real derived address.
…th path

getSignature and getAuthResponse previously delegated to the snapshot
helpers without catching the file-private identity marker — a mid-unlock
wallet switch would have leaked it. A shared _withIdentityRetry helper now
wraps all three public entry points with the bounded fresh-snapshot retry
and the readable fail-loud exception, so the marker genuinely never leaves
the file.
The persisted fixture now shares the draft's address (matching
persistBitboxWallet's behaviour) and buildSignMessage is stubbed on the
exact address, so a signature saved under a foreign address or a wrong
message argument fails the test.
@TaprootFreak
TaprootFreak force-pushed the feature/bitbox-migration branch from f8bc49a to da58abb Compare August 3, 2026 20:59
@TaprootFreak

Copy link
Copy Markdown
Contributor Author

Review process: 6 full passes across two dimensions (conventions/quality and logic/correctness) until zero findings, each pass re-checking the fix commits of the previous one.

Fixed along the way, beyond the initial implementation: a money postcondition for the transfer (settling poll on a fail-loud fresh balance read before the wallet switch, remainder re-transfer, fail-closed timeout), link hardening (fresh bearer + source-registration precheck so an expired token can never silently attach the device to a separate account), an address-bound session token with an atomic-snapshot auth retry (closes an A-B-A cache poisoning window), message-scoped signature caching (keeps testnet auth working across the env-scoped sign-message change), EIP-55 normalization on all BitBox address writes, an escape hatch for terminal transfer failures, single-flight retries, close-mid-flight guards, the register step extracted as its own page + cubit per the multi-step rule, and full responsive-matrix/catalog coverage for every new sticky-CTA surface.

One deliberate product note is in the PR description: the wizard's tightly-bound self-transfer is reachable without the insider unlock that gates general Pay/Send (#885).

@TaprootFreak
TaprootFreak marked this pull request as ready for review August 3, 2026 21:14
github-actions Bot and others added 6 commits August 3, 2026 23:38
The baseline commit was pushed by the actions bot, whose default-token
pushes intentionally start no workflow runs.
The keep-message and drop-message branches of saveSignature's optional
third argument and the settling timeout reached through consecutive poll
errors were unexercised.
Eleven baselines matching the repo's per-screen golden convention: intro,
register, registration pending, transfer ready/in-flight/failure, settling,
settling timeout, success and both failure variants. PNGs are rendered by
the self-hosted runner via golden-regenerate.
@TaprootFreak TaprootFreak added the tier3:full Opt-in: run Tier 3 Maestro handbook flows on this PR label Aug 4, 2026
@TaprootFreak TaprootFreak reopened this Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

tier3:full Opt-in: run Tier 3 Maestro handbook flows on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant