fix: restore four Swift-parity guards in the Kotlin SDK, plus two DPNS marketplace defects - #4423
fix: restore four Swift-parity guards in the Kotlin SDK, plus two DPNS marketplace defects#4423bfoss765 wants to merge 9 commits into
Conversation
…er got Each of these landed in the Swift SDK through review and was never ported to Kotlin, so the Android persister diverged from the reference implementation. 1. Asset-lock Consumed (4) is terminal again. `onPersistAssetLockUpsert` did a plain Room upsert and `onPersistAssetLockRemoval` an unconditional delete, both last-write-wins. The wallet-event adapter's batched drain can deliver a stale reconstruction snapshot AFTER the live flow's consumption write, which silently regressed a consumed lock. Now a stored consumed row is never overwritten by a non-consumed write and never deleted by a non-consumed removal — mirroring PlatformWalletPersistenceHandler.swift:270 and :310. Non-terminal statuses stay last-write-wins in both directions. 2. Split isOwned from the marketplace columns. The identity-snapshot sweep deleted departed DPNS rows outright, and its canonical branch wrote saleStatusRaw = 0 / counterpartyIdentityId = null over live marketplace state. A name that departed in a round the marketplace pass could not classify lost its sale history permanently — Android-only; Swift never did this. The sweep now marks isOwned = false and deletes only when documentId is null (a pure label-cache row); the canonical branch refreshes only acquiredAt/label and carries every marketplace field through. Mirrors upsertDPNSNames (swift:1912-1941). 3. isLocal is promoted, not hardcoded. Every persister-created row got a constant false, so a wallet's own identities — always local — were mis-marked. The flag is now promoted whenever the row carries a wallet link (one-way: nothing writes false over true), plus a promote-only, idempotent load-path heal for rows already written by the old code. Mirrors swift:1827 and healIdentityIsLocalFlags (swift:4688, called from loadWalletList :4719). Safe on Android precisely because the Kotlin persister never mislinked walletId — "has a wallet link" is exactly "is wallet-owned". The example app's manual-add is corrected to isLocal = true to match owner semantics. 4. Watch-only clears a stale seedMismatch. `unlockWalletFromKeystore` returned on the no-mnemonic check before any status update, so a wallet whose seed failed to bind and whose Keystore entry was then removed kept publishing the unlock banner for a seed that is no longer there. Mirrors PlatformWalletManager.swift:764-770. Tests: 16 added, :sdk:testDebugUnitTest green at 325. Verified as real regression coverage — reverting the four fixes fails exactly the 10 tests that assert changed behavior, while the 6 that pin unchanged behavior (guard narrowness, cache-row deletion, observed identities) keep passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t zero-price listings Two defects in the DPNS marketplace lane, both surfaced by the same audit. **F1 — orphaned marketplace row after a restart.** `resolve_departed_name` derived a departed name's `previous_document_id` solely from the in-memory `dpns_name_states` map. That map is session-scoped: the load path builds it EMPTY on every process start and nothing rehydrates it. So a departure observed in the first sync pass after a restart resolved no document id, emitted no removal delta — and still deleted the label. Since the label is what triggers departure detection, no later pass ever revisited it: the host mirror (Swift PersistentDPNSName, the Android Room dpns_names table) kept a stale owned/listed row for a name the wallet no longer holds, permanently. The lookup now falls back to the durable mirror via a new defaulted `PlatformWalletPersistence::get_dpns_name_state`, following the same `Ok(None)`-default shape as `get_core_tx_record` / `list_wallet_core_txids`, so no existing backend breaks. `SqlitePersister` overrides it with a real reader scoped on all three of wallet, identity and normalized label — `Sold`/`Transferred` rows are retained here, so dropping the identity predicate could remove another identity's document. A persistence read failure degrades to today's behaviour rather than aborting the departure. Both defective arms are fixed at once: the `Ok(None)` arm and the summary in the `Err` arm shared the same resolution. **Zero-price listings.** `set_dpns_name_price` never validated `price != 0`. Consensus would accept the listing and anyone could then take the name for free. Rejected now with a typed `InvalidParameter` as the first statement — ahead of the operation gate and any network round-trip. `purchase_dpns_name` likewise refuses a `Some(0)` listing, checked before the `expected_price` comparison so the caller is told the listing is invalid rather than that the price moved. Every `> 0` path is unchanged. Also in this commit, from review of the above: - `read_all` moved its SQL into a `format!` as part of sharing the row projection with the new reader, which silently escaped the TC-P1-003 `prepare_cached` lint (its allow-list probe only inspects the three lines starting at the `.prepare(` call). Switched to `prepare_cached` and dropped the now-dead allow-list exemption rather than widen it — a stale exemption would later mask a genuinely uncached writer. - Added the missing coverage for the SQLite reader itself; the marketplace tests exercise it through a test double, which proves the wiring but not the SQL. No migration for a dedicated index: SQLite serves this from the `(wallet_id, document_id)` primary key, scanning one wallet's rows, at most once per departed name per pass. Adding one would bump max_supported_version and trip the forward-version gate. Tests: platform-wallet 855 passed / 0 failed; platform-wallet-storage 336 passed / 0 failed (doctests included). fmt and clippy (-D warnings) clean on both crates. Non-vacuity checked by disabling the persister branch: exactly the 4 fallback-dependent tests fail, including the end-to-end one through `resolve_departed_name`, while the steady-state and unwired-backend tests correctly keep passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (1)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughChangesKotlin wallet state
Rust DPNS recovery and marketplace flow
Estimated code review effort: 5 (Critical) | ~120 minutes Merge Risk: 🟡 Moderate · up to The marketplace departure-recovery change can report unresolved durable rows, but the delta emptiness check still treats those reports as empty, allowing consumers to discard them and leave recovery failures hidden. Merge should wait for this bounded correctness issue to be fixed or explicitly accepted by the owner. Sequence Diagram(s)sequenceDiagram
participant MarketplaceSync
participant WalletPersister
participant SQLiteNameState
participant DashNetwork
MarketplaceSync->>WalletPersister: recover departed DPNS document ID
WalletPersister->>SQLiteNameState: query wallet, identity, normalized label
SQLiteNameState-->>WalletPersister: return owned or retained row
WalletPersister-->>MarketplaceSync: return persisted document ID
MarketplaceSync->>DashNetwork: classify departed name
DashNetwork-->>MarketplaceSync: return resolved, retry, or failure outcome
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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. Comment |
|
⛔ Blockers found — Opus deferred (commit 1d83e8f) |
…the unit under test The item-4 fix had no regression test with teeth: `isGenuineWatchOnly` took a pre-computed Boolean and a bare clear lambda, so WatchOnlySeedMismatchTest exercised the helper against itself while the real guard sequence — storage read, status-key derivation, the seedMismatch clear, the early return — lived untested at the call site. Reverting the call site to the pre-fix `if (!walletStorage.hasMnemonic(walletId)) return false` kept the whole suite green. The seam now IS the sequence: `isGenuineWatchOnly` takes the wallet id, the storage existence probe, and the manager's status-map updater, and performs the read, the hex-key derivation, the clearing transform, and the verdict itself. The call site in `unlockWalletFromKeystore` is pure delegation. The rewritten tests drive the seam through a fake probe and a manager-shaped status map and pin the ordering (clear BEFORE the watch-only return; stored mnemonic touches nothing). Mutation-verified: transplanting the pre-fix call-site shape into the seam (watch-only verdict with no clear) fails 2 of the 3 tests; the stored-mnemonic test, which pins unchanged behavior, keeps passing. Full suite 325/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…departed-name fallback yet The item-5 narration overstated its reach. `get_dpns_name_state` is overridden only by `SqlitePersister`; `FFIPersister` keeps the `Ok(None)` default and the persistence vtable has NO read slot for the lookup — there is no callback a host could set. So the restart orphan the fallback exists to close is still live on both mobile hosts (the Android Room and iOS SwiftData mirrors) until a `get_dpns_name_state` read callback lands with the batched vtable/ABI additions. Corrects the docs on `previous_document_id_for`, the trait method, and `resolve_departed_name` to state that scope instead of implying the durable fallback reaches every host mirror. Docs only — no code change; `cargo check -p platform-wallet` and `cargo fmt --check` clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Two corrections on my own claims in this PR, both pushed (0311298). Item 4's test evidence was circular. The watch-only test exercised the extracted helper against itself: reverting the production call site to the pre-fix Item 5's description overstated its reach. The |
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
The Kotlin parity changes and zero-price guards are generally well scoped, but the new SQLite departed-name recovery can still orphan the current row when multiple historical documents match, and it permanently loses recovery after a transient persistence-read failure. The production SQLite decoder also needs checked timestamp conversions, and the purchase-side zero-price ordering lacks direct regression coverage.
Source: reviewers codex general/security-auditor/rust-quality (backend model gpt-5.6-sol); final verifier codex (backend model gpt-5.6-sol). openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.
Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— security-auditor (completed),gpt-5.6-sol— rust-quality (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 2 blocking | 🟡 2 suggestion(s)
🤖 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-storage/src/sqlite/schema/dpns_name_states.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/dpns_name_states.rs:249-252: Select the current retained row deterministically
The schema does not enforce uniqueness for `(wallet_id, identity_id, normalized_label)`; its primary key is `(wallet_id, document_id)`. DPNS domain documents can be deleted and re-registered with a new document ID, while this table deliberately retains earlier `Sold` and `Transferred` rows. A wallet identity can therefore have an old historical row and a newer owned row for the same normalized label. This unordered `LIMIT 1` may select the old row according to primary-key scan order. If the current document is then deleted and the fallback runs after restart, the sync removes the historical row and the identity label while leaving the current persisted row permanently orphaned. Prefer an `Owned` row, then the most recently synchronized row, and update the trait documentation that currently says any matching row is acceptable.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/schema/dpns_name_states.rs:211-216: Use checked conversions for persisted timestamps
Only `price` has a non-negative schema constraint; the four timestamp columns permit negative SQLite integers. These `as u64` casts silently turn a malformed or externally modified `-1` into `u64::MAX`, despite the storage crate's explicit rule that durable-boundary casts use `safe_cast`. This decoder is now used by a production persistence lookup rather than only the test-gated whole-table helper. Decode every signed database value through `i64_to_u64` so malformed rows return the existing typed `IntegerOverflow` error.
In `packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs:437-446: Retry departed-name recovery after persistence read failures
`Ok(None)` means that a backend does not support this lookup or has no row, while `Err` means an attempted persistence read failed; this branch collapses those distinct outcomes. If SQLite returns a transient read error and the subsequent Platform lookup confirms that the domain document is absent, `resolve_departed_name` receives no previous document ID and sets `retry` to false. The caller then removes the label without emitting a row-removal delta. Because that label is the trigger for future departure detection, the durable row is permanently orphaned. `PersistenceError` already preserves transient classification, and this flow already retains pending departures for retryable network errors. Propagate the persistence lookup result into `resolve_departed_name` and retain the pending departure on a transient error instead of treating it as an unsupported lookup.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs:1168-1179: Add direct coverage for purchase-side zero-price rejection
The added tests exercise `set_dpns_name_price(0)` and its non-zero path, but no test reaches this separate guard in `purchase_dpns_name`. Add a regression case whose fetched domain state has `price = Some(0)` and whose `expected_price` is non-zero. It should assert `InvalidParameter`, rather than `DocumentPriceChanged`, and verify that signing and broadcast are not reached. This pins the ordering that the new code and API documentation explicitly promise.
…ts timestamps checked Two round-3 findings on the persisted-row lookup that backs departed-name recovery. Deterministic selection (blocking). `(wallet_id, identity_id, normalized_label)` is not unique: the primary key is `(wallet_id, document_id)`, a DPNS name can be deleted and re-registered under a fresh document id, and this table deliberately retains the earlier `sold` / `transferred` row — so one identity can hold a historical row AND the current one for the same label. The unordered `LIMIT 1` followed primary-key scan order, which is document-id order and unrelated to recency, and could hand back the historical row. The caller then deletes THAT row and drops the identity's label — the only trigger for future departure detection — leaving the current row orphaned forever. Now ordered `owned` first, then `last_synced_at_ms DESC`, then `document_id DESC`, and the trait contract on `get_dpns_name_state` states the same preference for every backend instead of saying any match will do. Checked timestamp decode (suggestion). `price` is the only column with a non-negative CHECK; the four timestamp columns are unconstrained, so `as u64` turned a corrupted or hand-edited `-1` into `u64::MAX` — a year-584-million timestamp that reads back as valid marketplace state. All five signed columns now decode through `safe_cast::i64_to_u64` and surface the typed `IntegerOverflow` naming the column. This decoder now backs a production read, not just the test-gated whole-table helper. Tests: the ordering test asserts the pre-fix unordered query really does select the historical row before asserting the fixed one selects the current row, so it cannot pass vacuously; a second test pins that recency outranks document id when no owned row is left; a third corrupts each timestamp column in turn and asserts the error names it. `cargo test -p platform-wallet-storage` 138+ pass, clippy and fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ce read FAILS Two round-3 findings on the DPNS marketplace departure path. Persistence Result no longer collapsed (blocking). `previous_document_id_for` flattened `Err` into `None`, erasing the difference between "the backend answered and has no row" and "the read failed, so we do not know". With the second reading as the first, a transient SQLite error followed by a Platform lookup that CONFIRMS the domain document is gone resolved the departure with no document id: the label was removed while no row-removal delta was emitted. The label is what triggers departure detection, so nothing ever revisits that row — the durable mirror keeps an owned/listed row for a name the wallet no longer holds, permanently. The lookup now returns `Result` and `resolve_departed_name` decides: a transient error retains the pending departure (`retry`), which makes the sync loop push it back on the queue and break BEFORE `remove_dpns_label`, so the label, the queue entry and the row all survive to the next pass. A non-transient error (Fatal / Constraint / LockPoisoned) cannot be retried into success, so it degrades to `None` with a warning rather than wedging this identity's departure queue for the life of the process. Purchase-side zero-price coverage (suggestion). `purchase_dpns_name`'s guard had no test — only the listing-side `set_dpns_name_price` did. The price pre-flight is extracted as a pure `preflight_purchase_price` so its rejection ORDER can be pinned directly, and the end-to-end tests drive the real method against a mock Platform serving a document listed at `$price = 0`. The purchaser is deliberately not a wallet identity, so every step after the pre-flight fails with a different, identifiable error — a typed `InvalidParameter` back is proof the guard fired and nothing downstream ran. The `expected_price = 0` case matters most: the prices MATCH, so without the guard the call would sail on into signing and broadcast. Test infrastructure: the mock SDK is now primed to answer the DPNS contract fetch and the exact-match domain query, with the protocol version pinned (expectations are keyed by the encoded request, and an unpinned mock ratchets its version on the first response, silently re-encoding every later query so nothing matches). This is what makes the retention test non-vacuous: it asserts the confirmed-absent branch is reached with a healthy mirror before asserting a failing mirror retains instead, then heals the mirror and asserts the departure finally resolves with its removal delta. Without the primed mock every fetch errors and the retry arm is reached anyway, so the assertion would have proved nothing. `cargo test -p platform-wallet --features shielded` 851 pass, clippy and fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## v4.2-dev #4423 +/- ##
============================================
- Coverage 87.74% 87.27% -0.48%
============================================
Files 2681 2727 +46
Lines 342632 347325 +4693
============================================
+ Hits 300658 303112 +2454
- Misses 41974 44213 +2239
🚀 New features to boost your workflow:
|
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
All four previously verified findings are fixed at the current head: the SQLite reader selects deterministically, persisted integers use checked conversions, transient read failures retain departures, and purchase-side zero-price behavior has direct coverage. Two new in-scope blockers remain in departed-name recovery: the in-memory source can select a historical row instead of the current row, and non-transient persistence failures still discard the only future reconciliation trigger.
Source: Codex reviewers (general, security-auditor, rust-quality): gpt-5.6-sol; final verifier: gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.
Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— security-auditor (completed),gpt-5.6-sol— rust-quality (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 2 blocking
🤖 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/network/dpns_marketplace.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs:437-442: Select the current row from the in-memory snapshot too
`previous_rows` is keyed by document ID and retains `Sold` and `Transferred` history, so one identity can have both a historical document and a newer `Owned` document for the same normalized label. This `.find()` follows `BTreeMap` document-ID order rather than the deterministic current-row preference now required of persistence backends, and any match prevents the corrected SQLite fallback from running. If the newer document later disappears, recovery can remove the historical row and drop the identity label while leaving the actual current row permanently orphaned. Apply the same `Owned`, `last_synced_at_ms`, and document-ID ordering to the in-memory snapshot.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs:1675-1684: Preserve the departure after non-transient persistence read failures
This arm converts a failed persistence read into `previous_document_id = None`. If Platform then confirms the domain document is absent, the caller removes the identity label without emitting a row-removal delta and reports a successful sync. That label is the only trigger for future departure detection, so the persisted marketplace row remains permanently orphaned even if the underlying problem is later repaired. This is reachable through the newly checked decoder, for example when a negative timestamp produces a fatal `IntegerOverflow`. A non-transient classification means the operation should not be retried indefinitely without surfacing the failure; it does not establish that no row exists. Preserve the label and propagate the persistence failure, or retain a terminal per-item failure without taking the successful-departure path.
…ry snapshot and preserve departures over fatal persistence reads Two departed-name recovery fixes in dpns_marketplace: 1. previous_document_id_for's in-memory snapshot scan was a first-match in document-id order, but previous_rows retains Sold/Transferred history, so one identity can hold a historical row and the current Owned row under the same normalized label. A historical hit removed the wrong row, dropped the label, orphaned the current row, and masked the corrected SQLite fallback. The scan now applies the same deterministic selection the persistence contract demands of get_dpns_name_state backends: Owned first, then greatest last_synced_at_ms, then greatest document_id. 2. A NON-retryable persistence read failure (Fatal / Constraint / LockPoisoned) degraded to "no previous id"; if Platform then confirmed the domain document absent, the departure resolved, removed the label with no removal delta behind it, and reported a successful sync — permanently orphaning the durable row. The error is now HELD until the pass knows whether the id is load-bearing: Sold/Transferred departures (which never read it) resolve normally, while the confirmed-absent branch turns it into a terminal per-item failure (DepartureResolution::Failed) — the label and durable row are preserved so a later pass re-detects the departure once the backend heals, and the skip is surfaced on the sync summary as a FailedDpnsDeparture instead of silently swallowed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.
Inline comments:
In
`@packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt`:
- Line 2915: Rename the newly added test methods in
PlatformWalletPersistenceHandlerTest, including
consumedAssetLockIsNotRegressedByAStaleNonConsumedUpsert and the other listed
tests, to descriptive Kotlin backtick identifiers beginning with “should”, while
preserving their test behavior.
In `@packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs`:
- Around line 286-289: The DpnsSyncPassSummary delta logic currently ignores
departures_failed and host-facing summaries omit it. Update is_empty_delta() and
has_delta() to treat non-empty departures_failed as a delta, and propagate
FailedDpnsDeparture through the event and detailed FFI summary representations.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: d8382d3d-4342-47da-96da-6b587f9992a1
📒 Files selected for processing (14)
packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/LoadIdentityScreen.ktpackages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.ktpackages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/IdentityDao.ktpackages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.ktpackages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.ktpackages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/WatchOnlySeedMismatchTest.ktpackages/rs-platform-wallet-storage/src/sqlite/persister.rspackages/rs-platform-wallet-storage/src/sqlite/schema/dpns_name_states.rspackages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rspackages/rs-platform-wallet-storage/tests/sqlite_compile_time.rspackages/rs-platform-wallet/src/changeset/traits.rspackages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rspackages/rs-platform-wallet/src/wallet/identity/network/mod.rspackages/rs-platform-wallet/src/wallet/persister.rs
💤 Files with no reviewable changes (1)
- packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.
thepastaclaw
left a comment
There was a problem hiding this comment.
Final validation — Codex/Sol only (Phase 2 disabled)
The two prior blocking findings are fixed at the exact head: in-memory recovery now uses the deterministic current-row preference, and fatal persistence failures preserve the departure instead of orphaning its durable row. Two non-blocking review items remain: the terminal outcome is not tested through the sync-loop caller, and the newly added Kotlin tests do not follow the repository's required should … naming convention.
Source: Codex reviewers (general, rust-quality, security-auditor): gpt-5.6-sol; final verifier: gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.
Validated zero-blocker Codex/Sol precheck evidence was promoted to final because Phase 2 (Sonnet/Opus) is temporarily disabled. This is Codex/Sol-only final validation, not Codex + Sonnet/Opus coverage.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— rust-quality (completed),gpt-5.6-sol— security-auditor (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet/Opus: not run (Phase 2 disabled — temporary Codex/Sol-only final)
- Secondary pass: disabled (
temporary_phase2_sonnet_disable)
🟡 1 suggestion(s) | 💬 1 nitpick(s)
🤖 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/network/dpns_marketplace.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs:1641-1648: Exercise terminal departure failure through the sync-loop caller
The new tests call `resolve_departed_name` directly and prove that a fatal mirror read produces `DepartureResolution::Failed`, but they do not exercise this load-bearing caller branch. A regression here could still remove the label, apply a row delta, omit `departures_failed`, or prevent a later pass from rediscovering the departure while every resolver test remains green. Add a `sync_dpns_marketplace` regression with a managed identity carrying the departed label, a confirmed-absent Platform response, and a fatal mirror read. Assert that the label and persisted row remain, no successful departure or row delta is emitted, and `departures_failed` contains the failure; then heal the mirror and verify a later pass completes the removal.
In `packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt`:
- [NITPICK] packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt:2915-3189: Name the new Kotlin tests with the required `should` prefix
All 13 tests added in this section use camel-case names such as `consumedAssetLockIsNotRegressedByAStaleNonConsumedUpsert`. The repository testing guideline in `AGENTS.md` requires descriptive test names starting with `should …`. Rename these newly added methods to Kotlin backtick identifiers beginning with `should` while leaving their behavior unchanged.
…h the sync-loop caller The DepartureResolution::Failed tests called resolve_departed_name directly; nothing proved the load-bearing branch in sync_dpns_marketplace that consumes it. The new regression drives a full pass with a managed identity still carrying the departed label, Platform confirming the domain document absent, and a fatal mirror read, asserting the failure lands in departures_failed while the label stays, no deltas or names_departed entry are emitted, nothing reaches the durable mirror, and the queue is not parked — then heals the mirror and proves a later pass completes the removal with the document id recovered from it. MirrorPersister now records the DPNS name-state changesets handed to store(), so the test asserts what actually crossed the persistence boundary, not merely what the summary claims. Verified the test fails against the old degrade-to-None behavior by mutation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… should prefix Renames the 13 tests added at PlatformWalletPersistenceHandlerTest.kt lines 2915-3189 to descriptive names beginning with "should", per the AGENTS.md testing guideline. shouldCamelCase (not backtick style) to match this file's existing all-camelCase names and the module's other should-prefixed tests (PlatformWalletManagerInitializationTest). Behavior unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
The two prior findings are fixed: terminal persistence failures are now tested through the full marketplace sync loop, and all 13 new Kotlin tests use the required should prefix. One in-scope blocker remains: when a deleted label has been re-registered under a new document ID, departure resolution deletes the replacement ID and permanently orphans the recovered historical row. Source: Codex reviewers gpt-5.6-sol (general, FFI engineer, Rust quality, and security auditor); final verifier gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.
Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— ffi-engineer (completed),gpt-5.6-sol— general (completed),gpt-5.6-sol— rust-quality (completed),gpt-5.6-sol— security-auditor (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 1 blocking
🤖 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/network/dpns_marketplace.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs:1877-1886: Remove the recovered document when the label was re-registered
`previous_document_id` identifies the current row selected from this identity's in-memory snapshot or durable mirror, but this branch discards it whenever an exact-label document currently exists. DPNS domain documents are deletable, and the new persistence contract explicitly handles a label being re-registered under a fresh document ID. If persisted document A was deleted and unrelated document B was registered under the same normalized label, `dpns_name_state` returns B; `classify_departure(B, old_identity)` returns `None`, so this code reports and removes B. The sync caller then drops the old identity's label, while row A remains with no trigger for a future reconciliation pass. When the recovered ID differs from the live state's ID, resolve the prior incarnation by removing A rather than treating B as the departed document. If the prior-ID lookup failed and the replacement makes that ID necessary, preserve the label through `DepartureResolution::Failed`. Add a regression with persisted A and a live, history-unrelated B that asserts A is the removal ID and the replacement is untouched.
| }, | ||
| entry: status.map(|sale_status| state.to_entry(*identity_id, sale_status, now)), | ||
| remove_document_id: status.is_none().then_some(state.document_id), | ||
| retry: false, | ||
| resolution: DepartureResolution::Resolved, |
There was a problem hiding this comment.
🔴 Blocking: Remove the recovered document when the label was re-registered
previous_document_id identifies the current row selected from this identity's in-memory snapshot or durable mirror, but this branch discards it whenever an exact-label document currently exists. DPNS domain documents are deletable, and the new persistence contract explicitly handles a label being re-registered under a fresh document ID. If persisted document A was deleted and unrelated document B was registered under the same normalized label, dpns_name_state returns B; classify_departure(B, old_identity) returns None, so this code reports and removes B. The sync caller then drops the old identity's label, while row A remains with no trigger for a future reconciliation pass. When the recovered ID differs from the live state's ID, resolve the prior incarnation by removing A rather than treating B as the departed document. If the prior-ID lookup failed and the replacement makes that ID necessary, preserve the label through DepartureResolution::Failed. Add a regression with persisted A and a live, history-unrelated B that asserts A is the removal ID and the replacement is untouched.
source: ['codex']
Six audit-verified fixes in one batch.
Items 1-4 are Swift-parity restorations: each landed in the Swift SDK through review and was never ported to Kotlin, so the Android persister has been running without a guard its own reference implementation has. Every one cites the Swift code it mirrors.
Items 5-6 are two adjacent defects in the marketplace lane (Rust) surfaced by the same audit. Item 6 is fully fixed; item 5 lands the Rust plumbing and the SQLite backend but does not yet reach the mobile hosts — see its section.
1. Consumed asset locks are terminal again
Swift reference:
PlatformWalletPersistenceHandler.swift:270(upsert),:310(removal)onPersistAssetLockUpsertdid a plain Room@UpsertandonPersistAssetLockRemovalan unconditional delete — both last-write-wins. The writers race: the wallet-event adapter's batched drain can deliver a stale reconstruction/enrichment snapshot after the live flow's synchronous consumption write, silently regressing a consumed lock. Swift guards both sides; Kotlin did not.A stored
Consumed(4) row is now never overwritten by a non-consumed write and never deleted by a non-consumed removal. The guard is deliberately narrow — non-terminal statuses legitimately move both ways and stay last-write-wins.2.
isOwned/ marketplace-column authority splitSwift reference:
upsertDPNSNames,PlatformWalletPersistenceHandler.swift:1912-1941Two halves, both restored:
isOwned = falseand deletes only whendocumentId == null(a pure label-cache row). This is the audit's Android-only permanent-destruction case: a name that departed during a round the marketplace pass could not classify lost the only record of where it went. Swift never did this.saleStatusRaw = 0andcounterpartyIdentityId = nullover live marketplace state on every identity flush. Swift refreshes onlyacquiredAt/label(plusisOwned = true).The identity snapshot's authority stops at
isOwned; the marketplace columns belong to the reconciliation lane.3.
isLocalpromotion + startup healSwift reference:
row.isLocal = trueon wallet linkage (swift:1827);healIdentityIsLocalFlags()(swift:4688, called fromloadWalletList:4719)The persister hardcoded
isLocal = falseon every row it created, so a wallet's own identities — which are always local — were mis-marked, with no promotion on wallet linkage and no heal for rows already written.falseover atrue.UPDATE identities SET isLocal = 1 WHERE walletId IS NOT NULL AND isLocal = 0) runs from the load path — the one guaranteed per-launch pass over the store — and is skipped while a changeset round is open.The heal is safe on Android precisely because the Kotlin persister never mislinked
walletId. It only ever writes the link the FFI entry declared, so "has a wallet link" is exactly "is wallet-owned" — the condition cannot over-promote.Also corrects the example app's
LoadIdentityScreenmanual-add toisLocal = true: a manual add is an identity the owner deliberately tracks, which is what the flag means. It carries nowalletId, so neither the promotion nor the heal can reach it — that write is the only thing that can set it.4. Watch-only clears a stale seed-mismatch flag
Swift reference:
PlatformWalletManager.swift:764-770unlockWalletFromKeystorereturned on thehasMnemoniccheck before any status update. A wallet whose seed failed to bind and whose Keystore entry was then removed kept publishing the unlock banner forever for a seed that is no longer there — nothing downstream of the early return could clear it. No mnemonic is not a mismatch.Ordering is the whole contract, so the ENTIRE guard sequence — the
hasMnemonicstorage read, the hex status-key derivation, theseedMismatch = falsetransform, and the early-return verdict — lives in aninternalseam (isGenuineWatchOnly; the file's established pattern for native-free unit tests — cf.initializePlatformWalletNativeManager,decodeShieldedCreatePayload), and the call site inunlockWalletFromKeystoreis pure delegation. The tests drive the seam through a fake storage probe and a manager-shaped status map and assert the clear provably runs before the watch-only return. An earlier cut of this fix extracted only a Boolean-taking helper, which left the real call-site sequence untested — see the corrected non-vacuity note below.5. Orphaned marketplace row after a restart (audit F1) — Rust-side plumbing + SQLite backend; mobile-host wiring pending the persistence-vtable batch — the mobile orphan is NOT yet fixed
resolve_departed_namederived a departed name'sprevious_document_idsolely from the in-memorydpns_name_statesmap. That map is session-scoped: the load path builds it EMPTY on every process start and nothing rehydrates it. So a departure observed in the first sync pass after a restart resolved no document id, emitted no removal delta — and still deleted the label. Since the label is what triggers departure detection, no later pass ever revisited it, and the host mirror kept a stale owned/listed row for a name the wallet no longer holds, permanently.The lookup now falls back to the durable mirror through a new defaulted
PlatformWalletPersistence::get_dpns_name_state, following the sameOk(None)-default shape as the existingget_core_tx_record/list_wallet_core_txids, so no existing backend breaks.SqlitePersisteroverrides it with a real reader scoped on all three of wallet, identity and normalized label —Sold/Transferredrows are retained in that table, so dropping the identity predicate could remove another identity's document. A persistence read failure degrades to today's behaviour rather than aborting the departure.Scope — what this does and does not fix. The fallback reaches only backends that implement the read, and today that is
SqlitePersisteralone.FFIPersisterkeeps theOk(None)default, and the persistence vtable has no read slot for this lookup — there is no callback a host could set. So the Android Room and iOS SwiftData mirrors — the two hosts this section's orphan narrative describes — still resolve nothing after a restart, and their orphan is still live. On Android, combined with item 2's keep-as-history sweep, it now renders as a permanent "Owned · not listed" card in the marketplace list (see item 2's note). Closing it requires aget_dpns_name_stateread callback in the persistence vtable, which is deliberately deferred to the batched vtable/ABI additions rather than shipped piecemeal here; the in-code docs onprevious_document_id_forand the trait method state this scope plainly.Both defective arms are fixed at once on implementing backends: the
Ok(None)arm and the summary in theErrarm shared the same resolution.No migration for a dedicated index — SQLite serves this from the
(wallet_id, document_id)primary key, scanning one wallet's rows, at most once per departed name per pass. Adding one would bumpmax_supported_versionand trip the forward-version gate.6. Zero-price listing guard
set_dpns_name_pricenever validatedprice != 0. Consensus would accept the listing and anyone could then take the name for free. It is now rejected with a typedInvalidParameteras the first statement — ahead of the operation gate and any network round-trip.purchase_dpns_namelikewise refuses aSome(0)listing, checked before theexpected_pricecomparison so the caller is told the listing is invalid rather than that the price moved. Every> 0path is unchanged.There is no legitimate zero-price caller: a deliberate free handover is
transfer_dpns_name, which names the recipient.Test evidence
:sdk:testDebugUnitTest(Kotlin)cargo test -p platform-wallet --features shieldedcargo test -p platform-wallet-storagecargo fmt --check(both crates)cargo clippy --all-targets -- -D warnings(both crates)Non-vacuity was verified rather than assumed — a green suite proves nothing if the tests don't gate the fix:
if (!walletStorage.hasMnemonic(walletId)) return false— the actual regression — kept the whole suite green (verified, 325/0). That gap is closed: the guard sequence now lives whole in theisGenuineWatchOnlyseam, the call site is pure delegation, and the mutation was re-run against the seam — transplanting the pre-fix call-site shape into it fails 2 of the 3 watch-only tests, while the stored-mnemonic test (pinning unchanged behavior) keeps passing. The one line JVM tests still cannot see is the delegation call itself, which now carries no logic.resolve_departed_name_recovers_the_document_id_from_the_persister, which runs the real async fn on a realIdentityWalletand so proves the fallback is wired into production rather than merely reachable as a helper. The steady-state test (..._without_reading_the_persister) and the unwired-backend test correctly keep passing. These exercise the SQLite-backed path; there is no FFI-host equivalent to test because no FFI read slot exists yet (see item 5's scope).Also in scope, from reviewing the above
read_allmoved its SQL into aformat!as part of sharing the row projection with the new reader, which silently escaped the TC-P1-003prepare_cachedlint — its allow-list probe only inspects the three lines starting at the.prepare(call. Switched toprepare_cachedand dropped the now-dead allow-list exemption rather than widening it; a stale exemption would later mask a genuinely uncached writer.Reviewer notes
documentId, a shape the old Kotlin condition skipped entirely, leaving them to leak afteronRemoveDpnsNameStatenulls the marketplace columns. This is Swift's exact behavior, so it is parity, but it is worth a look.get_dpns_name_stateread callback.@QueryUPDATE, andIdentityEntityis untouched.Summary by CodeRabbit
New Features
Bug Fixes