Skip to content

feat(platform-wallet)!: support DashPay shielded tips with dedicated accounts - #5288

Open
PastaPastaPasta wants to merge 23 commits into
v5.1-devfrom
feat/dashpay-shielded-tips
Open

PastaPastaPasta wants to merge 23 commits into
v5.1-devfrom
feat/dashpay-shielded-tips

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Supersedes #4616, recreated from a dashpay/platform branch so the full CI (Rust, wallet, Kotlin, Swift, e2e) runs; fork PRs skip those jobs. The commits are unchanged (head 5b6e8d6583). Review history is on #4616. Two review threads on #4616 are still open for a maintainer decision (restart-safe tip submission in the example apps).

Issue being fixed or feature implemented

Let a DashPay user publish a reusable shielded tip address and receive payments by username. Wallet-generated addresses use a dedicated ZIP-32 account per identity, separating tip viewing keys from ordinary wallet activity. Users can also publish an externally generated Orchard address.

This follows the transparent profile-address work in #4380 and the 43-byte format in dashpay/dips#188.

Split (2026-09-16): the contract field shipped separately in #4768 and the DPNS resolver fix in #4769; both are in v5.1-dev. This PR now carries only the wallet, persistence, native bindings, and Swift/Kotlin example flows, and targets v5.1-dev (milestone v5.1.0). Nothing that remains here is consensus code.

What was done?

  • The contract field (profile.shieldedAddress on DashPay v2 at position 7, 43 raw bytes including the full diversifier; consensus enforces the byte length, clients validate the Orchard decoding) landed in feat(dpp)!: dashpay profile shielded address field #4768 together with the protocol 13 to 14 upgrade tests and the fee/root fixtures. Since the 2026-10-05 merge of v5.1-dev those files are no longer in this diff.
  • Carry all three profile payment fields through Rust wallet models, document parsing/construction, persistence, C FFI, Swift, and Kotlin. Explicit keep/set/remove updates preserve addresses during unrelated profile edits. Publication checks the connected chain's contract capability.
  • Reserve the upper half of ZIP-32 account indices for tips, mapping each wallet identity index to 0x40000000 + index. Preparation verifies the seed, binds viewing keys, and flushes persistence before returning an address. Identity discovery reconstructs the account without relying on the current profile; retired tip accounts keep scanning. Ordinary balance/spending choices exclude tip accounts, with explicit access to tip funds.
  • Add publish/replace/remove and pay-by-username flows in both example apps. Resolve DPNS and a proof-verified profile, display the identity/address, warn about previously seen recipient changes, and recheck the confirmed recipient before sending. No transparent fallback or contact request is involved.
  • Migrate existing SQLite, SwiftData, and Room profile data without losing existing identity/contact state. Expose the address metadata in the Swift storage explorer and preserve coverage checks for inherited schema models. The DPNS resolver fix for identifiers decoded as bytes moved to fix(rs-sdk): resolve DPNS names whose identity record is not an Identifier value #4769.
  • Document account allocation, restoration, address rotation, and external-address ownership. Account separation is wallet policy; it is not a consensus restriction or a guarantee against correlations from later spending. Per-contact encrypted addresses and authenticated sender attribution are outside this change.

How Has This Been Tested?

