Skip to content

feat(credits)!: SDK-only Buy Credits — background recovery + spend-everything MAX - #1536

Open
HashEngineering wants to merge 15 commits into
feat/kotlin-sdk-phase1from
fix/kotlin-sdk-asset-locks
Open

feat(credits)!: SDK-only Buy Credits — background recovery + spend-everything MAX#1536
HashEngineering wants to merge 15 commits into
feat/kotlin-sdk-phase1from
fix/kotlin-sdk-asset-locks

Conversation

@HashEngineering

@HashEngineering HashEngineering commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

What

Buy Credits becomes SDK-only end to end (MO-998 / #1520 Phase 1B): the dashj purchase path is deleted, the purchase runs as unique background work with crash/interruption recovery, and MAX ("spend everything") works — using the same design the shielded Internal Transfer screen already ships.

Based on feat/kotlin-sdk-phase1 — independent of #1535 (feat/maya-sdk-route); the two can merge in either order.

Purchase pipeline

  • The purchase runs as UNIQUE WorkManager work (PerformTopUpWorker): a lock screen, rotation, or process death cannot cancel it mid-flight, and a double tap attaches to the running work instead of buying twice. The screen only observes.
  • Interrupted attempts are completed in the background by ResumeTopUpsWorker from the SDK's tracked asset lock — the resume gate re-consumes the same lock, so no double pay.
  • A Platform "output already completely used" rejection is treated as terminal, not retryable: those credits provably landed on an earlier attempt. Without this, one stale tracked lock blocked every future purchase (observed live). The SDK-side root fix is a separate platform branch.
  • Confirmed top-ups get a credited state in transaction details.

MAX — the Internal Transfer pattern

MAX fills the FULL spendable balance; the worker submits it as-is and, when the asset-lock coin selection comes up short pre-broadcast (nothing spent, selection released), retries ONCE with an estimated fee reserve withheld. Over-reserve is lossless — the builder returns the excess as change. Differences from the shielded original, each forced by a live testnet failure:

  • The retry matches classifyBroadcastFailure's verdict (two named, provably-pre-broadcast reasons), not an engine message — the top-up build's shortfall text differs from the shielded path's, and matching the wrong one made the first live MAX fail without retrying.
  • The fee reserve is sized from the SDK's own eligible-UTXO count (a COUNT twin of the preflight's eligibility SQL, shared predicate), not dashj's spendableUtxoCount().
  • The adjusted retry is refused below Platform's 50,500-duff top-up floor rather than stranding a lock Platform deterministically rejects.
  • The funding preflight is MAX-aware: the withheld reserve IS the fee allowance, so the preflighted figure absorbs the fixed headroom instead of stacking on it.

Verified end to end on testnet: full-balance attempt refused pre-broadcast → auto-adjusted (reserve from a 2-UTXO count) → broadcast → chain-locked next block → credited → lock consumed. Also confirmed en route: an InstantSend-locked deposit is spendable by MAX immediately while unconfirmed.

UI fixes found during testing

  • The credits explainer re-appeared on every visit (its "seen" flag was written from the sheet's own lifecycle scope and cancelled by dismissal) — persisted from the ViewModel scope with NonCancellable.
  • The Send button now holds its progress state until the work reaches a terminal state, so every failure shows its dialog on this screen. Closing at the SDK hand-off was tried and reverted — it silenced all post-hand-off failure dialogs; the code comment records why.
  • The silent minimum explains itself; the confirmation screen's unused dashj dry-run figures (and their null-fee crash site) are deleted; BuyCreditsFragment no longer imports dashj at all.

Not in this PR

  • The SDK pin: local testing used 0.1.0-v41int18-maya8-SNAPSHOT (uncommitted pin); the branch builds against the phase1 base's committed pin as well.
  • Display-balance corrections for asset-lock spends (two overlaid-balance queries) are held back pending the platform-side fix (fix/asset-lock-spend-visibility), which makes them redundant.
  • The SDK root causes found during this work are addressed platform-side:

Review response (round 1)

All eight review points are addressed on the branch — fixes in 5c000e201 (scoped stale-work gate replacing the global prune; inclusive 50,500-duff floor end to end; single observer registration), documentation in 5f0ad15c6 (restore caveat on the credited-state readers; lifecycle idiom; imports), and hardening in 3c51d0eb8 (typed shortfall arms). Point 6's dependency (rediscovery before pre-cutover users lose the legacy retry path) is recorded in MO-998. Full per-item detail is in the review-reply comment below.

v41int21 verification (review point 2) — done, from source

The int21 AAR binary isn't in any cache here, but its source line is: bfoss765/pool/qa5-4350. The wallet's exact top-up call path was traced hop by hop on that branch:

SdkTransparentTopUp.topUpIdentityCredits.topUpFromCore → JNI topUpIdentityFromCoreplatform_wallet_top_up_identity_with_funding_signerIdentityWallet::top_up_identity_with_fundingresolve_funding_with_is_timeout_fallback(FromWalletBalance)create_funded_asset_lock_proofbroadcast_funded_asset_lockbuild_asset_lock_transaction_with_funding, whose coin-selection error is wrapped by map_builder_errorPlatformWalletError::AssetLockInsufficientFunds (every shortfall shape, including zero-candidate NoUtxosAvailable) → FFI code 29 (ErrorAssetLockInsufficientFunds; the duff amounts travel in the message "asset lock coin selection is short: …") → Kotlin 29 -> DashSdkError.PlatformWallet.AssetLockInsufficientFunds(message, cause) — the same class, nesting, and constructor this branch's new typed arm matches.

Confirmed against the built artifact too: with the local pin on 0.1.0-v41int21-maya10-SNAPSHOT, the wallet compiles and the platform-service test sweep is green (912 tests, 0 failures), including the typed-shortfall classifier test constructing int21's actual error classes.

Conclusion: on the int21 line the MAX retry survives twice over — the typed arm added in 3c51d0eb8 catches FFI 29 regardless of message text, and the pre-existing message arm still matches the wording. CoreInsufficientFunds (22) does not occur on the top-up path (it's the atomic Core-send selection); its typed arm stays as cross-path armor.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 43733ecd-d388-41d5-95a9-0350d24b76a3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

HashEngineering and others added 12 commits August 10, 2026 08:26
… top-up rescue

The SDK top-up (SdkTransparentTopUp, post-cutover Buy Credits) is a
fused build+credit call whose in-process resume gate only helps if the
user re-enters the flow. This adds the restart-surviving half:

- SdkTopUpRecoveryService: one pass lists the SDK's tracked top-up
  locks (the two resumable funding types) and resumes each from its
  persisted outpoint via resumeTopUpWithExistingAssetLock — Rust owns
  rebroadcast/proof/consumption, so a pass is idempotent and a crash
  mid-run or duplicate enqueue is harmless. Includes a no-boot pending
  check for the sync sweep (never starts the SDK just to probe).
- ResumeTopUpsWorker/Operation: payload-free WorkManager job — no txid,
  no identity, and no wallet password in WorkManager's database (the
  legacy TopupIdentityWorker stores one); unique KEEP work, network
  constraint, exponential backoff. Enqueued when SdkTransparentTopUp
  reports an Ambiguous outcome and from the checkTopUps sweep whenever
  tracked locks are pending.
- checkTopUps: documents the scoping invariant (the legacy scan only
  ever sees dashj-created top-ups — the SDK derives keys the dashj
  chain never watches; this body retires with the dashj engine), and
  re-announces legacy top-up txs that provably never reached the
  network (SELF + PENDING + zero broadcast peers — the port-9999 field
  failure) while dashj still owns the peer group. An unconfirmed lock
  also blocks the auto-cutover, so rescuing these directly shortens the
  dual-running period.
- signAndSendAssetLock stays the pre-cutover dashj builder; its top-up
  key is now issued inside the dashj branch only, so no key index is
  burned when the flow does not reach the dashj build.

Restore recovery is deliberately absent: it requires the SDK to record
the top-up locks its chain scan already recognizes (platform ask on
MO-998); once that lands, this worker completes rediscovered locks with
no further wallet changes.

16 host tests. MO-998 / #1520

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SDK top-ups have no topups-table row, so the detail screens now derive
credited state from the SDK itself: a lock still in the recovery queue
is pending, gone is credited (SdkTopUpRecoveryService.isTopUpPending +
TransactionResultViewModel.sdkTopUpCredited). The OP_RETURN output row
of an SDK top-up is labeled 'Platform credits (…)' like the dashj path
always did, instead of the raw script name. The legacy
TopupIdentityWorker status observers (log-only plus one error flag) are
dropped from both screens — that worker is deleted in a follow-up
commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A lock screen cancelled a purchase mid-flow in testing: the fragment ran
the SDK top-up in its own lifecycle scope. PerformTopUpWorker now owns
the purchase — input is the amount only (no wallet password, no txid in
WorkManager's database; the SDK signs internally), one unique KEEP work
name so double-taps attach instead of buying twice, and the screen just
observes WorkInfo (spinner/success/failure/unconfirmed). A rerun after
process death cannot double-pay: SdkTransparentTopUp's resume gate
matches the already-broadcast lock first, and an unconfirmed outcome
hands off to ResumeTopUpsWorker. Buy Credits is SDK-only from the go
handler down (dashj branch removed from the fragment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Management rule: a replaced function loses its dashj implementation in
the same PR. Deleted: signAndSendAssetLock + the isAssetLock dry-run arm
(SendCoinsViewModel), createAssetLockSendRequest (SendCoinsTaskRunner),
getNextKey (topup-chain key issuance), and TopupIdentityWorker/Operation
(the legacy per-txid retry job that stored the wallet password in
WorkManager). Pre-cutover Buy Credits now refuses cleanly via
SdkTransparentTopUp's fail-closed gate instead of building with dashj.
checkTopUps recovery and the topups table STAY — they credit legacy
top-ups and can only retire once the SDK can adopt locks it did not
create (MO-998 upstream ask).

MO-998 / #1520

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n dismissal

The write lived only in the dismiss() override, so any close that
bypasses it (swipe-down, tap outside, back) never saved the flag and the
explainer re-appeared on every Buy Credits tap; even the button path
wrote during teardown, racing the dialog's destruction. Mark it shown
the moment it is displayed instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…alog

The Send button shows a progress circle (taps swallowed) only until the
purchase worker reports it has handed the buy to the SDK (a progress
marker set just before the SDK call) — from that point the outcome no
longer needs the screen: success still auto-closes, failures still
dialog, and the recovery worker owns anything interrupted. The shared
enter-amount component gains an optional setContinueLoading(loading)
overlay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
checkTopUps now does one thing: wake the SDK recovery worker when
tracked locks are pending. The two dashj-era loops — the unused-rows
retry and the once-per-start key-chain scan (with the never-broadcast
re-announce) — are deleted, along with their tests. Uncredited dashj-era
top-ups are no longer retried by the app; they become recoverable again
when the SDK gains chain rediscovery of asset locks (pending platform
change), surfacing on the same recovery queue. Funds are never lost in
the interim: the locks sit on chain, claimable by this wallet's keys.

MO-998 / #1520

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BuyCreditsFragment now imports no legacy platform-library classes and no
dashj machinery beyond Coin arithmetic and the shared fee-preview types
(Phase 3 neutral-money-type material).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Platform's own rejection of a consumed asset lock does NOT arrive as the
SDK's typed AssetLockAlreadyConsumed error — it is a Generic protocol
error reading "output N already completely used" (the wording the legacy
dashj path matched on). Two live consequences on testnet:

- the recovery worker classified it as a retryable failure, so
  WorkManager backed off forever on a lock whose credits had landed;
- the purchase resume gate classified it as Ambiguous, so once a stale
  consumed lock sat in the SDK's tracked list EVERY Buy Credits attempt
  failed with "outcome unconfirmed" and no new top-up was possible.

isAlreadyConsumed() now matches both shapes (down the cause chain, since
the JNI wraps it). The recovery worker reports it terminal; the resume
gate drops that specific lock and retries once, taking the fresh-build
branch. Safe by construction: the rejection proves the lock's outputs are
spent, so no double-pay is possible, and the skip is keyed to that one
outpoint so it cannot loop.

Root cause is upstream: nothing marks a lock consumed locally when
Platform reports it used (MO-998 ask). These are the client-side
mitigations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four on-device findings from the testnet round:

- The credits explainer re-appeared on every visit: its "seen" flag was
  written from the sheet's own lifecycle scope, so dismissing it
  immediately cancelled the write. It now persists from the ViewModel
  scope with NonCancellable, which outlives both the sheet and the
  screen. (Day-one bug from PR #1338 — the write only ever ran on the
  button path, never on swipe/back/outside-tap.)
- The Send button's spinner was gated on the worker's "handed to the SDK"
  marker, which fires ~20ms after the tap while the purchase takes
  seconds, so it flashed invisibly. It now holds for the whole purchase,
  the button is disabled while busy (standard disabled styling), the
  balance observer can no longer re-enable it mid-flight, and on success
  it stays disabled through to the screen closing.
- Whole-balance ("Max") top-ups are refused with an explanation instead
  of approximated: the SDK's top-up call takes an exact amount and its
  send-all mode is not reachable through the FFI yet (rust-dashcore #915
  + the key-wallet flag, MO-998). Tapping MAX explains immediately —
  before the PIN prompt and without changing the amount, via a new
  opt-in EnterAmountFragment.onMaxVetoed hook — typing the full balance
  shows the same text inline, and the purchase path refuses as a
  backstop.
- The pre-existing silent minimum now explains itself: below 0.0005 DASH
  the Continue button greyed out with no message. One constant drives
  both the button rule and the text so they cannot drift.

Also keeps the null-fee guard on the confirmation screen: post-cutover
the dry run does not complete the tx, so tx.fee is null and Max crashed
after the PIN prompt.

MO-998 / #1520

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The screen is SDK-only now, so nothing here needs bitcoinj:

- money types switch to org.dash.wallet.common.money.Coin, dropping the
  toDashjCoin() round-trips; the one conversion left reads the base
  ViewModel's still-dashj maxOutputAmount via toNeutralCoin();
- the dry-run block in showPaymentConfirmation is deleted outright. Every
  value it computed (amount, total, exchangeRate, amountStr, fee) was
  assigned and never read — ConfirmTopUpDialogFragment takes no arguments
  and reads currentAmount from the shared ViewModel — so this removes the
  null tx.fee crash site rather than guarding it. The caller already
  checks dryrunSendRequest != null, so the local guard went too;
- the InsufficientMoneyException check becomes a plain
  SendCoinsViewModel.isInsufficientFunds boolean, keeping the dashj
  exception type inside the ViewModel for Phase 3 to deal with.

Compile-checked only; not yet exercised on device.

MO-998 / #1520

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e + one fee-adjusted retry

Replaces the MAX refusal with the same design the shielded Internal
Transfer screen already ships (ShieldedTransferExecutor.submit): MAX
fills the FULL spendable balance, the worker submits it as-is, and when
the asset-lock coin selection comes up short pre-broadcast (nothing
spent, selection released) it retries ONCE with an estimated fee reserve
withheld. Over-reserve is lossless — the builder returns the excess as
change. The exact L1 fee is unknowable app-side, which is why the retry
converges where any precomputed amount cannot.

Differences from the shielded original, each forced by a live testnet
failure or a top-up-specific constraint:

- The retry matches the CLASSIFIER's verdict, not an engine message.
  The top-up build surfaces its shortfall as key-wallet's builder text
  ("Coin selection error: Insufficient funds…"), not the shielded
  path's "asset lock coin selection is short" — matching the latter
  alone made the first live MAX fail without ever retrying. The two
  provably-pre-broadcast retryable reasons in classifyBroadcastFailure
  are now named constants and the worker keys on them.
- The fee reserve is sized from the SDK's OWN eligible-UTXO count — a
  COUNT twin of the preflight's eligibility SQL (shared predicate, so
  they cannot drift) — not dashj's spendableUtxoCount(), which counts
  coins the asset lock can never select and can be stale post-cutover.
- The adjusted retry is refused below Platform's 50,500-duff top-up
  floor rather than broadcasting a lock Platform deterministically
  rejects (which would strand the whole balance).
- The funding preflight is MAX-aware: it demands fee headroom ON TOP of
  the amount, which a full balance can never satisfy — and the withheld
  reserve IS the fee allowance, so a MAX preflights the fee-adjusted
  figure with the headroom absorbed (live failure two: reserve and
  headroom stacked, rejecting every MAX under ~33 UTXOs).

The screen keeps its spinner until the work reaches a terminal state so
every failure still shows a dialog HERE — closing at the SDK hand-off
was tried and reverted (it silenced all post-hand-off failure dialogs);
the comment records that so it is not reintroduced casually. A MAX
purchase waiting on a chain-locked block legitimately spins for minutes;
the purchase survives the screen either way (unique work + recovery
worker).

Verified end-to-end on testnet: MAX over a 1,000,737-duff balance —
first attempt refused pre-broadcast, auto-adjusted to 999,545 (reserve
1,192 duffs from a 2-UTXO count), broadcast, chain-locked next block,
credited, lock consumed. Also confirmed en route: an InstantSend-locked
deposit is spendable by MAX immediately while unconfirmed — the SDK's
require_final_inputs is not a wait-for-confirmation rule.

The success log now names the amount actually sent, not the requested
one. The dead EnterAmountFragment.onMaxVetoed hook and the
buy_credits_max_not_supported string are removed with the refusal.

MO-998 / #1520

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@HashEngineering
HashEngineering force-pushed the fix/kotlin-sdk-asset-locks branch from 310001a to fe43bbc Compare August 10, 2026 15:31
@HashEngineering
HashEngineering changed the base branch from feat/maya-sdk-route to feat/kotlin-sdk-phase1 August 10, 2026 15:31
@HashEngineering
HashEngineering marked this pull request as ready for review August 10, 2026 15:38

@bfoss765 bfoss765 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is arguably the best-protected surface of the three PRs — double-pay is defended in depth (unique work + KEEP, the resume gate re-consumes the same tracked lock after process death, Ambiguous is never re-run as a purchase, the already-consumed skip only proceeds when Platform has proven the old lock spent), and getting the wallet password out of WorkManager's database is a real security improvement. Comments below; the two blocking-flavored ones are coordination/verification rather than defects in the diff.

  1. CoordinationSdkAssetLockFundingPreflight.kt: there is an in-flight refactor on the branch tip that rewrites ELIGIBLE_ASSET_LOCK_DUFFS_SQL into a shared join spine with a parameterized finality term; a merge simulation confirms this file conflicts. When rebasing, please rebuild the COUNT twin over the same spine — if the count query keeps the old finality term while the sum gains the extension, the two populations diverge and the MAX fee reserve gets sized from a different UTXO set than the one the build selects.

  2. Verify on the current AAR linePerformTopUpWorker MAX retry trigger: the retry keys off classifyBroadcastFailure matching two message shapes. On the v41int19+ AAR line the engine raises typed errors for funding shortfalls (CoreInsufficientFunds, FFI 22) — that exact migration silently disabled the send-all adjust-down retry once before. Please verify the top-up asset-lock build's shortfall still carries a matched message on int21, and consider adding the typed arm(s) to classifyBroadcastFailure defensively — otherwise the first live MAX on the new AAR fails without retrying.

  3. PerformTopUpOperation.prune(): WorkManager.pruneWork() prunes every finished work item in the app, not just the top-up chain — tag-observed finished states used by other features (e.g. the username-vote broadcast status readers) can vanish out from under their observers. Consider scoping this rather than a global prune.

  4. BuyCreditsFragment.MIN_TOP_UP = 50_000 vs PLATFORM_TOP_UP_FLOOR_DUFFS = 50_500: the screen accepts 50,001–50,500 duffs, which the floor then rejects — a failure dialog for an amount the UI said was valid. Suggest raising the screen minimum to 50,500 so the "Enter more than %s" message names the real floor. Also, adjusted > PLATFORM_TOP_UP_FLOOR_DUFFS refuses an adjusted retry of exactly 50,500; if the FFI floor is inclusive this should be >=.

  5. BuyCreditsFragment.observeTopUpWork(): called from handleSdkTopUp on every purchase, and each call registers a fresh observer on the same viewLifecycleOwner — a second purchase in one screen session leaves two observers and a later FAILED state shows two failure dialogs. Register once (e.g. onViewCreated) or unregister before re-observing. Related: re-entering the screen mid-purchase shows no spinner until Continue is tapped again — observing from onViewCreated would surface in-flight work immediately.

  6. TopUpRepositoryImpl.checkTopUps: dropping the legacy dashj retry loops is right post-cutover, but it also removes the retry for pre-cutover users holding an unused dashj-era top-up, and SDK chain-rediscovery of those locks is still unlanded — until it lands, those top-ups sit stranded-but-safe with no in-app path. Please make sure the tracking ticket names that dependency explicitly.

  7. SdkTopUpRecoveryService.isTopUpPending / sdkTopUpCredited: "no pending lock" is read as "credited", but after a phrase restore the tracked-lock table is empty, so an actually-unclaimed SDK top-up displays as credited. Display-only, and restore-rediscovery will fix the root — a code comment noting the restore caveat would prevent this being trusted for anything stronger than a label.

  8. Nits: EnterAmountFragment.setContinueLoading uses deprecated launchWhenStarted (repeatOnLifecycle is the current idiom); TransactionResultViewBinder fully-qualifies de.schildbach.wallet.service.platform.sdk.AssetLockKind inline three times — import it.

HashEngineering and others added 3 commits August 10, 2026 17:01
…ngle observer

Three fixes from PR review:

- Replace the app-global WorkManager.pruneWork() with an observer-side
  gate: terminal work states delivered before this view has seen the
  purchase active are leftovers from a previous visit and are ignored.
  A global prune erased finished work states other features observe by
  tag (e.g. the username-vote broadcast status readers).

- Align the Buy Credits screen minimum with the FFI's INCLUSIVE
  50,500-duff floor (was 50,000, which let 50,001-50,500 through to a
  guaranteed refusal), and accept a MAX retry of exactly the floor
  (>= not >). The error message now names the real minimum.

- Register the top-up work observer once in onViewCreated instead of
  per purchase: re-observing stacked observers (two failure dialogs on
  a second failed purchase), and observing only after Continue hid the
  in-flight spinner from a user re-entering mid-purchase.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cle idiom, imports

- Document on isTopUpPending/sdkTopUpCredited that a phrase restore
  empties the tracked-lock table, so "no pending lock" also matches an
  unclaimed top-up: the signal is display-only until chain rediscovery
  of tracked locks lands. Never gate a spend or retry on it.

- setContinueLoading: replace deprecated launchWhenStarted with
  withStarted on the view lifecycle.

- Import AssetLockKind instead of fully qualifying it three times.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s the typed-error AAR line

CoreInsufficientFunds (FFI 22, atomic Core selection) and
AssetLockInsufficientFunds (FFI 29, asset-lock coin selection — the
top-up build's shape) now classify by TYPE to the same named retryable
reasons the MAX top-up's fee-adjusted retry keys off. The message arms
stay as fallback for AAR lines that surface these as WalletOperation
strings.

Both types are raised while building, strictly pre-broadcast: the
asset-lock builder promotes every shortfall shape (including the
zero-candidate NoUtxosAvailable) to the typed error via
map_builder_error.

The new test pins the reason mapping and includes deliberately
unrecognizable message texts, so classification holds on type alone if
engine wording drifts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Thanks for the review — all eight points are addressed. Per item:

1. Preflight SQL / shared join spine — already converged on the current tip, by construction. When this branch rebased onto feat/kotlin-sdk-phase1 it picked up the spine refactor (17c450467, IS/CL-context finality + core_addresses join), and this PR's own diff extracts that predicate into a single shared constant (ELIGIBLE_ASSET_LOCK_PREDICATE_SQL) from which BOTH the SUM and the COUNT twin are built, with the same single bind arg. The two populations cannot diverge — there is only one predicate to edit. If your merge simulation ran against the pre-rebase (maya-based) snapshot, this should now come up clean; if it still conflicts against something newer than the phase1 tip, point me at the branch.

2. MAX retry on the typed-error AAR line — verified from source and hardened (3c51d0eb8). The int21 AAR itself isn't available on this machine to inspect (nothing newer than the int18 line in any local cache, and the artifacts are locally-published rather than on a public repo), so I verified against the newest line you've published: pool/qa5-4350. There, a top-up asset-lock build shortfall surfaces as the typed AssetLockInsufficientFunds (FFI 29) — not CoreInsufficientFunds (22) — via map_builder_error, which promotes every builder shortfall shape including the zero-candidate NoUtxosAvailable; its message is still "asset lock coin selection is short: …", which the classifier already matched, so the retry was surviving on the message arm. Defensively, classifyBroadcastFailure now has typed arms for BOTH 22 and 29, mapped to the same named retryable reasons the retry keys off, with a test that includes deliberately unrecognizable message text so classification holds on type alone. If int21 differs from pool/qa5-4350 on this path, a pointer to the actual branch/AAR would let me close the gap — but the typed arms should hold regardless of message wording.

3. Global pruneWork() — replaced entirely (5c000e201). WorkManager offers no scoped prune, so instead of pruning, the fragment gates on having seen the purchase active in the current view: a terminal state delivered before any active state is a leftover from a previous visit and is ignored. Other features' finished work records are untouched.

4. Screen minimum vs floor — fixed (5c000e201). Confirmed in the FFI source that the floor is inclusive (amount_duffs < MIN_TOP_UP_DUFFS rejects), so: the screen minimum is now PLATFORM_TOP_UP_FLOOR_DUFFS itself (inclusive, via setMinAmount(isIncludedMin = true)), the adjusted-retry check is >=, and the error string names the real floor ("Enter at least %s").

5. Stacked observers — fixed (5c000e201). observeTopUpWork() registers once in onViewCreated; no re-observe per purchase. Your related note is covered too: re-entering mid-purchase now shows the spinner immediately.

6. Stranded pre-cutover top-ups — the dependency is now named explicitly in MO-998: rediscovery must ship in a pinned SDK before (or soon after) this PR reaches pre-cutover users; until then dashj-era top-ups are stranded-but-safe with no in-app path.

7. Restore reads unclaimed as credited — documented at both ends (5f0ad15c6): isTopUpPending carries a RESTORE CAVEAT block ("display-only — never gate a spend or a retry on it") and sdkTopUpCredited states that after a phrase restore "no pending lock" also matches an unclaimed top-up, until rediscovery lands.

8. Nits — done (5f0ad15c6): setContinueLoading uses withStarted on the view lifecycle (only the PR-added call; the file's pre-existing launchWhenStarted uses are out of scope), and AssetLockKind is imported.

🤖 Generated with Claude Code

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.

2 participants