feat(credits)!: SDK-only Buy Credits — background recovery + spend-everything MAX - #1536
feat(credits)!: SDK-only Buy Credits — background recovery + spend-everything MAX#1536HashEngineering wants to merge 15 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
… 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>
310001a to
fe43bbc
Compare
bfoss765
left a comment
There was a problem hiding this comment.
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.
-
Coordination —
SdkAssetLockFundingPreflight.kt: there is an in-flight refactor on the branch tip that rewritesELIGIBLE_ASSET_LOCK_DUFFS_SQLinto 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. -
Verify on the current AAR line —
PerformTopUpWorkerMAX retry trigger: the retry keys offclassifyBroadcastFailurematching 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) toclassifyBroadcastFailuredefensively — otherwise the first live MAX on the new AAR fails without retrying. -
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. -
BuyCreditsFragment.MIN_TOP_UP = 50_000vsPLATFORM_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_DUFFSrefuses an adjusted retry of exactly 50,500; if the FFI floor is inclusive this should be>=. -
BuyCreditsFragment.observeTopUpWork(): called fromhandleSdkTopUpon every purchase, and each call registers a fresh observer on the sameviewLifecycleOwner— 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 fromonViewCreatedwould surface in-flight work immediately. -
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. -
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. -
Nits:
EnterAmountFragment.setContinueLoadinguses deprecatedlaunchWhenStarted(repeatOnLifecycleis the current idiom);TransactionResultViewBinderfully-qualifiesde.schildbach.wallet.service.platform.sdk.AssetLockKindinline three times — import it.
…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>
|
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 2. MAX retry on the typed-error AAR line — verified from source and hardened ( 3. Global 4. Screen minimum vs floor — fixed ( 5. Stacked observers — fixed ( 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 ( 8. Nits — done ( 🤖 Generated with Claude Code |
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
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.ResumeTopUpsWorkerfrom the SDK's tracked asset lock — the resume gate re-consumes the same lock, so no double pay.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:
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.spendableUtxoCount().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
NonCancellable.BuyCreditsFragmentno longer imports dashj at all.Not in this PR
0.1.0-v41int18-maya8-SNAPSHOT(uncommitted pin); the branch builds against the phase1 base's committed pin as well.fix/asset-lock-spend-visibility), which makes them redundant.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 in5f0ad15c6(restore caveat on the credited-state readers; lifecycle idiom; imports), and hardening in3c51d0eb8(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.topUp→IdentityCredits.topUpFromCore→ JNItopUpIdentityFromCore→platform_wallet_top_up_identity_with_funding_signer→IdentityWallet::top_up_identity_with_funding→resolve_funding_with_is_timeout_fallback(FromWalletBalance)→create_funded_asset_lock_proof→broadcast_funded_asset_lock→build_asset_lock_transaction_with_funding, whose coin-selection error is wrapped bymap_builder_error→PlatformWalletError::AssetLockInsufficientFunds(every shortfall shape, including zero-candidateNoUtxosAvailable) → FFI code 29 (ErrorAssetLockInsufficientFunds; the duff amounts travel in the message"asset lock coin selection is short: …") → Kotlin29 -> 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
3c51d0eb8catches 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