Skip to content

feat: dashj→Dash Platform Kotlin SDK cutover — Phase 1 (SDK owns L1/L2 from first launch) - #1532

Merged
bfoss765 merged 386 commits into
feat/kotlin-sdk-phase-1from
feat/kotlin-sdk-phase1
Aug 21, 2026
Merged

feat: dashj→Dash Platform Kotlin SDK cutover — Phase 1 (SDK owns L1/L2 from first launch)#1532
bfoss765 merged 386 commits into
feat/kotlin-sdk-phase-1from
feat/kotlin-sdk-phase1

Conversation

@bfoss765

@bfoss765 bfoss765 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

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

  • Cutover machinery: CutoverCoordinator state machine (immediate commit for new/restored wallets, auto-commit for upgrades), engine-start gating, SDK-fed blockchain_state (sync %/stage), SDK-sourced quorums.
  • Sends: SDK-authored + broadcast; DashPay contact-received funds are spendable (single-account funding-path fallback + multi-contact consolidation — never unions accounts; platform PRs #4247/#4256); mixed-funds migration (shield / keep-spendable) with forced post-sync prompt.
  • Restore: identity/username/contact recovery re-triggered off SDK sync completion (the dashj listener that used to drive it never fires post-cutover); CoinJoin gap limit 30→100 (platform side) verified with a 1,000+-tx benchmark restore at exact balance parity.
  • Display integrity: SDK-authoritative 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.
  • DashPay: contact requests via SDK (iOS interop verified), contract-bound identity keys + CBOR tolerance, shielded + L1 invites incl. claim recovery.
  • Diagnostics: Tools 'dashj sync' toggle — runs dashj as a parity check against the SDK (isolated from the main UI), height-based %, green/purple/red verdict at true-100%, dashJ-kotlin-parity-log.txt in support bundles; verbose parity logging is debug-build-only.
  • Post-cutover fixes for dashj-dependent features: paper-wallet sweep (Electrum/Insight broadcast), masternode/username voting (SDK masternode lookup — platform PR #4258), large-wallet scale hardening (bounded queries, adaptive parity cadence).

Dependencies

Known pre-release gates

🤖 Generated with Claude Code

bfoss765 and others added 30 commits July 15, 2026 17:59
…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>
bfoss765 and others added 25 commits August 15, 2026 14:28
… 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>
@bfoss765
bfoss765 marked this pull request as ready for review August 20, 2026 16:40
@bfoss765

Copy link
Copy Markdown
Contributor Author

@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):

  1. wallet/src/de/schildbach/wallet/service/platform/sdk/ — the SDK service layer (SdkL1SendService, SdkTransparentTopUp, ShieldedBalanceService, SdkTxStoreWalker). This is the cutover's heart. Note SdkTransparentTopUp's new-intent gate + durable rerun marker (948bc87) — the double-purchase guard.
  2. The seam-routing gates (isSynced()/blockchain_state feeds) and CreateIdentityService/RestoreIdentityWorker — identity lifecycle incl. the persisted-record seeding and dual-username guards.
  3. integrations/maya/ SwapKit changes — the MAX route moved off the changeless drain (da9ba9c).
  4. UI wiring (username request flow, shielded screens, More-screen balance) is lower risk — it's been through several on-device QA rounds (testnet + mainnet, S21/S22, plus your emulator A/B).

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 HashEngineering left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM; This is a good start for the migration.

@bfoss765
bfoss765 merged commit 76f98d6 into feat/kotlin-sdk-phase-1 Aug 21, 2026
3 checks passed
@bfoss765
bfoss765 deleted the feat/kotlin-sdk-phase1 branch August 21, 2026 19:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants