feat: dashj→Dash Platform Kotlin SDK cutover — Phase 1 (SDK owns L1/L2 from first launch) - #1532
Conversation
…egression) The prior change showed the shielded denomination on the instant/secondary username confirm in a fresh dual creation. That was wrong: the instant name adds no incremental cost — the identity funding (0.3 shielded / L1 asset lock) is the same with or without it and is disclosed on the PRIMARY contested confirm. Showing a price on the instant confirm contradicted 'instant username at no cost' (and the store behavior). Secondary now always resolves to 0.00; the shielded/L1 cost display stays on the primary confirm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t the exit denomination The sheet asks how much to shield, which is the fund-minimum (SHIELDED_USERNAME_FUND_MIN 0.15 / _CONTESTED 0.35 — the 0.1/0.3 Type-20 exit denomination padded for the shielded-spend fee), not the bare denomination (0.1/0.3) that finally leaves the pool. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…icker, entry auth 1) Dismissing a terminal shielded-transfer outcome entered via 'Shield your funds first' unwound the create-username/invite back stack one screen at a time; shield-first entries now clear straight to home (card entries keep the plain finish). 2) The More screen showed the CONTESTED name under the avatar right after a dual creation (profile seeded with the primary; refresh persists the owned instant name) — the under-avatar name now derives from the identity's active username when the dual-voting rule holds. 3) Tapping the requested-username tile flashed and bounced: the XML navGraph eagerly instantiated the request fragment (whose identity observer finishes the activity for a name in voting) before the code set the resolved start destination — graph now set only in code. 4) Opening the shielded transfer screen prompted for auth: createIntent now carries KEEP_UNLOCKED (SendCoinsActivity precedent); auth remains solely on transfer submit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d variant
profileDisplayUsername preferred the identity's activeUsername, which is
the DPNS-normalized secondary label (homoglyphs folded: 'c0ntested11-2').
Once the profile refreshes, profileUsername holds that name's display
label ('contested11-2'); prefer it when its normalized form matches the
active label. The normalized value now shows only in the brief first
render before the profile refresh, instead of persistently.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ool gate The 'shielded pool still syncing' pre-broadcast refusal (nothing spent) surfaced as a red 'network error' dialog. Two fixes: - Fix A: the pool-not-ready reasons are now classified (typed constants + isPoolNotReadyReason) and shown as a calm 'Your shielded balance is still syncing, please wait and try again' dialog, not the error dialog. - Fix B: the Request button follows the LIVE shielded sync status — while the pool is not READY on the shielded path it shows a disabled 'Preparing shielded balance…' and re-enables when ready, instead of accepting the tap from a stale cache and failing at the SDK. The L1 path is never gated on shielded sync status. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The L1/non-shielded submit set usernameRequestSubmitting=true and handed off to CreateIdentityService, which runs out-of-band and never reported back — so the processing dialog stayed up until the user dismissed it manually, even though the identity was already created behind it (the earlier finish-gating fix turned the old screen-finish dismissal into a deadlock for this path). The ViewModel now observes the persisted identity creation state and clears submitting (dismissing the dialog and completing the flow) once it reaches a terminal state (DONE / DONE_AND_DISMISS / VOTING) while a submit is in flight. The shielded path already clears submitting via its executor states — no-op there; a pre-existing DONE with no submit in flight is ignored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…laims into a Type-20 create Adds the shielded-invitation path parallel to the existing L1 asset-lock invite, gated on USE_KOTLIN_SDK_SHIELDED (L1 path byte-identical when off). Inviter: SdkShieldedInviteCreation generates a one-time Orchard key, shieldedTransfers the denomination (0.1 non-contested / 0.3 contested) to its address, and encodes the one-time SK + funding height into the link (InvitationLinkData.createShielded). No L1 asset lock is created. Receiver: CreateIdentityService.createIdentityFromInvitation branches to a shielded claim (createIdentityFromOneTimeKey) that funds the identity directly from the invite note; double-claim is guarded by the nullifier. Decision sheet offers "Create a private invitation" when the pool can already fund an invite, replacing "Shield your funds first". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ansfer stall 35s→40s) The Halo 2 proof plus pool sync routinely runs past the old thresholds, so the hint/stall banners were firing on healthy operations. Tests reference the constants symbolically, so no test breakage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t button
When the shielded funding path is selected and the pool is not yet READY
(the button reads "Preparing shielded balance…"), the shield is still
confirming/syncing — i.e. the user is creating the username before the
recommended ~2h privacy window. Surface a persistent advisory just above
the button ("You should wait at least 2 hrs before creating your shielded
username") that floats above it and stays until the pool reaches READY.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…, SDK-sourced sync header Makes the cutover real and rehearsable while staying inert by default (every install is DUAL_RUNNING; only a deliberate debug COMMIT flips it, ROLLBACK restores). - BlockchainServiceImpl consults CutoverCoordinator.dashjEngineMayStart() once per launch (resolved before onCreateCompleted, which checkService awaits) and holds the dashj L1 peergroup when the cutover is committed — the SDK owns L1, never two live SPV engines. Defaults to starting dashj. - CutoverDebugReadout gains COMMIT_CUTOVER / ROLLBACK_CUTOVER adb triggers (debug builds only) alongside the existing advisory CHECK_CUTOVER, so a supervised rehearsal can drive the real transitions. - Home "Syncing N%" header reverts to a single status and reads from whichever engine owns L1 this launch: the SDK L1 scan after cutover (new pure shadowSyncPercent helper), dashj before. The debug two-engine "DashJ · Kotlin" label is removed. Pre-cutover rendering is byte-identical. Full balance/history/send authority remains dashj-backed — the remaining migration surface; this lands the engine-stop + sync-source flip that the rehearsal exercises, all reversible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… binds fresh A full wipe cleared the DashPay identity config but left the SDK side (bound wallet + binder latch) intact. On the next wallet the stale binding let the PREVIOUS wallet's discovered identity keep driving the DashPay UI — the "Join DashPay" entry points stayed hidden after a reset because the old DONE creation state never cleared. Intermittent, matching a race between an in-flight bind/discovery pass and the clears. L1ShadowSyncService.clearForWalletWipe() runs the recreate teardown (stop shadow + shielded, removeSdkWallet cascade, delete SPV dataDir, reset the binder latch) WITHOUT the rebind, and WalletApplicationExt invokes it in the wipe path — the SDK twin of the existing PlatformSyncService resurrection guard. Wipe only; the restore path re-binds to the restored seed instead. SDK-side state only; dashj untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t hide Join DashPay until restart isPlatformAvailable was set only from the sync-stage flow (observeSyncStage().distinctUntilChanged()), so the DAPI 3-of-3 quorum check ran only when the stage CHANGED. A transient `false` returned once the stage settled at BLOCKS latched forever — permanently hiding BOTH Join DashPay entry points (the home card and the More-screen DashPay section, each gated on isAbleToCreateIdentity = isPlatformAvailable && noIdentity) until an app restart. Observed on-device: identical wallets, one device got `true` and showed the tiles, the other latched `false` and never retried. Adds a self-healing poll in MainViewModel.init: while Platform is unreachable, re-check every 30s until it succeeds, then stop (nothing flips it back to false). Test stubs the new call. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tions When the wallet had a balance and the recovery phrase wasn't backed up, reset showed two back-to-back dialogs with the same body text and the same red "Reset" button — reading as an accidental double-prompt (user pressed the red button twice). Collapsed to a SINGLE confirmation that keeps both safety affordances: the balance in the title and the "back up your seed first" extra action. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After a committed cutover the dashj engine is held, so its balance flow freezes. The home total-balance now sources from the SDK L1 scan (L1ShadowSyncService.latestParity.sdkDuffs) when sdkOwnsL1, falling back to the dashj balance until the first SDK parity report lands. Pre-cutover (every install today) the feed is byte-identical to the original dashj-only path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ted cutover Post-COMMIT the dashj engine is held, so a dashj "send" would commit a tx that silently queues on the dead peergroup and then broadcasts on a later ROLLBACK — the worst failure mode. This makes the send path cutover-aware, with NO dashj fallback anywhere once committed: - SdkL1SendService.cutoverCommitted() reads the persisted CUTOVER_STATE (fail-safe false); a committed cutover enables the SDK send path regardless of the USE_KOTLIN_SDK_L1_SEND soak flag. - Typed overload (main send UI): pure cutoverSendRoute decision — DASHJ pre-cutover (byte-identical today); SDK_BRIDGED for simple pay-to-address (SDK broadcast + synchronous bridge returns the live dashj Transaction); FAIL_CLOSED for custom-selector / send-all with a clear error. - Neutral overload + the SendRequest funnel (BIP70/CrowdNode backstop): post-cutover NotBroadcast throws instead of falling through to dashj. Tests: CutoverSendRouteTest (routing invariants), cutover-gate tests in SdkL1SendServiceTest (state parsing, fail-safe, flag-off-but-committed broadcasts); all send-test harnesses updated for the new collaborator. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-cutover The first live send rehearsal failed closed: the main send UI builds and signs its own SendRequest for the fee preview and submits it straight to the sendCoins funnel, bypassing the typed overload's cutover routing — so it hit the fail-closed backstop (funds provably safe, but no send). extractSdkRoutablePayment identifies the ONE payment a completed SendRequest carries (single foreign recipient, default zero-conf selection, no locked outputs, not send-all, standard scripts) and the funnel now routes it via sendViaSdkBridged; anything else still fails closed with a clear error. Conservative-null extraction is pure and host-tested (simple send, no-change, send-all, custom selector, multi-recipient, send-to-self, OP_RETURN). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cutover holding dashj
Post-cutover rehearsal failure (S22): with the dashj L1 engine held,
checkService() never runs initDashSync()/setMasternodeListManager(), so
the dashj-platform DAPI client had no quorum source — every proof
verification failed ("quorum not found"), each DAPI address got banned
(NoAvailableAddresses; DashPay dark) and the quorum callback spammed
"lateinit property masternodeListManager has not been initialized".
Mapped flow: the platform-mobile rust SDK is created ONCE in the
DapiClient constructor (platformMobileSdkCreateDashSdkWithContext) with
the quorum callback permanently bound to DapiClient's INTERNAL
ContextProvider, which answers from a lateinit
SimplifiedMasternodeListManager set only via
Platform.setMasternodeListManager. (The app-side provider assignment in
PlatformService.init replaces only the Java field — the rust side never
sees it.) The Kotlin SDK's own DAPI proofs are independent: its
TrustedHttpContextProvider fetches quorum keys over HTTPS.
Fix (app-side seam):
- SdkSourcedQuorums.kt: SdkQuorumDataSource fetches the Kotlin SDK's
getCurrentQuorumsInfo (unproved query over the SDK's own DAPI client;
no dashj anywhere in the chain) and caches quorum_hash →
threshold_public_key with merge-on-refresh (a quorum's key is
immutable for its hash, so rotated-out quorums stay resolvable).
SdkSourcedMasternodeListManager synthesizes a SimplifiedQuorumList
from those keys (both hash byte orders — orientation is unverifiable
off-device) and leaves the masternode list empty so the DAPI address
provider falls back to the default HP masternode list.
- BlockchainServiceImpl: when the cutover gate resolves to "hold dashj",
wire platformRepo.platform.setMasternodeListManager(bridge manager) —
one call initializes all three layers (app provider field, DapiClient's
rust-registered provider, DAPI address provider).
- PlatformService: the quorum callback guards the lateinit field
(logged once, returns not-found) and never throws across the JNI
boundary.
Verified: :wallet:compile_testNet3DebugKotlin green; full
:wallet:test_testNet3DebugUnitTest green incl. 13 new host tests for the
parse/cache logic. On-device DAPI proof success post-cutover still needs
the next device window.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The post-cutover quorum bridge built each entry with Quorum(params, llmqParameters, hash, publicKey), whose 3-arg FinalCommitment path leaves the message length unset. On the first addQuorum -> addCommitment -> commitment.getHash() the serializer threw "Length field has not been set in FinalCommitment", every entry was dropped, the bridge served an empty list, and every DAPI proof failed "quorum not found" (observed live on the cutover rehearsal — the provider returned an all-zero key). Build each commitment through the 13-arg constructor instead, which computes and sets length. Only quorumHash and the 48-byte threshold public key matter for proof verification; the signer/valid-member bitsets, vvec hash, and BLS signatures are zeroed placeholders that exist solely so the commitment serializes. Version 3 (BASIC_BLS non-indexed) is the scheme active on mainnet and testnet. Both hash byte orders are still added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Home welcome tile now appears after a shielded (Type-20) username create: the create handoff to RestoreIdentityWorker passes fromCreation=true so completion stops at DONE (like the L1 CreateIdentityService path) instead of over-advancing to DONE_AND_DISMISS, which pre-dismissed the hello card. Genuine device restores keep DONE_AND_DISMISS. - More screen shows the original DPNS label (contested111), not the normalized form (c0ntested111): the contested-name recovery branch now also runs for a directly-found contestable currentUsername, restoring the display .label from the user's own contender document and inserting the UsernameRequest row resolveRequestedUsernameDisplay needs. End restore state for established names is unchanged. - 2-hour shielded-username advisory restyled as a floating toast-style bubble (rounded card, border, elevation) above the button. - Suppress the red insufficient-funds flicker on the enter-username screen while the shielded pool is still preparing (gated in lock-step with the PreparingShielded button state). - Invitation fee screen shows the fee per tile (L1 0.03/0.25; private 0.1/0.3 exit denomination). - Invitation contested option is selectable for private invites, gated on the shielded pool balance (>= 0.35) via the new host-testable inviteFeeGate() instead of the L1 balance; mid-sync balance treated as unknown. Adds InviteFeeGateTest (5 cases). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A contested / in-voting username has no home welcome tile — its status lives on the More screen's username-voting tile — but the create-username flow finished straight back to Home for every completion, leaving the user on Home with nothing to show (observed on the shielded contested create). Both finish sites in RequestUsernameFragment (the L1 processing-dialog dismiss callback and the identity-state observer) now go through a shared finishAfterCompletion(): a new pure usernameCompletionRoute(creationState, usernameContestable) returns MORE when the name is in VOTING or contestable (else HOME), and MORE brings MainActivity forward on the More tab via the established createIntent(R.id.moreFragment) path before finishing. Non-contested completions still land on Home (fix (a)'s welcome tile). A completionHandled latch runs it exactly once. Adds UsernameCompletionRouteTest (4 cases). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ting - Contested tile is now always selectable: the tile no longer disables on affordability; inviteFeeGate() returns a single Confirm-and-pay gate for the selected kind, and an insufficient-funds message above the button names the requirement (shielded pool 0.15/0.35, or L1 fee 0.03/0.25). - Confirm screen shows the amount actually withdrawn: shielded 0.1/0.3, standard 0.03/0.25 (was always the L1 fee 0.03/0.25). The confirm screen's L1 spendable-balance pre-check is skipped for shielded invites (funded from the pool, already gated at the fee dialog). - "Make your invitation private" sheet shows both Private and Standard costs in a two-column table (Non-contested/Contested rows), using the withdrawn amounts, with wider components / more vertical spacing. Updates InviteFeeGateTest (single-Boolean gate + requirement resolver). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ool min The invite fee dialog's Confirm-and-pay gate + insufficiency message for a private invite now require the Type-20 exit denomination the invite withdraws (0.1 non-contested / 0.3 contested) — matching the amount shown on the tiles and the confirm screen — instead of the padded 0.15/0.35 pool fund-minimum. Updates InviteFeeGateTest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…wModel The create-invitation flow's InvitationFragmentViewModel was fragment- scoped (by viewModels()) in each destination, so ConfirmInviteDialog set the shielded invite link on its OWN instance while InviteCreatedFragment observed a fresh, different instance whose shieldedInviteLink was null — leaving the created screen stuck on "Loading Invite…" for every shielded invite (contested and non-contested). L1 invites were unaffected because they observe global WorkManager status, not the in-memory link. Scope InvitationFragmentViewModel with activityViewModels() across the flow (InvitationFragment base, ConfirmInviteDialog, InvitationFeeDialog), matching the parallel username flow's shared-VM pattern, so the link published at confirm is observed by the created screen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tchdog - Shielded invites now share/copy/persist an AppsFlyer OneLink wrapping the dashpay:// deep link (same OG preview as L1 invites), generated post-spend via TopUpRepository's extracted OneLink core; falls back to the raw deep link if generation fails or times out (15s) — never blocks the funded invite. - The shield "taking longer than expected" notice now reads as clearly still-in-progress (reworded title/message, live progress spinner, "Continue in the background" button) so its dismissal no longer reads as success before the balance lands. - Stall watchdog raised 40s -> 90s: the Halo2 proof + islock verification legitimately exceeds 40s on older devices (Galaxy S21); still a soft advisory — the spend continues past it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ame entry - STALL_TIMEOUT_MS back to 40s (Brian's call): with the notice now unambiguously reading as still-in-progress, firing early on slow hardware is informative rather than alarming. - The enter-username screen kicks shieldedBalanceService.syncNow() when shielded observation starts: entering right after a shield could catch a stale READY status from before the pending wallet-shield lock registered, briefly enabling Continue without the "Preparing shielded balance" gate or the wait advisory (observed on S22). Same entry-kick pattern as the More screen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shielded runtime only started when a shielded UI screen called ensureShieldedReady() (More, username, invite flows), so until then every other surface read a NOT_READY pool with a Dash.ZERO placeholder and falsely concluded there were no shielded funds; cutover readiness also stalled on an untouched launch for the same reason. kickSdkEngines() (every blockchain-service start, post-SDK-bind) now brings the runtime up alongside the L1 shadow: ensureShieldedReady() (idempotent, self-gated on USE_KOTLIN_SDK_SHIELDED, kicks the pending-wallet-shield resume sweep) followed by syncNow() so notes land promptly. stopSdkEngines() already stops it symmetrically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The floating shielded-wait bubble (and small screens) covered the bottom of the validation list, hiding the red username-taken / insufficient- balance rows behind it (observed on S22). The availability and balance status rows now sit at the top of the stack, directly under the input, so a failure is always visible regardless of the bubble or screen size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…me card A DUAL creation (contested PRIMARY + instant/secondary username) returned to Home instead of More, and the instant name's welcome card never showed. The flow's last screen is the secondary (instant) one, whose own usernameContestable is false and whose dismiss can precede the VOTING state flip, so usernameCompletionRoute resolved HOME. - usernameCompletionRoute now also takes primaryUsernameContestable (OR-ed with VOTING / usernameContestable), so a dual creation routes to MORE regardless of which name's flow finishes last. - New pure helloCardEligible(): a dual creation whose contested primary is in VOTING but whose instant secondary is already registered (usernameSecondary present) shows the welcome card — the instant name is usable immediately. Contested-only in VOTING shows NO card (nothing usable yet), non-contested single unchanged, restores stay hidden. votingDualHelloCard has its own persisted dismissal (DONE_AND_DISMISS can't be used while VOTING). Adds HelloCardEligibleTest; extends UsernameCompletionRouteTest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…shj kill-list After a committed cutover the dashj wallet is held/frozen, so the dashj-fed home screen went stale — observed live on-device: an incoming SDK-received tx never appeared (balance + tx list), an SDK-broadcast send stayed "Sending" forever (dashj never sees the islock), and the coins-received notification never fired. New CutoverUiDataService (gated on the SAME persisted cutover state the send path uses, observed reactively; fails closed to dashj): - neutral L1TxUiRecord model read from the SDK Room stores (the exact tables the parity harness reads) — no SDK JNI types past the seam, no fake dashj Transactions; - balance: SDK unspent-TXO sum overlaid at the WalletDataProvider facade (observeTotalBalance/getWalletBalance) so every consumer switches at once; MainViewModel's parity-report balance hack removed; - tx list: pure planL1DisplaySync writes the existing tx_display_cache — inserts SDK-only txs (invisible-receive fix), surgically flips "Sending"→"Sent" and clears "Processing"/"Confirming" on islock/ chainlock (stuck-Sending fix); group/gift-card/service/error rows untouched; 60s idempotent ticker heals cache rebuilds; - coins-received notification from the SDK sync loop (24h recency guard, structurally deduped by row existence). Pre-cutover provably inert: gate false → SDK side permanently null → dashj feeds pass through unchanged (test-asserted). Adds DASHJ-KILL-LIST.md: full dashj dependency inventory (~310 files) with a 4-step kill order ending at dashj removed from build.gradle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dashj removed from common and crowdnode Neutralize every dashj/bitcoinj type crossing module boundaries and delete org.dashj:dashj-core from common/build.gradle and integrations/crowdnode/build.gradle (wallet module keeps dashj for Steps B-D): - WalletDataProvider(+Ext) rewritten on neutral types (Dash/duffs, base58 strings, hex txids, TxInfo snapshots); old dashj surface moved to wallet interface WalletData with WalletDataAdapter concentrating all conversions - TxInfo neutral tx model + TxInfoConverter (wallet) mirroring old matcher semantics; transaction filters retyped, dashj originals moved to wallet - Monetary/Coin/Fiat/MonetaryFormat/ExchangeRate ported self-contained into common money/ (byte-faithful vs dashj 22.0.3, parity-tested in wallet) - Base58/AddressNetwork/Scripts/PaymentURI/AddressUtils self-contained; BIP70 copy-out finished (local TrustStoreLoader, neutral PaymentProtocol/ PaymentSession); PaymentIntent neutral with parse-time script validation - crowdnode matchers retyped on TxInfo, all 24 tests ported unchanged - Room persistence: TxId replaces Sha256Hash in common entities with byte-identical BLOBs, schema v19 untouched Post-review hardening (4-way adversarial review, 2 rounds): id-based BIP70 network check in InputParser (AddressNetwork vs NetworkParameters equals was always false), filters pushed back inside WalletObserver to restore emission/listener gating, fail-closed lock/wait-for-lock on wallet-lookup miss, Fiat.hashCode dashj parity, nullable estimated-balance contract restored, OP_RETURN 80-byte limit, BIP70 tx.verify() reinstated, txId case normalization, dashj-aligned isP2PK/secondChunkData. Gates: common+crowdnode build without dashj; unit tests 917 wallet / 21 common / 24 crowdnode, 0 failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… seam Field report: the "Past" checkbox in Transaction metadata settings is disabled, so historical metadata cannot be uploaded. Two dashj reads, both stale for every SDK-owned wallet (the dashj wallet is HELD — frozen at the cutover snapshot — so it enumerates nothing): 1. getUnsavedTransactions() walked `walletDataProvider.getTransactions()` on the dashj WalletData. Returning empty made hasPastTransactionsToSave false, so the UI concluded there was no history to save and greyed out the control — on exactly the wallets that need it. 2. publishPastTxMetadata() walked `walletApplication.wallet?.getTransactions(true)`. Even reaching the upload would therefore have published nothing. Both now read WalletDataProvider.getTransactions() — the SDK-fed set plus any dashj-only transactions, deduped — the same source the transaction list and the CSV exporter use. Third instance of this defect class after the CSV export and the dashj Buy Credits path. The CoinJoin exclusion moves from CoinJoinTransactionType (which needs a dashj Transaction) to TxInfo.isEntirelySelf: a mixing round spends the wallet's own coins into its own denominated outputs and is entirely-self, while a CoinJoin SEND pays a foreign address and is not — so the same population is excluded. Also guards the progress divisor against an empty list.
…the scan
Third and final layer of the disabled-"Past"-checkbox defect, caught on device.
hasPastTransactionsToSave read _oldUnsavedTransactions.value imperatively inside
onEach. The scan behind that value takes ~7.5 s on a large wallet, so the flow
emitted "0 never-published" first and never re-emitted when the real count
landed — the terminal source (the change cache) had not changed. Device log:
14:45:40 unsaved count: 0 (0 cached + 0 never-published)
14:45:48 determining unsaved transactions: 7.458 s, 998 txes
The two populations are independent and either can change alone, so they are
now combine()d rather than chained. Verified on device:
unsaved count: 998 (0 cached + 998 never-published)
and the "Past" row reports enabled.
Field report on 11.10.93: the "Current Identity Balance" label did not appear at all on a tester's device, while the same build showed it on the S21. Three faults, all mine. 1. drop(1) was a race, not a constant. The collector dropped the StateFlow's replayed value on the assumption it "is always the initial null anyway (the balance loads async)". That holds only when the load is still in flight when the view subscribes. When it had already resolved, the replayed value WAS the balance — dropped, with no further emission ever coming, so the label never rendered. The S21 was the slow case, the tester's device the fast one. Collect without dropping; repeatOnLifecycle(STARTED) already runs after onViewCreated, so the 11.10.89 crash cannot recur, and the child-view null check covers a torn-down view. 2. Hiding the row on a failed read was the wrong default for a diagnostic label. A screenshot then cannot distinguish "the balance could not be read" from "this build does not have the feature" — which is exactly the ambiguity the field report ran into. The row is now always shown, reading "unavailable" when unknown. Still never zero, which would wrongly say "no credits". 3. The read was one-shot at ViewModel construction. It is a Platform round trip and fails against unreachable nodes with "Dapi client error: no available …" (surfaced, confusingly, as "Identifier not found") while succeeding moments later. A single attempt therefore pinned "unavailable" for the whole visit. Now three attempts with backoff, plus a refresh on every visit to the screen. Verified on device: the row renders on both a cold and a warm open, showing 0.07752796 DASH when the read succeeds and "unavailable" when Platform is unreachable, with the attempts visible in the log.
…d transactions Field report: transactions that HAVE metadata on the network showed none of it on screen, on a wallet holding 12,556 fetched metadata rows. Root cause, in updateAndInsertIfNotExist: when no local metadata row exists yet, it calls insertTransactionMetadata(), which derives the row from a dashj wallet Transaction and returns null when the wallet does not hold the tx. It then falls back to a caller-supplied row — but syncPlatformMetadata passed NO fallback. So for any transaction the HELD dashj wallet does not have (post-cutover that is essentially every transaction on an SDK-owned wallet) the fetched metadata was dropped before it could reach the table the UI reads. The documents were fetched, counted and stored as platform documents, and then silently went nowhere. Fifth instance of this defect class after the CSV exporter, the dashj Buy Credits path, the "Past" upload gate and getUnsavedTransactions: a read that still assumes dashj owns the wallet. syncPlatformMetadata now passes the platform row itself as the fallback, so the row is created and the merge proceeds. Logging, since silence is what let this hide: - the drop branch, if ever reached again, now warns instead of returning quietly - syncPlatformMetadata logs what actually merged, per field (memo length, rate, service, tax category) — the boundary where network metadata becomes displayable - TransactionRowView logs what metadata each ROW was bound with Together those two bracket the question: metadata merged but not bound is a render fault; never merged is a sync fault. Memo LENGTH only; never its text.
The syncPlatformMetadata fallback fix stops metadata being discarded from now on, but does nothing for what was already lost: those documents are stored in transaction_metadata_platform, and the fetch watermark guarantees the network will never deliver them again. A field wallet holding 12,556 fetched rows would have kept displaying none of them, and the only repair would have been clearing the metadata store and re-downloading everything. The documents carry the whole field set — memo, rate, service, tax category — so the repair is a purely LOCAL re-merge with no network round trip. Runs once per install behind a one-shot flag, off the sync scope. A failure deliberately leaves the flag unset so the next launch retries instead of silently skipping. Verified on device: 396 stored rows across 396 transactions, 396 merged, 0 dropped, 2.153 s. Joel's device has ~12.5k rows, so roughly a minute, once.
…uction deleted (#1535) MayaBlockchainApiImpl rebuilt on buildSignedPayment/broadcastDeferredPayment; all dashj tx construction on this path removed. MAX sells reserve a fee (spendable − reserve) rather than draining — a changeless drain is invisible to compact-filter scanning and permanently inflates the balance (#1538). Verified on mainnet end to end, ordinary and MAX. Builds against v42int4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…erything MAX (#1536) * feat: background completion of interrupted SDK top-ups + stuck legacy 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> * feat: credited state for SDK top-ups in transaction details 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> * feat: run the Buy Credits purchase as unique background work 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> * refactor!: delete the dashj Buy Credits purchase path (Phase 2/3) 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> * fix: persist the credits explainer's shown flag when displayed, not on 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> * feat: inline button progress for Buy Credits instead of a blocking dialog 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> * refactor!: delete the legacy dashj top-up retry loops (Phase 2/3 item 5) 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> * chore: sweep 17 dead imports left by the deleted dashj purchase path 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> * fix: treat a Platform already-used rejection as terminal, not retryable 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> * fix: Buy Credits UI — explainer, button busy state, and Max refusal 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> * refactor: remove the last dashj references from BuyCreditsFragment 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> * feat: Buy Credits MAX via the Internal Transfer pattern — full balance + 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> * fix: review round 1 — scoped stale-work handling, inclusive floor, single 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> * docs+chore: review round 2 — restore caveat on credited state, lifecycle 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> * fix: typed shortfall arms in classifyBroadcastFailure — retry survives 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> * fix: post-rebase reconciliation with the phase1 tip + review nit 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> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…bled on-chain send paths (#1537) Routes CrowdNode message signing (API withdrawals and email registration) through the platform Kotlin SDK, and fences the on-chain send paths that CrowdNode has disabled service-side rather than porting them. - SecurityFunctions.signMessage delegates to ManagedPlatformWallet.signMessage via a new SdkMessageSigner seam; the dashj ECKey path, keystore password retrieval, and Kotlin-side key derivation are gone from signing. - The old silent ""-on-missing-key return is replaced by a typed MessageSigningException (SIGNING_KEY_UNAVAILABLE / INVALID_ADDRESS / UNAVAILABLE) with the SDK error preserved as cause. - Fixes a latent crash: a signing failure during withdrawal propagated uncaught through CrowdNodeApi.withdraw. - All six senders in CrowdNodeBlockchainApi throw CrowdNodeServiceUnavailableException before any side effect when CrowdNodeConstants.SIGNUP_AND_DEPOSITS_ENABLED is false; original bodies are preserved as the template for a future SDK port. UI entry points are gated with an honest service-status notice. - Withdrawal and email registration now require PIN/biometric before signing. Verified on mainnet: an SDK-signed withdrawal message was accepted by CrowdNode's live server. Requires SDK 0.1.0-v42int4 or later, which carries the signMessage surface (dashpay/platform 92f94526ef + #4321). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mpleted PlatformSynchronizationService.shutdown() asserted `Preconditions.checkState(platformSyncJob!!.isActive)` whenever the job was non-null and an identity existed. But a non-null-completed job is a normal state at teardown: the job finishes on its own when the sync scope is cancelled by an earlier step, and a service restart — the post-seed-backup onboarding restart in QA repro Mo-972 — reaches shutdown() with the field still set. The failed precondition threw IllegalStateException inside BlockchainServiceImpl.onDestroy's coroutine, which has no handler, killing the whole process (PROCESS EXIT REASON: CRASH(4), foreground, 11.10.87 field report 2026-08-16T21:30:44Z). Handle it exactly like the txMetadataJob block below always has: cancel only if still active, always clear the field. Drops the now-unused Guava Preconditions import. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…penses Two store-truth defects surfaced by the S22 ZenLedger reconciliation (every duff of its 2.64420404 discrepancy attributed): 1. The engine can persist a multi-account spend with the net of only ONE account's slice (dashpay/platform#4387): a 15-input full-balance sweep stored as −0.005 instead of −2.61920199. The existing reattribution only examined impossible INCOMING shapes, so the row could never self-repair. Widen the flag to OUTGOING Standard rows with spent evidence — the mirror recompute already yields the whole-wallet net, an already-correct row round-trips unchanged (no persist, no log), and the CAS write rules stay as before. 2. An AssetLock credit purchase is persisted INTERNAL/net-0 (every non-burn output returns to the wallet), hiding the real burn+fee spend from the CSV — the reconciliation's 0.03000241 residual. The exporter now includes entirely-self transactions that burn value into an OP_RETURN, valued at −(burn+fee); zero-value OP_RETURN internals (CoinJoin rounds, memo txs) stay excluded. Store-side fix (persist the real net) tracked on the platform repo; this branch goes inert once it lands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e + MIME Mo-973: the welcome screen offers the shielded pool as the payment source when it covers the NON-contested 0.03 tier, but a CONTESTED name needs the 0.25 denomination — a pool below it dead-ended the flow even when the ordinary Dash-balance path could fund the name (QA field wallet: pool 0.031 < 0.25 while 0.96 sat asset-lock-eligible; every contested attempt refused). New effectivePaymentSource() falls back SHIELDED_BALANCE → DASH_BALANCE for exactly that case, and BOTH the balance gate and the submit routing consult it, so they cannot disagree — the gate passes on the same path the submission then takes. CSV export: name the file deterministically (dash-wallet-transactions- <yyyy-MM-dd>.csv) instead of createTempFile's random-digits-before-suffix pattern, and declare the share intent as text/csv rather than text/plain — share-sheet receivers that rename by display title or MIME were delivering an extensionless file (QA field report). One canonical file per day also stops the cache dir accumulating abandoned temp files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…OUTGOING heal Extends the store heal (eb5dbb05b) per the .99 decision: the AssetLock credit-purchase row persisted INTERNAL/net-0 (dashpay/platform#4412) is now flagged in exactly that defective shape and corrected by the same mirror math to OUTGOING/−(burn+fee) with the fee recovered — so the UI, the CSV and every other consumer read the true spend from the one store row. The exporter's burn branch (which fires only on net==0) goes inert once the heal lands, exactly as it will under the eventual rust store-side fix. Also gates the widened OUTGOING audit's payload fetch: a row whose stored net already equals the mirror recompute is correct, produces no correction, and must not pay a dashj payload parse per pass. The bounded funded-split aggregate still runs for audited rows — a correct row and a per-account-slice row (#4387) are numerically indistinguishable without it — and its "already correct" verdict is what suppresses the fetch and the write; the durability test now pins that exact contract. Tests: the field AssetLock figures heal to −0.03000241/fee 241; a non-zero stored net leaves the arm inert (the post-rust-fix shape); the #4387 undersized-OUTGOING sweep heals to the whole-wallet net; the point-lookup expectation moves to the whole-wallet net accordingly. 24/24 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… loop Field loop (S22 mainnet, 11.10.98, 2026-08-17): the backfill gate rewound ~141k filters once a minute forever with the sync indicator pinned at "Syncing 100%". Mechanism: each provisioning sweep re-enqueues the SAME six receival-account builds, the drain re-"builds" them (report.built counts queue departures, not first-time creations), recordPassOutcome re-raises the registration signal with the pass's own builds, and the next gate consultation reads that as "accounts registered since the sweep" — rewind, re-arm, repeat. Fingerprint-based redundancy detection is unsound here (the deferred drain legitimately completes NEW SDK accounts under an unchanged contact set — pinned by gate_registrationFlagIsConsumedByThePassItCauses). The sound bound is the account space itself: a wallet has at most one receival account per SDK contact request, so once this process has raised the signal for that many builds, every further report is provably a re-build. noteAccountBuildsRegistered now tracks a process-cumulative count and mutes past the cap (BackfillObservation.sdkContactCount); a new contact raises the cap before its build registers, so genuine registrations always pass, and a relaunch re-proves from scratch like every other process-scoped judgment here. 53/53 gate tests green, including both deferred-drain contracts unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…87 wallet-level net fold) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d at 'Preparing shielded balance…' reset() runs on every empty username input (including screen entry) and rebuilt RequestUsernameUIState from defaults, wiping shieldedSyncStatus back to NOT_READY and fundingNoteAnchored back to false. The service never re-emits an unchanged READY, so the mirror could never repair and the submit button stayed disabled for the life of the screen — the real shape of QA Mo-973, reproduced 100% on the S21 (fresh process, anchored note in store, non-contested name). - reset() now preserves the service-state mirrors (re-sourcing status from the live VM mirror) and kicks an async anchor re-read - the fragment's button gate consumes effectivePaymentSourceFor(), so a contested name the pool cannot fund gates on the Dash-balance path it actually uses — not on shielded pool readiness (Mo-973 completion) - the fallback log fires once per decision transition instead of on every gate recompute (was ~9 lines/second while typing) Regression tests: reset_preservesTheShieldedGateMirrors, effectiveSource_contestedShieldedShortfall_fallsBackToDashBalance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The passInFlight → SYNCING arm made every 60-second background pass yank READY away for its 2-4s duration, so every READY-gated surface flickered with it — observed live on the S21 as the username submit button dropping back to 'Preparing shielded balance…' mid-typing, seconds after correctly enabling. Once the first catch-up pass has completed, the persisted note store stays trustworthy through routine passes (a pass adds notes; it does not blank the store), so SYNCING now means only 'first catch-up not finished'. Consumers that must not run concurrently with a pass poll ShieldedSource.isShieldedSyncing directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed adoption; unshield timing copy The dual flow submits the contested primary as a CONTEST (correctly — it was live on chain, 'brimoztest' in voting) and registers the instant secondary immediately. dashj's recoverUsernames() can only see REGISTERED domains, so it adopted the instant name — in DPNS-NORMALIZED form — as primaryUsername, and the repository's on-chain-wins adoption copied it over the record's requested primary 20 ms before the contested-name scan read that label as its candidate. The app forgot its own in-flight contest: no voting tile, no UsernameRequest row, DONE instead of VOTING, 'br1m0ztest3' greeting the user (S21 repro 2026-08-18; QA Mo-972). - recoveredPrimaryIsPendingDualSecondary (pure, host-tested): when the recovered 'primary' is the record's own instant secondary (normalized compare) and the record's primary is a genuinely contestable pending request, adoption preserves the requested primary and routes the recovered name's status/salt to the SECONDARY fields; a contested primary that has WON normalizes to the record primary and the guard stands down - isDualPrimaryClobbered now also matches the NORMALIZED clobber form the raw equality missed - unshield (Shielded balance → Dash Wallet) proving dialog says 'up to 10 minutes', not the to-shielded 30-second promise (QA field report) Existing damaged records are not repaired retroactively (same policy as the pre-11.10.52 clobber): the lost primary is re-filed via Request Username. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…requested names before recovery The adoption guard could never fire: restoreIdentity(fromCreation=true) reset the working record to an EMPTY BlockchainIdentityData before the recovery walk, so the requested primary/secondary were gone before recoverUsernames() rebuilt the record from on-chain names (verified live on the fixed-guard build: candidates=[br1m0zv0te4], contest missed, DONE — the same end state as the original repro). Seed the fromCreation walk from the PERSISTED record instead: the requested names survive, the adoption guard sees them, and the contested-name scan gets its candidate. A genuine device restore (fromCreation=false) keeps the historic empty reset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… real platform-confirm cadence Field timing (two independent fresh-wallet runs, 2026-08-18): L1 asset lock InstantSend-locks in ~1s; the pool credit lands 2m53s-3m30s after the tap (Type-18 transition + note anchoring + the 60s sync pass). The 40s Stalled surface fires by design mid-wait, but its 'can take a minute' line underpromised and read as a failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…alifiers; stale shielded balance after Type-20 spends
Two QA field reports (Brian, S21 2026-08-18):
1. The criteria rows under the username input were keyed off the invite
tier only, so the PLAIN (non-invite) flow — which allows contested
names — showed the non-contested invite qualifiers ('Between 20 and
23 characters' / 'Numbers 2-9') sitting unmatched under a perfectly
valid contested name. The general validity rules now serve the plain
flow and contested-tier invites; the qualifier rows (and their
meet-ONE-of header) remain only for an invite actually restricted to
non-contested names.
2. A shielded-funded username submit spends pool notes outside
ShieldedBalanceService's own write paths, so the More card kept the
pre-spend balance until the next 60-second sync pass. The executor
now calls noteExternalShieldedSpendBroadcast() (stale-mark + an
immediate sync kick) for Broadcast and Ambiguous outcomes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-waiters) Carries platform fix/asset-lock-proof-bounded-repoll (8b6540e98c): both lock-waiters re-read the record at least once per second, restoring ~5s InstantSend proofs for self-broadcast shields/invites/identity funding (was one chainlock, 2-6 min — see dashpay/platform#4417 for the root-cause investigation and the four-build bisect). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…able across two completed passes A Type-20 spend's note settlement spreads across multiple sync passes (observed live: spent-nullifier, change note, and OVK outgoing recovery landing at three separate pass completions), so clearing the stale flag on the FIRST completed pass let the More card march through several intermediate balances after a shielded username create. The flag now clears only when a completed pass yields the same balance as the previous completed pass; deposits are unaffected (only spend paths set the flag). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SDK AAR dependency places a ResolutionBackedFileCollection in the data-binding merge task's compileTimeDependencies, which the configuration cache cannot serialize — dataBindingMergeDependencyArtifacts_testNet3Release fails the store step and the whole build with it. CI runners start fresh every run, so the cache never produces a hit there anyway; local builds keep it enabled via gradle.properties. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The already-consumed carve-out in SdkTransparentTopUp retried the pipeline with skipLockOutpoint, which takes the fresh-build branch. When PerformTopUpWorker reruns after a mid-flight process death (Platform consumed the lock, the worker never recorded success — the state observed live 2026-08-04), the rerun found its OWN purchase's lock, the resume was rejected "already completely used", and the carve-out then built and broadcast a SECOND full-amount asset lock: one tap, two charges. The carve-out is only legitimate for a NEW user intent — an interactive purchase blocked by a stale consumed lock that PREDATES the request (leftover bookkeeping the SDK never marks consumed locally, MO-998). The two cases are now distinguished explicitly: - PerformTopUpWorker durably records its own WorkManager request id (BlockchainIdentityConfig.TOP_UP_WORK_SDK_STARTED_ID) right before the SDK hand-off. An execution that finds its OWN id already recorded is a RERUN of a purchase that already reached the SDK once; a fresh request id (new enqueue, new UUID) is a new user intent. An execution stopped BEFORE the hand-off correctly still counts as a new intent — nothing of that purchase can be on chain. A marker that cannot be written aborts the purchase pre-SDK (proceeding without it would re-open the hole). - SdkTransparentTopUp.topUp/topUpTransparent take an explicit newUserIntent flag (no default — every caller must decide). On a rerun (false), a resume rejected as already-consumed terminates as SUCCESS: the rejection proves this purchase's own credits landed on the earlier attempt (Broadcast(BALANCE_ALREADY_CREDITED); the balance itself is unknown), and the completed purchase is best-effort labelled a Topup. Only a new intent (true) may skip the stale lock and fresh-build, once. Host-JVM tests cover both directions: a rerun hitting the consumed rejection returns success with zero topUpFromCore calls, and a new intent blocked by a stale consumed lock still fresh-builds exactly once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SwapKitApiAggregator.buildAndSendDepositTx still sent a MAX sell via sendCoins(..., emptyWallet = true) — the generic changeless send-all drain SdkL1SendService documents as settlement-defective: a drain has NO wallet-owned output, so compact block filters never match the transaction in a block, it never reaches CONTEXT_IN_BLOCK, and the wallet counts the spent inputs as spendable forever (mainnet field bugs a5c99aec/1f608a9a). The Maya vault route was already moved off this path (#1535 / MayaBlockchainApiImpl); the SwapKit/NEAR deposit route was the scope gap. Post-cutover the typed emptyWallet overload additionally FAILS CLOSED (SendCoinsTaskRunner), so a max sell could not even broadcast. Apply the same fix as the vault route: the MAX quote is already spendable − fee reserve (MayaBlockchainApi.maxSwapDepositAmount, measured through the SDK builder and re-measured by the commit-time getSwapInfo refresh), so the deposit now pays exactly that quoted amount as an ordinary fixed-amount send — no emptyWallet. The reserve's unused remainder returns as change, restoring a wallet-owned output so the deposit confirms and settles normally. Quote/deposit semantics are otherwise unchanged, and tightened: the amount reported downstream and the amount actually sent are now equal by construction, so NEAR Intents under-delivery is impossible (a balance drop is still aborted before the build by the existing re-measurement guard, and now also fails safe as a whole-send refusal rather than a short sweep). The amount computation is extracted as the pure swapKitDepositAmountDash (ordinary sell = amount + swap fee, max sell = exactly the quoted amount) and host-JVM tested. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@HashEngineering this is ready for your review — requesting it now. Given the size (370 commits), a review guide so you can weight your attention: What this PR is: Phase 1 of the dashj→Kotlin SDK cutover. dashj still owns the wallet object/seed/keys/persistence; the SDK (org.dashj:dash-sdk-android:0.1.0-v42int4d, resolved from GitHub Packages) takes over the seams: sync engine, platform identity/username/contacts, shielded operations, asset locks, and the L1 send path. CI is green (the config-cache × data-binding fix is CI-scoped in dashwallet.yml). Where to spend review attention (highest value first):
Already adversarially audited (so you can skim rather than deep-dive): the Maya memo/amount/vault safety, #1536 MAX convergence + recovery drain, #1537 CrowdNode fencing, #1510 crypto core — a multi-agent audit re-reviewed all merged PRs on this branch this week; findings that survived are fixed on the branch tip. Known-open items that are NOT this PR's problem (tracked separately): the SDK store restore/persistence bug you root-caused (kotlin-sdk side, your reconcile plan), the stale "Syncing 100%" label on relaunch, and the metadata backfill follow-ups. After merge into feat/kotlin-sdk-integration, the plan is small focused PRs against integration from here on. |
HashEngineering
left a comment
There was a problem hiding this comment.
LGTM; This is a good start for the migration.
What this is
The Phase-1 integration branch of the dashj→Kotlin SDK cutover: from first launch (new, restored, or upgraded wallets) the SDK owns L1 sync, sends/receives, shielded operations, DashPay identity/contacts, invites, and the transaction display pipeline. dashj remains the wallet/keys/persistence foundation but its L1 engine is held (never syncs) once the cutover commits — reversible by design.
Draft while the QA test round completes on-device (testnet, 2 devices, builds 11.10.25→11.10.40).
Highlights
CutoverCoordinatorstate machine (immediate commit for new/restored wallets, auto-commit for upgrades), engine-start gating, SDK-fedblockchain_state(sync %/stage), SDK-sourced quorums.tx_display_cache(direction/amount/IS-CL status from engine events + TXO-derived nets; legacy dashj writers can no longer clobber corrected rows), contact rows/notifications/tx-detail all SDK-sourced, refresh bus.dashJ-kotlin-parity-log.txtin support bundles; verbose parity logging is debug-build-only.Dependencies
org.dashj:dash-sdk-android:0.1.0-v41int11-SNAPSHOT— platform branchkotlinSDK-v4-qa3(bfoss765/platform @ f01dc1a8b7+), rust-dashcore fork @ 4d927c15. Platform PRs: #4204 #4240 #4247 #4251 #4256 #4258 (+bases #4184 #4185 #4186).Known pre-release gates
🤖 Generated with Claude Code