Skip to content

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

Merged
HashEngineering merged 16 commits into
feat/kotlin-sdk-phase1from
fix/kotlin-sdk-asset-locks
Aug 17, 2026
Merged

feat(credits)!: SDK-only Buy Credits — background recovery + spend-everything MAX#1536
HashEngineering merged 16 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: 7215f56b-ffd4-4a64-9847-b8ef8682fb74

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
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

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

@bfoss765

Copy link
Copy Markdown
Contributor

The pre-cutover dependency does not apply

Flagging this because it was raised as a shipping dependency on this PR (point 6 of the earlier review): that #1536 removes the only in-app retry path for pre-cutover users holding an unused dashj-era top-up, so asset-lock rediscovery must ship in a pinned SDK before or soon after this reaches them.

There is no pre-cutover user. The rollout decision is that the SDK owns the wallet for every user-facing feature immediately — new wallets, upgraded wallets and restored wallets alike. Every install is effectively CUT_OVER from first launch. The graded CutoverState values are development scaffolding, not a description of a field population.

We have documented this directly in CutoverStateMachine.kt on feat/kotlin-sdk-phase1, with a TODO to delete the state machine once no code path has dashj owning L1, because this distinction has repeatedly led both people and AI agents to invent dependencies on a cohort that does not exist.

One nuance that survives, and is worth stating precisely: a user who topped up in 11.9 and then upgraded does still hold a legacy asset lock, and they are SDK-owned immediately. But their dashj retry loop cannot run in that state either — dashj is held. So #1536 is not what strands that lock; the cutover already did. Deleting the loops removes dead code, not a working recovery path.

Rediscovery remains worth having for exactly that population, but it is decoupled follow-up, not a gate on this PR.

Review notes

Read the purchase and recovery paths in full. The fund-safety design holds up:

  • PerformTopUpWorker runs as unique work, so a lock screen, rotation or process death cannot cancel mid-flight, and a double tap attaches rather than buying twice.
  • The MAX convergence retry fires only on NotBroadcast with REASON_PRE_BROADCAST_BUILD_SHORTFALL / REASON_PRE_BROADCAST_ASSET_LOCK_SELECTION — provably pre-broadcast, nothing on the wire — and is one-shot, with the PLATFORM_TOP_UP_FLOOR_DUFFS guard preventing a retry that Core accepts and Platform deterministically rejects. Sizing the reserve from the SDK's own eligible-UTXO query rather than dashj's spendableUtxoCount() is the right call post-cutover.
  • Ambiguous never re-runs the purchase; it hands off to ResumeTopUpsWorker, which resumes only the tracked lock from its persisted outpoint and never builds new funding. That is the property that makes reruns safe.
  • drainPendingTopUps isolates per-lock failures, treats already-consumed as terminal rather than retrying forever, and returns "failed" (retryable) rather than dropping when locks exist but no identity does.

Two minor observations, neither blocking:

  1. retryableShortfall already requires first is NotBroadcast, so the if re-tests it — harmless, just redundant.
  2. isAlreadyConsumed message-matches "already completely used" down the cause chain. Documented as temporary pending the SDK reconciling the local row, with the bounded 8-hop walk — fine as written, but it will silently stop working if that wording changes upstream. Worth a follow-up once the typed error covers the Platform-side case.

The branch is currently CONFLICTING against its base and needs a rebase before it can merge.

HashEngineering and others added 16 commits August 17, 2026 08:13
… 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>
…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>
Rebasing onto the current feat/kotlin-sdk-phase1 (identity-balance
display, updateView re-entry crash fix, evidence-based preflight):

- The eligible-UTXO COUNT is rebuilt over the base's shared join spine
  with the SAME lockCount-parameterized finality term and the SAME
  persisted-IS-lock evidence as the eligibility sum
  (eligibleAssetLockUtxoCountSql), so the MAX fee reserve is sized from
  exactly the population the sum (and the engine's selection) sees.
- Drop the base's `viewModel.isAssetLock = true` dashj tripwire: the
  dashj purchase path it guarded is deleted; handleGo is overridden to
  the SDK worker and can never reach signAndSendPayment.
- Remove a duplicate flat ELIGIBLE_ASSET_LOCK_DUFFS_SQL that the replay
  reintroduced beside the base's parameterized one.
- Review nit: retryableShortfall already proves NotBroadcast — drop the
  redundant re-test in the MAX retry condition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@HashEngineering
HashEngineering force-pushed the fix/kotlin-sdk-asset-locks branch from 3c51d0e to 7d0b392 Compare August 17, 2026 15:32

@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.

Reviewed at head 7d0b39243 (post-rebase; review round 2 counting the earlier cutover-dependency comment).

Verified against the real integration line, since the rebase landed today:

  • Merges clean onto the current feat/kotlin-sdk-phase1 head (e13129029, pushed today — includes the metadata/backfill and Buy Credits identity-balance work): zero conflicts.
  • Compiles on the merged tree (:wallet:compile_testNet3DebugKotlin).
  • The PR's own tests pass on the merged tree: SdkTopUpRecoveryServiceTest 13/13.

The earlier note stands and is now moot in the right direction: the rollout has no pre-cutover population, so the SDK-only Buy Credits path is the shipping path from first launch, and the background recovery + spend-evidence work here is exactly what that requires.

Approving. Merge whenever you're ready — it's your PR.

@HashEngineering
HashEngineering merged commit f007915 into feat/kotlin-sdk-phase1 Aug 17, 2026
3 checks passed
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