Local macOS builds and targeted validation:

  • Rust wallet: 876 tests passed, including profile patches, recipient changes, dedicated-account recovery, wrong-seed rejection, retired accounts, and received-note isolation/rotation.
  • SQLite storage: 139 unit tests passed (2 ignored), 7 migration tests and 17 persistence roundtrip tests passed.
  • Drive/ABCI: 18 upgrade/schema tests, 160 document tests, 21 check-tx tests (1 ignored), and 2 deterministic-root tests passed. Historical protocol roots remain unchanged. (Now covered by feat(dpp)!: dashpay profile shielded address field #4768.)
  • Native FFI: 6 profile tests and 55 persistence tests passed. Wallet, FFI, and host JNI Clippy checks passed; the wallet also builds with default nonshielded features plus serde.
  • Swift: 23 migration/persistence/history tests passed; simulator framework and example app built with warnings treated as errors. The framework/app were refreshed after the final shared SDK change.
  • Kotlin: 556 JVM tests passed across SDK and example app; SDK/app builds and instrumented-test Kotlin compilation passed. SDK lint passed. App lint retains one pre-existing StateFlow.value composition finding in SyncStatusScreen.kt:215.
  • Follow-up review validation: all 11 viewing-key binding tests and the profile replay test pass; Rust Clippy passes; Swift app rebuild and 4 migration tests pass; Kotlin SDK/app rebuilds, SDK lint, and 2 additional retry-policy tests pass.
  • Changed Rust files pass rustfmt; git diff --check passes. Storage explorer coverage passes for all 36 models, with a negative fixture verifying inherited-model omissions are detected.

GitHub skips Rust and mobile runner jobs for this fork under the existing workflow trust policy; the results above are local validation.

Not exercised: a live funded network tip transaction, Android NDK/native .so build, or Android device/instrumented execution. Full network end-to-end validation remains a release validation step.

Breaking Changes

Client-side only: there is no consensus or protocol change here (the protocol-14 DashPay v2 contract bytes and their fee/root baselines shipped in #4768). The client changes are breaking:

  • C ABI: the #[repr(C)] restore structs IdentityEntryFFI and ContactProfileRowFFI gained the three payment addresses and their presence flags (IdentityEntryFFI grows from 224 to 312 bytes), and the native profile/update structs widened too. Every C ABI consumer, the Swift xcframework, and the Android JNI library must be rebuilt together with the host code; mixing old and new binaries misreads these structs. The existing FFI profile setter stays as a wrapper with keep-address semantics.
  • Rust API: the public profile and update models (DashPayProfile, the profile patch types) gained fields.
  • Persistence: forward migrations in SQLite (V019), Room (14 to 15), and SwiftData (live V4). Downgrading a migrated wallet database to an older SDK is not supported.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

This pull request was created by Codex.

Fix-up (2026-09-16, maintainer push)

Merged v4.2-dev (ffb6e53f20) into the branch and addressed the open review threads; head = merge 47e9e1bc95 + a637a0e2bd.

  • Storage migration renumbered V008 to V019: v4.2-dev already ships V008 to V018. The entry_format / profile_format stamps now DEFAULT 1 and the migration marks the rows it finds 0, so only pre-V019 rows are decoded as the legacy shape; both identity readers on the hard-delete schema dispatch on the stamp. tests/sqlite_profile_address_encoding.rs walks a V007 database through the whole chain. SCHEMA.md documents both columns.
  • (Superseded by the 2026-10-05 update below.) Swift schema V4 frozen through the generator (scripts/freeze_schema_models.py, FREEZES row at 787cac09e7) instead of a hand-written copy; DashSchemaV5 adds PersistentDashpayPaymentAddresses; dash-v5.store written by this build for the fixture-based hash test from feat(swift-sdk): generate the frozen SwiftData schema models, and guard them with real stores #4644.
  • Drive PV14 pins recomputed for DashPay v2 on top of the contract version item (feat(platform)!: prove data contract versions without the contracts via a PV14 version item #4749): contract create/update fees, document deletion/replacement fees, and the genesis root hash. These pins shipped with feat(dpp)!: dashpay profile shielded address field #4768.
  • Review fixes: catch_query_panic around the non-spending tip exports plus an entry-point-level panic regression test; app-owned ShieldedTipSubmissions on iOS (parity with Android); Kotlin tip account derived from the live identity index rather than Room's placeholder 0.

Verified locally: storage crate suite, wallet tip/profile/bind tests (87), FFI panic tests, Drive check-tx/document/root-hash tests, Swift package persistence tests (18) and the example app build with warnings as errors, Kotlin SDK/app compile and tip unit tests.

Update (2026-10-05, maintainer push)

Merged v5.1-dev (1ebcedb028): the #4768/#4769 copies dropped out (143 to 99 files). Persistence numbering moved to follow the base:

  • Room: v5.1-dev ships versions 12 to 14, so the profile address columns are now MIGRATION_14_15 (database version 15, 15.json); migration tests walk 14 to 15 and 10 to 15.
  • SwiftData: v5.1-dev replaced the development V4/V5 chain with App Store release snapshots (feat(swift-sdk)!: freeze schemas only after App Store publication #4818, fix(swift-sdk)!: restore historical V2 migration to live V3 #4910) and published schema 3.0.0. The hand-registered V4 freeze and V5 from the earlier fix-up are gone: DashSchemaV3 is bound to its generated release snapshot and a new live DashSchemaV4 adds PersistentDashpayPaymentAddresses (V2 to V3 to V4, and V1 to V3 to V4). SCHEMA_RELEASES.md lists the routes.
  • Storage: SQLite stays at V019.

Also addressed in the remaining review threads: the tip resolver releases the wallet registry lock before its network lookups; Kotlin ties the tip account to the identity it was resolved for; Swift identity deletion paths remove payment-address rows; literal-bytes coverage for the pre-V019 profile record.

🤖 Generated with Claude Code

PR Hygiene · 6c88c31

  • Bots — coderabbitai not yet · thepastaclaw not yet — /skip-bots proceeds without the ones not yet reported
  • Self-review — post /self-reviewed
  • Within your 5 open PRs — this one is beyond the limit; it waits until one merges
  • Build running
  • Approvals
    • kotlin-sdk (packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt, packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt, packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt and 28 more) — HashEngineering
    • rs-platform-wallet-ffi (packages/rs-platform-wallet-ffi/src/dashpay_profile.rs, packages/rs-platform-wallet-ffi/src/identity_persistence.rs, packages/rs-platform-wallet-ffi/src/persistence.rs and 3 more) — HashEngineering or ZocoLini or llbartekll or romchornyi
    • wallet-storage (packages/rs-platform-wallet-storage/SCHEMA.md, packages/rs-platform-wallet-storage/migrations/V019__profile_address_encoding.rs, packages/rs-platform-wallet-storage/src/sqlite/mod.rs and 9 more) — lklimek
    • rs-platform-wallet (packages/rs-platform-wallet/docs/SHIELDED_TIPS.md, packages/rs-platform-wallet/src/changeset/changeset.rs, packages/rs-platform-wallet/src/error.rs and 18 more) — HashEngineering or ZocoLini or llbartekll or romchornyi
    • files with no dedicated owner (packages/rs-unified-sdk-jni/src/dashpay.rs, packages/rs-unified-sdk-jni/src/funding.rs, packages/rs-unified-sdk-jni/src/persistence.rs and 2 more) — QuantumExplorer or shumkov
    • swift-sdk (packages/swift-sdk/SCHEMA_RELEASES.md, packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift, packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift and 35 more) — llbartekll or romchornyi

When every box is checked the PR Hygiene check passes and this can merge.

PastaPastaPasta and others added 17 commits September 8, 2026 09:44
Resolves the conflicts with the 99 commits v4.2-dev gained since the branch
point and adapts the branch to them:

- platform-wallet-storage: the profile-encoding migration is renumbered
  V008 -> V019 (v4.2-dev already ships V008-V018). The stamp columns now
  DEFAULT 1 and the migration marks the rows it finds 0, so only pre-V019
  rows are ever decoded as legacy. The identities writer and both readers
  on the hard-delete schema (#4496) stamp and dispatch on `entry_format`;
  the dashpay writer stamps `profile_format`. The migrated-database walk
  moved to tests/sqlite_profile_address_encoding.rs (the crate's
  retired-table-name scan covers src/), backed by test-only legacy
  encoders. SCHEMA.md and the migration fingerprints updated.
- swift-sdk: V4 is frozen through scripts/freeze_schema_models.py (FREEZES
  row at 787cac0) instead of a hand-written copy; the dash-v5.store
  fixture is written by this build; the migration tests join the
  fixture-based suite from #4644.
- drive / drive-abci: PV14 fee and root-hash pins recomputed for DashPay
  contract v2 on top of the contract version item (#4749).
- platform-wallet: base's re-seeded shield regression fixture kept; both
  sides' viewing-key bind tests kept; the FFI account-indices exports
  re-appended after base's new test modules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tip send guard

- platform-wallet-ffi: `platform_wallet_manager_prepare_shielded_tip_address`
  and `platform_wallet_resolve_shielded_tip` run their worker call inside
  `catch_query_panic` (ErrorWalletOperation, retryable); the raw output
  writes happen only on success so the zeroed buffers survive a panic. A
  test-only fault injected inside the worker future drives the three real
  `extern "C"` tip entry points, so removing a guard at a call site fails
  `exported_tip_entry_points_contain_a_worker_panic`.
- SwiftExampleApp: `ShieldedTipSubmissions`, an app-owned per network and
  wallet guard, keeps an in-flight or uncertain tip send locked across sheet
  dismissal and both entry points (parity with the Kotlin app); tests.
- KotlinExampleApp: the tip account is derived from the live identity's
  optional derivation index (`ManagedPlatformWallet.identityIndex`), not
  Room's non-null `identityIndex` whose 0 is a placeholder that aliases
  identity 0's tip pool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Brings the branch up to date with v5.1-dev (1ebcedb). The DashPay
contract field (#4768) and the DPNS resolver fix (#4769) are now in the
base, so the branch's byte-identical copies of those files drop out of
the diff.

Conflict resolution:
- Room: v5.1-dev already ships versions 12-14, so the profile payment
  address migration becomes MIGRATION_14_15 (database version 15, schema
  15.json exported by Room); the migration tests walk 14->15 and 10->15.
- SwiftData: v5.1-dev replaced the development V4/V5 chain with App Store
  release snapshots (#4818, #4910) and published schema 3.0.0. Instead of
  the branch's hand-registered V4 freeze and V5, DashSchemaV3 is now bound
  to its generated release snapshot (DashSchemaSnapshotV3) and a new live
  DashSchemaV4 adds PersistentDashpayPaymentAddresses, with V2->V3->V4 and
  V1->V3->V4 lightweight stages. The DashSchemaV4 freeze files and the
  dash-v5.store fixture are dropped; the model is added to
  schema-models.json and SCHEMA_RELEASES.md describes the new routes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- FFI: platform_wallet_resolve_shielded_tip clones the identity wallet
  under the registry guard and releases it before the DPNS/profile
  network lookups, so a slow resolution no longer blocks handle
  creation and teardown for unrelated wallets.
- Kotlin: tag the produceState tip-account result with the
  (wallet, manager, identity) it was resolved for; produceState keeps
  its previous value when its keys change, so identity A's account could
  otherwise be offered to the tip sheet while B's lookup runs.
- Swift: payment-address rows are keyed by owner id with no relationship
  cascade, so PersistentIdentity.remove, the identity list's local
  removal and the orphan-identity deletion now remove them too
  (removeOwned is public for host deletion paths).
- Storage: pin the pre-V019 profile record with hand-written bincode
  bytes, independent of the frozen legacy declarations.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…mock query

v5.1-dev added DocumentQuery::integer_range_clauses; the branch's DPNS
mock query in the profile tests predates it and no longer compiled.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ormat stamp

patch_payments_into_entry_blobs read entry_blob without entry_format,
decoded it as the current IdentityEntry and wrote current-format bytes
under the old stamp. A pre-V019 identity whose first post-migration
write was a payment overlay round (changeset::core_bridge) would fail to
decode, or with empty profiles silently end up as current bytes stamped
0. Decode through decode_identity and restamp the row 1 in the same
UPDATE; the regression test walks a V007 legacy identity through a
payment-only round and reads it back through both identity readers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
DashPayTabScreen used produceState without importing it, and
DashPayProfileScreen smart-cast the delegated tipAccount property, which
Kotlin rejects. Both predate this round; CI skips Kotlin for fork PRs, so
neither had been compiled. Verified with :app:compileDebugKotlin,
:sdk/:app compileDebugUnitTestKotlin and :sdk:compileDebugAndroidTestKotlin.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ShieldedService rebuilt its bind request from the native
shieldedAccountIndices snapshot (after identity discovery and on the
post-Clear resume), and that snapshot includes every discovered and
retired tip account Rust binds on its own. With account 0 plus 64 tip
accounts the request tripped the 64-entry cap in the Swift wrapper and
the C export, so discovery could no longer register new accounts.
Both sites now go through ordinaryBindRequest, which drops tip accounts
and falls back to [0]; Rust still unions the tip accounts in.

Kotlin is unaffected: it binds with the default [0] request.

Tests: a Rust test binds 70 persisted tip accounts from a [0] request
(seedless and seeded) and shows the resulting snapshot exceeds the cap;
an app unit test covers ordinaryBindRequest with a 71-entry snapshot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ugh load

Drive FFIPersister::load with a host wallet-restore callback whose
identity carries a non-null dashpay_profile with distinct values for
every string and all three payment addresses, plus an identity with a
null profile pointer. The free callback scribbles over the host buffers
before the restored ManagedIdentity is asserted, so a restored profile
still borrowing host memory would fail. Removing the hydration branch
fails the test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ip account

Identity load and discovery prove an identity's index by deriving its
MASTER key, but for an identity already known (observed out-of-wallet,
or restored at a host placeholder slot) they only attached wallet_id and
left identity_index unset. That snapshot persists without an index, and
Kotlin/Swift, which cannot store "unknown", restore it as index 0, so
the dedicated tip account mapping would hand that identity identity 0's
tip pool.

- IdentityManager::adopt_into_wallet moves such an identity into the
  verified wallet slot and sets its index (never evicting a different
  identity from the slot); load and discovery call it, so new snapshots
  carry the real index and placeholder rows self-heal on rediscovery.
- prepare_shielded_tip_address now requires the seed to reproduce the
  identity's MASTER key at the claimed index before it allocates or
  returns a tip address, failing closed for unverified indices.

Tests: adopt_into_wallet re-slotting (observed, placeholder, occupied
slot); a cold-reload regression where a wallet-attached identity sits at
placeholder slot 0 alongside bound identity-0 tip funds is refused and
then accepted after rediscovery proves index 5.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…pied slot

adopt_into_wallet returned false when the verified slot held a different
identity, and both load and discovery ignored it, then marked the
unmoved identity active, set wallet_id and persisted key breadcrumbs at
the new index, leaving bucket, identity_index and persisted keys in
disagreement.

- adopt_into_wallet returns a typed IdentityIndexOccupied error and
  changes nothing; place_verified_identity (add when unknown, adopt
  otherwise) is the single step both callers run before touching status
  or keys, and reports Added vs Adopted.
- Load is an explicit single-identity operation and propagates the
  error.
- Discovery skips the conflicting index without touching either
  identity, counts the hit for the gap limit, and records the index as
  unanswered so the verdict is incomplete and the next launch rescans.
  Aborting would also stop the stale record from reaching its own
  verified slot later in the walk, which is what frees the contested one.

Tests: placement adds, adopts (not reported as new) and refuses an
occupied slot with the typed error and zero persisted stores; the scan
tally keeps walking and leaves the verdict incomplete on a conflict.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…very

Discovery reports only newly added identities, but it can also promote
an already-known identity into its verified slot, whose dedicated tip
account then needs registering. Rebind whenever shielded support is
available, keeping a bind failure non-fatal. The Swift screen already
rebinds unconditionally.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
add_identity rejected duplicate ids but inserted into the target slot
unconditionally, so an unknown identity verified at a slot held by
another (e.g. a restored placeholder) overwrote the occupant while the
occupant's reverse-index entry kept resolving to the newcomer; a later
adoption of the occupant then moved the wrong identity.

The occupied-slot check now lives in add_identity itself, before any
change or persist, returning the same IdentityIndexOccupied error, so
place_verified_identity refuses both the Added and Adopted paths and
discovery skips the index and marks it unanswered either way. Every
production caller (registration, invitation, register-from-addresses,
shielded-pool create) already treats an add_identity error as
non-fatal local bookkeeping that heals on the next sync; registration
targets a free slot.

Test: an unknown id targeting an occupied slot is refused through both
add_identity and place_verified_identity with both lookups, the bucket
and the persister write count unchanged; a free slot still works.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@PastaPastaPasta PastaPastaPasta added this to the v5.1.0 milestone Oct 5, 2026
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

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

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

⚙️ Run configuration
  • Configuration used: Repository: dashpay/platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 862cc98d-e4e9-44ac-98f1-1dc9ed9e8c71

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Waiting for bot review — coderabbitai not yet · thepastaclaw not yet. Wait for the missing reviews, or a writer can post /skip-bots to proceed without them; blocking findings still need addressing.
Full checklist in the description.

@github-actions github-actions Bot added the waiting-bots Waiting for the review bots to report on this head label Oct 5, 2026
@thepastaclaw

thepastaclaw commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

🔍 Review in progress — actively reviewing now (commit 6c88c31) · triage: critical
██████████░░░░░░░░░░ 47% · about 15 min left · running for 11 min
✅ triage → ✅ Phase 2 → ⏳ Phase 1 (1/2 lanes) → ▫️ verify 2 → ▫️ publish
Estimated from recent reviews of this tier · updated 00:47 UTC · live progress

Live V4 adds PersistentDashpayPaymentAddresses, which sorts ahead of
PersistentIdentity and renumbers it from 13 to 14. Core Data embeds the
ordinal in generated column names, so the token-balance table's
Z13TOKENBALANCES foreign key becomes Z14TOKENBALANCES with its values
intact. validatePreservation normalized Z_<n> join-table names but not
the Z<n><NAME> column form, so the V1 -> V3 -> V4 and historical
V2 -> V3 -> V4 migrations were reported as removing a column
(DashModelMigrationTests, CI on #5288).

validatePreservation now keys columns by entity name for the
cross-store comparison; a dropped or type-converted column is still
rejected. layout()/evidence() keep their existing keys so recovery
journals written by earlier builds still verify.

Test: a renumbered copy of the dash-v1 fixture (entity inserted at 8,
join-table and generated columns renamed) passes; the same copy with
the column dropped or converted to VARCHAR is rejected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Waiting for bot review — coderabbitai not yet · thepastaclaw ✓. Wait for the missing reviews, or a writer can post /skip-bots to proceed without them; blocking findings still need addressing.
Full checklist in the description.

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

Final validation — Phase 1 + Phase 2

Verified the supplied findings against head 02460d6 and confirmed four issues involving identity-placement persistence, restart-safe submissions, identity-specific spending-account selection, and concurrent account binding. These are client-side defects, classified as suggestions under the supplied policy reserving blocking severity for consensus invariants. Validation was static: the supplied CI snapshot reports successful Rust workspace and Kotlin checks, skipped dedicated wallet tests, and Swift build/tests still in progress.

🟡 4 suggestion(s)

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: platform-versioning); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 7: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 8: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The large, cross-language diff directly changes key derivation and dedicated account handling in packages/rs-platform-wallet/src/wallet/shielded/tips.rs, funds submission in packages/rs-platform-wallet-ffi/src/shielded_send.rs, and persisted-data migrations in SQLite, SwiftData, and Room.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 15% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — platform-versioning (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs:206-207: Preserve corrected identity indices through changeset merging
  The new adoption path changes identity_index, but IdentityChangeSet::merge never copies that field into an existing entry. With SQLite FlushMode::Manual, a snapshot buffered at placeholder index 0 can therefore absorb the subsequent discovery snapshot at verified index 5 while retaining index 0. Both Buffer::normalize_identities and the final buffered merge use these semantics, so flush/reload restores the incorrect placement. The same stale slot also remains occupied during buffered admission checks, potentially rejecting another identity that discovery places into the freed slot.

  Make identity_index follow the latest snapshot alongside wallet_id. Add a SQLite regression that buffers an old-slot snapshot, adopts the identity into its verified slot, persists the resulting snapshot, then flushes and reloads; also verify that another identity can occupy the freed slot.

In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ShieldedTipSubmission.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ShieldedTipSubmission.swift:28-32: Restore outstanding tip submissions before enabling another send
  The submission guard and wallet map are memory-only, and each new process initializes the guard to ready. Kotlin's ShieldedTipSubmissions has the same lifecycle. Restarting after a broadcast with an unresolved outcome therefore removes the warning and permits the user to retry the same intended payment as a new transfer. A relay withholding confirmation is sufficient to create this ambiguity; process termination during broadcast also exposes it.

  Rust records pending transfer activity and can redrive the original transition, but transfer reserves individual input notes rather than a payment intent. Additional spendable notes can fund the retry, allowing both transfers to execute. Persist an unresolved intent before entering the native send, restore its guard before enabling submission, and reconcile it with the original activity or transition. Add process-recreation coverage with an ambiguous original transfer and additional spendable notes; the existing reopening tests retain the same in-memory owner.

In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift:39-44: Verify the identity index before selecting its tip spending account
  Rust's optional index getter returns recorded metadata, not a verified identity-to-account association. The mobile restore path constructs ManagedIdentity::new(identity, spec.identity_index), so a persisted placeholder zero becomes Some(0). The existing should_refuse_tip_account_for_a_placeholder_index_until_it_is_verified regression demonstrates an identity belonging at index 5 restored at index 0 while index 0's tip account is bound.

  Preparation rejects this association by checking the seed, but this balance/spending selector and Kotlin's corresponding selectors bypass that check. They can display index 0's funds as belonging to the restored identity and pass that account to the send API, which validates account keys but receives no source identity to verify. Resolve identity-specific tip accounts through a verified wallet operation, or disable these selectors until the association is verified. Extend the placeholder regression to cover balance lookup and spending-account selection, not only address preparation.

In `packages/rs-platform-wallet/src/wallet/shielded/tips.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/tips.rs:142-147: Make tip preparation an atomic augmentation of the bound account set
  The account snapshot and replacement bind are separate operations. Preparation can snapshot ordinary account 0, another task can finish binding accounts 0 and 1, and preparation can then bind its stale snapshot plus the tip account. bind_shielded automatically retains discovered and persisted tip accounts, but not omitted ordinary accounts. Consequently, register_locked removes account 1 from registration and purges its live subwallet state, contradicting preparation's ordinary-account preservation guarantee.

  The shield guard and coordinator install transaction serialize individual installations, not the preceding read-modify-write sequence. Serialize configuration changes across the snapshot and bind, or provide an augmentation operation that merges with the current registration inside the installation transaction. Add a deterministic interleaving regression proving that an ordinary account added during preparation remains bound.
Out-of-scope follow-up suggestions (1)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • Seed verification runs while holding the wallet-manager read guard — The guard covers bounded synchronous derivation and verification with no network call or await inside the guarded block. Writers are briefly excluded, but the finding supplies no demonstrated hot-path or material contention problem; moving this work outside the lock is an optional optimization rather than a confirmed review defect.
    • Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.

Comment on lines +28 to +32
@Published private(set) var status: Status = .ready
@Published private(set) var message: String?

var busy: Bool { status == .sending }
var submitted: Bool { status != .ready }

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.

🟡 Suggestion: Restore outstanding tip submissions before enabling another send

The submission guard and wallet map are memory-only, and each new process initializes the guard to ready. Kotlin's ShieldedTipSubmissions has the same lifecycle. Restarting after a broadcast with an unresolved outcome therefore removes the warning and permits the user to retry the same intended payment as a new transfer. A relay withholding confirmation is sufficient to create this ambiguity; process termination during broadcast also exposes it.

Rust records pending transfer activity and can redrive the original transition, but transfer reserves individual input notes rather than a payment intent. Additional spendable notes can fund the retry, allowing both transfers to execute. Persist an unresolved intent before entering the native send, restore its guard before enabling submission, and reconcile it with the original activity or transition. Add process-recreation coverage with an ambiguous original transfer and additional spendable notes; the existing reopening tests retain the same in-memory owner.

source: gpt-6.1-sol (phase2-reviewer: general, architecture-layering, ffi-engineer, platform-versioning, rust-quality, security-auditor)

Comment thread packages/rs-platform-wallet/src/wallet/shielded/tips.rs Outdated
PastaPastaPasta and others added 2 commits October 5, 2026 13:16
…index

IdentityChangeSet::merge copied wallet_id and the other scalars but not
identity_index. Since discovery now re-slots an identity restored at a
placeholder index into its verified one, a SQLite Manual-flush buffer
holding the old-slot snapshot absorbed the re-slot snapshot and kept
index 0, both in Buffer::normalize_identities and the final merge: the
flush wrote the stale slot back and the freed slot refused its rightful
owner (IdentityIndexConflict).

identity_index now follows the latest snapshot like wallet_id. SQLite
regression: buffer slot 0, re-slot to 5, admit another identity into
slot 0, flush and reload; both identities land in their slots. It fails
with IdentityIndexConflict without the fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…owners

- Tip preparation read the bound account set and then bound an augmented
  copy as two steps; a bind of ordinary accounts landing in between was
  undone by the stale snapshot, dropping those accounts and purging their
  live state. A per-wallet shielded_config_lock is now taken by every bind
  entry point and held by preparation across snapshot and bind
  (bind_shielded_locked).
- send_shielded_tip now refuses to spend from a dedicated tip account
  whose slot holds an identity the seed does not prove at that index,
  before any recipient lookup or proving. Hosts pick the source account
  from the recorded index, and a host-restored placeholder 0 would
  otherwise spend identity 0's tip pool on another identity's behalf. An
  empty slot (a retired identity's account) stays spendable.

Tests: a deterministic interleaving (preparation paused after its
snapshot while another task binds accounts 0 and 1) keeps account 1 bound
and registered, and fails without the lock; the placeholder-index
regression now also covers spending from identity 0's tip account.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Waiting for bot review — coderabbitai not yet · thepastaclaw ✓. Wait for the missing reviews, or a writer can post /skip-bots to proceed without them; blocking findings still need addressing.
Full checklist in the description.

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

Re-review — Final validation — Phase 1 + Phase 2

Static verification confirms the original concurrent-bind race and buffered identity-index merge defect are fixed, and the reported occupied-placeholder spending case is now rejected before recipient resolution. Six client-side suggestions remain concerning restart recovery, stale source-account selection, identity replay, account preservation, and test isolation; no consensus-blocking defect was confirmed. No builds or tests were run, and the supplied exact-head CI snapshot still had most validation queued or pending.

🟡 6 suggestion(s)

1 finding(s) not shown inline (the lines are not part of this PR's diff)

🟡 Suggestion: Serialize shielded_add_account with the new shielded config lock
packages/rs-platform-wallet/src/wallet/platform_wallet.rs:1154-1160

Tip preparation now holds shielded_config_lock across its account snapshot and replacement bind, but shielded_add_account writes the same shielded_keys map without participating in that lock. Preparation can snapshot [0], an addition can persist and insert ordinary account 1, and preparation can then install its stale snapshot plus the tip account, overwriting the completed insertion while leaving its viewing-key row persisted. This breaks the new preparation helper's ordinary-account preservation guarantee even though the concurrent-bind case is fixed. Acquire the same configuration lock for the complete add operation, preserving the existing rule that host callbacks run outside the key-slot guard. The add API intentionally does not register accounts with the coordinator itself, so this finding concerns loss of its in-memory insertion, not a claim that it creates a coordinator registration. Extend the interleaving coverage to race preparation with this writer.

source: muse-spark-1.3-contributor (phase1-reviewer: rust-quality)

1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: platform-versioning); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 7: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 8: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); reviewer 9: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 10: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 11: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 12: gpt-6.1-sol (agent: phase2-reviewer, role: platform-versioning); reviewer 13: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 14: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — This large, intricate diff directly changes funds movement and dedicated ZIP-32 key/account handling in packages/rs-platform-wallet/src/wallet/shielded/tips.rs and packages/rs-platform-wallet-ffi/src/shielded_send.rs, as well as persisted-data migrations.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 15% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh final gate: an independent Phase-2 review ran after iterative findings were reconciled
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — platform-versioning (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — platform-versioning (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt`:
- [SUGGESTION] packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt:336-345: Invalidate and reverify the source account when an identity is re-slotted
  The lookup keys include the wallet wrapper, manager, and identity ID, but not the identity's placement. This PR allows discovery to move that same identity from placeholder index 0 to verified index 5 without replacing those objects. If discovery completes while this composition is alive, the cached result remains `0x40000000` and is still offered as that identity's dedicated source account. A blocking discovery can continue natively after its search screen is dismissed, so returning to DashPay before it finishes can expose this interleaving. The new Rust check does not reject the stale source: `verify_tip_account_owner` succeeds when slot 0 has become empty, and also succeeds if the rightful identity 0 later occupies it. A confirmed send can thus spend the retained account-0 funds rather than the selected identity's account-5 funds. Invalidate the lookup when verified placement changes and recheck the selected source identity/account association at submission; checking only the account's current occupant is insufficient. Add a regression resolving at placeholder 0, adopting into 5, and attempting a send with the old selection. The profile-screen lookup also needs placement-change invalidation.

In `packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs:195-207: Carry identity relocation through changeset replay
  Adoption now changes bucket placement and identity coordinates, but `IdentityManager::apply_identity_entry` still updates an existing identity in place and returns without applying `wallet_id`, `identity_index`, or `location_index`. Replaying valid snapshots A@0, then adopted A@5, then newly admitted B@0 through the public `PlatformWallet::apply` or `PlatformWalletInfo::apply_changeset` API leaves A at slot 0 until B's fresh insertion overwrites it. A's state is lost and its reverse-index entry still points to slot 0, so `identity(A)` returns B. Promotion from the observed bucket likewise fails to reproduce the adoption. Normal startup reconstruction uses a different path, which limits the current impact, but the public replay API no longer reproduces the mutations introduced here. Apply relocation while preserving managed state and reverse-index consistency, reject unrelated occupied targets, and handle merged batches independently of identity-ID ordering when one entry frees another's slot. Add round-trip coverage for observed-identity promotion and A@0 → A@5 followed by B@0.

In `packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt`:
- [SUGGESTION] packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt:147-151: Preserve ordinary accounts during discovery-triggered rebinding
  This new refresh calls the replacement bind API with its default account list `[0]`. For a wallet already bound to ordinary accounts `[0, 1]`, Rust adds discovered and persisted tip accounts but does not retain omitted ordinary account 1. `NetworkShieldedCoordinator::register_locked` consequently removes account 1's registration and purges its live notes and watermark, so discovering identities unexpectedly stops an existing ordinary account from scanning. Swift preserves a snapshot of ordinary accounts, but its snapshot and replacement call are also outside the native configuration lock. Provide a refresh operation that reads the current configuration and adds discovered tip accounts atomically under `shielded_config_lock`, and use it from discovery rather than treating `[0]` as a complete replacement configuration. Keep full bind available for intentional replacement. Cover discovery refresh starting from `[0, 1]` and verify that both ordinary registrations survive.

In `packages/rs-platform-wallet/src/wallet/platform_wallet.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/platform_wallet.rs:1154-1160: Serialize shielded_add_account with the new shielded config lock
  Tip preparation now holds `shielded_config_lock` across its account snapshot and replacement bind, but `shielded_add_account` writes the same `shielded_keys` map without participating in that lock. Preparation can snapshot `[0]`, an addition can persist and insert ordinary account 1, and preparation can then install its stale snapshot plus the tip account, overwriting the completed insertion while leaving its viewing-key row persisted. This breaks the new preparation helper's ordinary-account preservation guarantee even though the concurrent-bind case is fixed. Acquire the same configuration lock for the complete add operation, preserving the existing rule that host callbacks run outside the key-slot guard. The add API intentionally does not register accounts with the coordinator itself, so this finding concerns loss of its in-memory insertion, not a claim that it creates a coordinator registration. Extend the interleaving coverage to race preparation with this writer.

In `packages/rs-platform-wallet/src/wallet/shielded/tips.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/tips.rs:258-263: Isolate the preparation pause hook by wallet instance
  The global pause hook is consumed using only the deterministic wallet ID. The concurrency regression, placeholder-index regression, and discovery/restoration regression all create independent testnet wallet instances from `MESSAGE_SIGNING_TEST_MNEMONIC`, so they share that ID. Under parallel execution, another test's preparation can remove the concurrency test's hook and signal `reached` while holding its own configuration lock. The intended preparation then runs without pausing, allowing the competing bind to complete inside the timeout and failing the regression despite correct production locking. Attach the hook to shared state belonging to the actual wallet instance, use an instance-specific token, or give the concurrency fixture a distinct deterministic mnemonic. The current wallet-ID key does not provide the parallel-test isolation claimed by the comment.

In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ShieldedTipSubmission.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ShieldedTipSubmission.swift:28-32: Restore outstanding tip submissions before enabling another send
  (existing thread: https://github.com/dashpay/platform/pull/5288#discussion_r4185823786)
  The shared owner preserves the submission guard across sheet dismissal, but a new process reconstructs an empty wallet map and a `.ready` submission. Kotlin has the same lifecycle. After termination during an accepted-but-unconfirmed send, reopening therefore removes the uncertainty warning and permits the user to confirm the same payment again. Native pending activity and redrive records do not prevent this: note reservations exclude particular inputs, while `reserve_unspent_notes` can select other funded notes for a fresh transfer. The redrive transaction itself is armed only after an ambiguous broadcast result returns, so process loss during the call also cannot be treated as a recovered safe retry. Persist a network/wallet-scoped unresolved intent before entering native spending, restore its warning before enabling confirmation, and reconcile native activity or require explicit acknowledgment that another submission is a separate payment. Add process-recreation tests using newly constructed owners and sufficient independent notes to fund both attempts. The PR's pending maintainer decision is not an implemented recovery boundary.

Comment on lines +336 to +345
val tipAccountKey = listOf(managed, tipManager, identityHex)
val taggedTipAccount by produceState<Pair<List<Any?>, Result<Int>>?>(
initialValue = null, managed, tipManager, identityHex,
) {
value = tipAccountKey to runCatching {
val index = requireNotNull(managed) { "Wallet is not loaded" }
.identityIndex(identity.identityId)
?: error("Tip account requires a recoverable identity index")
requireNotNull(tipManager).shieldedTipAccountIndex(index)
}

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.

🟡 Suggestion: Invalidate and reverify the source account when an identity is re-slotted

The lookup keys include the wallet wrapper, manager, and identity ID, but not the identity's placement. This PR allows discovery to move that same identity from placeholder index 0 to verified index 5 without replacing those objects. If discovery completes while this composition is alive, the cached result remains 0x40000000 and is still offered as that identity's dedicated source account. A blocking discovery can continue natively after its search screen is dismissed, so returning to DashPay before it finishes can expose this interleaving. The new Rust check does not reject the stale source: verify_tip_account_owner succeeds when slot 0 has become empty, and also succeeds if the rightful identity 0 later occupies it. A confirmed send can thus spend the retained account-0 funds rather than the selected identity's account-5 funds. Invalidate the lookup when verified placement changes and recheck the selected source identity/account association at submission; checking only the account's current occupant is insufficient. Add a regression resolving at placeholder 0, adopting into 5, and attempting a send with the old selection. The profile-screen lookup also needs placement-change invalidation.

source: gpt-6.1-sol (phase2-reviewer: general)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Partly valid; fixed in 257fa0e. The lookup keys didn't include placement, so a re-slot while the screen was open left the old account cached. Both the DashPay tab and the profile lookup now also key on Room's identityIndex, which the persistence callback rewrites on a re-slot, so the lookup reruns against the live index. The tip sheet reads the account when you confirm, so it gets the corrected one.

We're not adding a separate check at submission:

  • A placeholder slot-0 spend while slot 0 is occupied is already refused by verify_tip_account_owner.
  • Once the re-slot empties slot 0, 0x40000000 is this wallet's own unowned (retired) tip account. Spending from it is allowed by design, and it doesn't touch another identity's funds.

🤖 Posted autonomously by Claude on behalf of pasta.

Comment on lines +195 to +207
self.wallet_identities
.entry(wallet_id)
.or_default()
.insert(identity_index, managed);
self.location_index_insert(*identity_id, target);
}
if let Some(managed) = self
.wallet_identities
.get_mut(&wallet_id)
.and_then(|bucket| bucket.get_mut(&identity_index))
{
managed.wallet_id = Some(wallet_id);
managed.identity_index = Some(identity_index);

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.

🟡 Suggestion: Carry identity relocation through changeset replay

Adoption now changes bucket placement and identity coordinates, but IdentityManager::apply_identity_entry still updates an existing identity in place and returns without applying wallet_id, identity_index, or location_index. Replaying valid snapshots A@0, then adopted A@5, then newly admitted B@0 through the public PlatformWallet::apply or PlatformWalletInfo::apply_changeset API leaves A at slot 0 until B's fresh insertion overwrites it. A's state is lost and its reverse-index entry still points to slot 0, so identity(A) returns B. Promotion from the observed bucket likewise fails to reproduce the adoption. Normal startup reconstruction uses a different path, which limits the current impact, but the public replay API no longer reproduces the mutations introduced here. Apply relocation while preserving managed state and reverse-index consistency, reject unrelated occupied targets, and handle merged batches independently of identity-ID ordering when one entry frees another's slot. Add round-trip coverage for observed-identity promotion and A@0 → A@5 followed by B@0.

source: muse-spark-1.3-contributor (phase1-reviewer: general, rust-quality); gpt-6.1-sol (phase2-reviewer: rust-quality)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 93d4d3b. Production restore doesn't go through this API, but replay should still reproduce the re-slot. Changes:

  • apply_identity_entry moves an existing identity to the (wallet_id, identity_index) its entry carries, via adopt_into_wallet, which keeps the reverse index in lockstep. If another identity still holds that slot, it logs and updates in place.
  • A fresh entry whose slot is occupied is skipped instead of overwriting the occupant.
  • apply_changeset applies batches order-independently through apply_identity_entries. Pure removals run first. Known identities run next, each deferred until a blocker moves out (a cycle falls back to an in-place update). New identities are inserted last. Removal still wins for an id that is both snapshotted and removed.

New tests in wallet/apply.rs:

  • A@0 → A@5 → B@0, applied one at a time and as a merged batch that visits B first
  • an observed identity promoted into the wallet
  • an occupied-slot insert being refused
  • a same-batch removal freeing a slot

🤖 Posted autonomously by Claude on behalf of pasta.

Comment on lines +147 to +151
// Rust adds tip accounts itself, so the default [0] request
// suffices; a bind failure stays non-fatal.
if (container.shieldedService.isAvailable) {
try {
mgr.bindShielded(wallet.walletId)

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.

🟡 Suggestion: Preserve ordinary accounts during discovery-triggered rebinding

This new refresh calls the replacement bind API with its default account list [0]. For a wallet already bound to ordinary accounts [0, 1], Rust adds discovered and persisted tip accounts but does not retain omitted ordinary account 1. NetworkShieldedCoordinator::register_locked consequently removes account 1's registration and purges its live notes and watermark, so discovering identities unexpectedly stops an existing ordinary account from scanning. Swift preserves a snapshot of ordinary accounts, but its snapshot and replacement call are also outside the native configuration lock. Provide a refresh operation that reads the current configuration and adds discovered tip accounts atomically under shielded_config_lock, and use it from discovery rather than treating [0] as a complete replacement configuration. Keep full bind available for intentional replacement. Cover discovery refresh starting from [0, 1] and verify that both ordinary registrations survive.

source: gpt-6.1-sol (phase2-reviewer: general, architecture-layering, ffi-engineer, platform-versioning, rust-quality, security-auditor)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Doesn't apply to the Kotlin app: it only ever binds ordinary account 0. These bind paths all request the default [0], and nothing in the Kotlin app or SDK adds other ordinary accounts:

  • AppContainer
  • ShieldedService.bind / bindEngine / clearLocalState
  • PlatformWalletManager.bindShielded
  • this discovery rebind

So the rebind can't drop an ordinary account, and Rust keeps the discovered and persisted tip accounts. Mirroring Swift's snapshot would need a new JNI binding for shielded_account_indices that nothing in Kotlin needs yet, so that's out of scope here.


🤖 Posted autonomously by Claude on behalf of pasta.

Comment on lines +258 to +263
pub(crate) async fn after_snapshot(wallet: [u8; 32]) {
let hook = AFTER_SNAPSHOT
.lock()
.unwrap()
.as_mut()
.and_then(|hooks| hooks.remove(&wallet));

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.

🟡 Suggestion: Isolate the preparation pause hook by wallet instance

The global pause hook is consumed using only the deterministic wallet ID. The concurrency regression, placeholder-index regression, and discovery/restoration regression all create independent testnet wallet instances from MESSAGE_SIGNING_TEST_MNEMONIC, so they share that ID. Under parallel execution, another test's preparation can remove the concurrency test's hook and signal reached while holding its own configuration lock. The intended preparation then runs without pausing, allowing the competing bind to complete inside the timeout and failing the regression despite correct production locking. Attach the hook to shared state belonging to the actual wallet instance, use an instance-specific token, or give the concurrency fixture a distinct deterministic mnemonic. The current wallet-ID key does not provide the parallel-test isolation claimed by the comment.

source: gpt-6.1-sol (phase2-reviewer: rust-quality)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 93d4d3b. The pause hook is now keyed by wallet instance (Arc::as_ptr of its shielded_config_lock, which clones share), not by the deterministic wallet id. Tests built from the same mnemonic no longer share a hook. The concurrency regressions now share one tip_race_fixture / race_tip_preparation helper.


🤖 Posted autonomously by Claude on behalf of pasta.

PastaPastaPasta and others added 2 commits October 5, 2026 18:53
…lots

- `shielded_add_account` takes `shielded_config_lock`, so tip
  preparation's snapshot-then-rebind can no longer drop an account added
  in between.
- Changeset replay follows identity re-slotting: an existing identity
  moves to the slot its entry carries, batches apply moves before new
  inserts and removals before both, and a new entry never overwrites an
  occupied slot.
- The tip-preparation test pause is keyed by wallet instance rather than
  wallet id, since tests sharing a mnemonic share the id.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s re-slotted

Discovery moves an identity from a placeholder index to its verified one
without replacing the wallet or manager objects the lookups were keyed
on, so the DashPay tab and profile kept the old tip account. Key them on
Room's identity index too, which the persistence callback rewrites.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@PastaPastaPasta

Copy link
Copy Markdown
Member Author

Re: the review's non-inline finding "Serialize shielded_add_account with the new shielded config lock" (platform_wallet.rs:1154-1160).

Confirmed and fixed in 93d4d3b. shielded_add_account now takes shielded_config_lock after its attachment check, before its key-slot read. Host callbacks still run outside the key-slot guard, and the lock order (config lock, then shield_guard / shielded_keys) is unchanged.

The new regression should_keep_an_added_account_during_tip_preparation pauses preparation after its snapshot, races shielded_add_account(1) against it, and checks that account 1 is still bound afterwards. The test fails without the lock.


🤖 Posted autonomously by Claude on behalf of pasta.

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Waiting for bot review — coderabbitai not yet · thepastaclaw not yet. Wait for the missing reviews, or a writer can post /skip-bots to proceed without them; blocking findings still need addressing.
Full checklist in the description.

`c_char` is `u8` on aarch64 Linux, where `as *mut u8` trips
clippy::unnecessary_cast; `cast::<u8>()` compiles cleanly on every target.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Waiting for bot review — coderabbitai not yet · thepastaclaw not yet. Wait for the missing reviews, or a writer can post /skip-bots to proceed without them; blocking findings still need addressing.
Full checklist in the description.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

waiting-bots Waiting for the review bots to report on this head

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants