Skip to content

fix: restore four Swift-parity guards in the Kotlin SDK, plus two DPNS marketplace defects - #4423

Open
bfoss765 wants to merge 4 commits into
v4.2-devfrom
fix/kotlin-swift-parity-batch
Open

fix: restore four Swift-parity guards in the Kotlin SDK, plus two DPNS marketplace defects#4423
bfoss765 wants to merge 4 commits into
v4.2-devfrom
fix/kotlin-swift-parity-batch

Conversation

@bfoss765

@bfoss765 bfoss765 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

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)

onPersistAssetLockUpsert did a plain Room @Upsert and onPersistAssetLockRemoval an 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 split

Swift reference: upsertDPNSNames, PlatformWalletPersistenceHandler.swift:1912-1941

Two halves, both restored:

  • The sweep deleted departed DPNS rows outright, with no marketplace-history guard. Swift sets isOwned = false and deletes only when documentId == 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.
  • The canonical branch wrote saleStatusRaw = 0 and counterpartyIdentityId = null over live marketplace state on every identity flush. Swift refreshes only acquiredAt/label (plus isOwned = true).

The identity snapshot's authority stops at isOwned; the marketplace columns belong to the reconciliation lane.

Note this pairs directly with item 5 — the unclassifiable departure round that destroyed the row here is the same round item 5 addresses on the Rust side. One consequence of restoring Swift's keep-as-history behavior: on hosts where item 5's fallback is not yet wired (both mobile hosts — see item 5's scope), the restart-orphaned row is no longer destructively erased by this sweep, so it surfaces as a permanent "Owned · not listed" card in the Android marketplace list. That is the same stale card the Swift host shows in the same scenario — parity, not a new defect class — but it is newly visible on Android, and it is the visible face of the still-open mobile orphan.

3. isLocal promotion + startup heal

Swift reference: row.isLocal = true on wallet linkage (swift:1827); healIdentityIsLocalFlags() (swift:4688, called from loadWalletList :4719)

The persister hardcoded isLocal = false on 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.

  • Promotion is one-way: set as soon as the row carries a wallet link; nothing writes false over a true.
  • A promote-only, idempotent heal (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 LoadIdentityScreen manual-add to isLocal = true: a manual add is an identity the owner deliberately tracks, which is what the flag means. It carries no walletId, 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-770

unlockWalletFromKeystore returned on the hasMnemonic check 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 hasMnemonic storage read, the hex status-key derivation, the seedMismatch = false transform, and the early-return verdict — lives in an internal seam (isGenuineWatchOnly; the file's established pattern for native-free unit tests — cf. initializePlatformWalletNativeManager, decodeShieldedCreatePayload), and the call site in unlockWalletFromKeystore is 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_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, 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 same Ok(None)-default shape as the existing 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 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 SqlitePersister alone. FFIPersister keeps the Ok(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 a get_dpns_name_state read callback in the persistence vtable, which is deliberately deferred to the batched vtable/ABI additions rather than shipped piecemeal here; the in-code docs on previous_document_id_for and 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 the Err arm 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 bump max_supported_version and trip the forward-version gate.

6. Zero-price listing guard

set_dpns_name_price never validated price != 0. Consensus would accept the listing and anyone could then take the name for free. It is now rejected 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.

There is no legitimate zero-price caller: a deliberate free handover is transfer_dpns_name, which names the recipient.


Test evidence

Suite Result
:sdk:testDebugUnitTest (Kotlin) 325 passed, 0 failed (16 new; the 3 watch-only tests since rewritten against the call-site seam, count unchanged)
cargo test -p platform-wallet --features shielded 855 passed, 0 failed, 3 ignored (9 new)
cargo test -p platform-wallet-storage 336 passed, 0 failed (3 new, doctests included)
cargo fmt --check (both crates) clean
cargo clippy --all-targets -- -D warnings (both crates) clean

Non-vacuity was verified rather than assumed — a green suite proves nothing if the tests don't gate the fix:

  • Kotlin, items 1-3: reverting them fails the 8 tests asserting their changed behavior; the 5 that pin unchanged behavior (guard narrowness in both directions, label-cache deletion, observed identities staying non-local) keep passing. No pre-existing test broke under the revert, so none of this rests on relaxed expectations.
  • Kotlin, item 4 — corrected: the evidence originally claimed here was circular. The watch-only test drove the extracted helper directly, so reverting the production call site to the pre-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 the isGenuineWatchOnly seam, 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.
  • Rust: short-circuiting the persister branch fails exactly the 4 fallback-dependent tests — including resolve_departed_name_recovers_the_document_id_from_the_persister, which runs the real async fn on a real IdentityWallet and 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_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 widening 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 — and a three-column filter is exactly where a wrong column or param order hides.

Reviewer notes

  • Item 2 has a small behavior change beyond the stated bug: the sweep now also deletes non-owned rows with no documentId, a shape the old Kotlin condition skipped entirely, leaving them to leak after onRemoveDpnsNameState nulls the marketplace columns. This is Swift's exact behavior, so it is parity, but it is worth a look.
  • Item 2 × item 5 interaction: restoring Swift's keep-as-history sweep means the restart-orphaned row (item 5, still unfixed on mobile hosts) is no longer destructively erased on Android — it persists as a "Owned · not listed" card. Parity with what Swift shows today, but newly visible on Android; it goes away when the persistence-vtable batch adds the get_dpns_name_state read callback.
  • No Room schema change, so no migration: the heal is a @Query UPDATE, and IdentityEntity is untouched.

bfoss765 and others added 2 commits August 19, 2026 14:31
…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>
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@bfoss765, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 14 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: edbaa017-ef4d-49b0-916f-64405801ff70

📥 Commits

Reviewing files that changed from the base of the PR and between 0b5fc6f and 0311298.

📒 Files selected for processing (13)
  • packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/LoadIdentityScreen.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/IdentityDao.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/WatchOnlySeedMismatchTest.kt
  • packages/rs-platform-wallet-storage/src/sqlite/persister.rs
  • packages/rs-platform-wallet-storage/src/sqlite/schema/dpns_name_states.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rs
  • packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs
  • packages/rs-platform-wallet/src/changeset/traits.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs
  • packages/rs-platform-wallet/src/wallet/persister.rs

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 added this to the v4.2.0 milestone Aug 19, 2026
@thepastaclaw

thepastaclaw commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Opus deferred (commit 0311298)
Canonical validated blockers: 2

bfoss765 and others added 2 commits August 19, 2026 18:07
…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>
@bfoss765

Copy link
Copy Markdown
Collaborator Author

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 if (!walletStorage.hasMnemonic(walletId)) return false — the actual regression — kept the whole suite green (verified, 325/0). I've made the call-site shape itself the unit under test: isGenuineWatchOnly now performs the entire guard sequence (storage read, status-key derivation, the seedMismatch clear, the verdict) and the call site is pure delegation. Mutation-verified this time in the direction that matters: transplanting the pre-fix shape into the seam fails 2 of the 3 watch-only tests, and the stored-mnemonic test correctly keeps passing. Suite is 325/0 again.

Item 5's description overstated its reach. The get_dpns_name_state override exists only in SqlitePersister. FFIPersister keeps the Ok(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 / iOS SwiftData mirrors the section narrates are NOT fixed: their restart orphan is still live until a read callback lands with the batched vtable/ABI additions (deliberately not smuggled into this PR). One knock-on worth flagging for item 2's review: with the Swift-parity sweep no longer destructively deleting departed rows, the unresolved orphan on Android now shows as a permanent "Owned · not listed" card in the marketplace list — same stale card the Swift host shows, but newly visible on Android. I've corrected the in-code docs and the PR description to state the real scope.

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

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.

Comment on lines +249 to +252
let sql = format!(
"SELECT {ROW_PROJECTION} FROM dpns_name_states \
WHERE wallet_id = ?1 AND identity_id = ?2 AND normalized_label = ?3 LIMIT 1"
);

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.

🔴 Blocking: 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.

Suggested change
let sql = format!(
"SELECT {ROW_PROJECTION} FROM dpns_name_states \
WHERE wallet_id = ?1 AND identity_id = ?2 AND normalized_label = ?3 LIMIT 1"
);
let sql = format!(
"SELECT {ROW_PROJECTION} FROM dpns_name_states \
WHERE wallet_id = ?1 AND identity_id = ?2 AND normalized_label = ?3 \
ORDER BY CASE status WHEN 'owned' THEN 0 ELSE 1 END, \
last_synced_at_ms DESC, document_id DESC \
LIMIT 1"
);

source: ['codex']

Comment on lines +437 to +446
match persister.get_dpns_name_state(identity_id, &normalized_label) {
Ok(row) => row.map(|entry| entry.document_id),
Err(error) => {
tracing::warn!(
identity = %identity_id,
name = label,
"persisted DPNS row lookup failed for a departed name; the host mirror may \
keep a stale row for it until the name is re-acquired: {error}"
);
None

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.

🔴 Blocking: 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.

source: ['codex']

Comment on lines +211 to +216
price: price.map(|p| p as u64),
status: status_from_columns(&status, counterparty)?,
created_at_ms: created_at.map(|v| v as u64),
updated_at_ms: updated_at.map(|v| v as u64),
transferred_at_ms: transferred_at.map(|v| v as u64),
last_synced_at_ms: last_synced as u64,

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: 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.

Suggested change
price: price.map(|p| p as u64),
status: status_from_columns(&status, counterparty)?,
created_at_ms: created_at.map(|v| v as u64),
updated_at_ms: updated_at.map(|v| v as u64),
transferred_at_ms: transferred_at.map(|v| v as u64),
last_synced_at_ms: last_synced as u64,
price: price
.map(|value| {
crate::sqlite::util::safe_cast::i64_to_u64("dpns_name_states.price", value)
})
.transpose()?,
status: status_from_columns(&status, counterparty)?,
created_at_ms: created_at
.map(|value| {
crate::sqlite::util::safe_cast::i64_to_u64(
"dpns_name_states.created_at_ms",
value,
)
})
.transpose()?,
updated_at_ms: updated_at
.map(|value| {
crate::sqlite::util::safe_cast::i64_to_u64(
"dpns_name_states.updated_at_ms",
value,
)
})
.transpose()?,
transferred_at_ms: transferred_at
.map(|value| {
crate::sqlite::util::safe_cast::i64_to_u64(
"dpns_name_states.transferred_at_ms",
value,
)
})
.transpose()?,
last_synced_at_ms: crate::sqlite::util::safe_cast::i64_to_u64(
"dpns_name_states.last_synced_at_ms",
last_synced,
)?,

source: ['codex']

Comment on lines +1168 to +1179
// A `$price` of 0 is not a listing this wallet will act on — see
// `set_dpns_name_price`, which refuses to create one. Rejected
// ahead of the `expected_price` comparison so the caller is told
// the listing itself is not purchasable rather than that the
// price moved. Only `== 0` is affected; every `> 0` listing
// follows the unchanged price-match path below.
if listed_price == 0 {
return Err(PlatformWalletError::InvalidParameter(format!(
"DPNS name {name:?} carries a listed price of 0 credits, which is not a \
valid sale listing and will not be purchased by this 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: 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.

source: ['codex']

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants