Skip to content

storage: fix the nine incorrect queries from the postgres audit - #178

Open
bennyhodl wants to merge 12 commits into
masterfrom
postgres-storage-fixes
Open

storage: fix the nine incorrect queries from the postgres audit#178
bennyhodl wants to merge 12 commits into
masterfrom
postgres-storage-fixes

Conversation

@bennyhodl

Copy link
Copy Markdown
Owner

Summary

Fixes the nine incorrect queries found in the Postgres storage audit (added here as docs/postgres-storage-audit.md, alongside the BDK wallet improvement plan). Each audit finding is its own commit with a regression test.

Changes

  • delete_contract now deletes: it called fetch_one on a DELETE with no RETURNING, so it always returned RowNotFound and rolled back
  • Migration 0004 re-keys block by (wallet_name, height) to match BDK's height→hash model, dedupes reorg leftovers, and drops the anchor_tx → block foreign key (anchors carry their block in the JSONB payload and may reference blocks no longer in the sparse chain; the FK made reorg deletes poison the persist transaction)
  • last_revealed is clamped with GREATEST so a stale write can no longer regress the derivation index (address reuse)
  • Migration 0005 persists the previously dropped changeset data: first_seen, last_evicted (new tx columns), and the keychain indexer spk_cache (new table), following BDK merge rules (first_seen only decreases, last_evicted/last_seen only increase)
  • last_seen is upserted instead of updated, so a value arriving before its tx row exists is no longer silently dropped
  • update_contract is a single atomic INSERT ... ON CONFLICT DO UPDATE instead of a racy SELECT-then-branch, and binds real is_offer_party / fee_rate_per_vb values via a new Contract::get_fee_rate_per_vb() accessor instead of hardcoding them
  • insert_descriptor / insert_network are idempotent (ON CONFLICT DO NOTHING), so re-staging no longer poisons the persist transaction
  • Migration 0006 drops the last_revealed DEFAULT 0, which made fresh wallets skip derivation index 0
  • The wallet address handlers surface persist errors instead of discarding them with let _ =

Testing

  • 8 Postgres storage tests (embedded server), including new regression tests for delete, reorg replacement, monotonic last_revealed, timestamp/spk-cache roundtrip, missing-row last_seen, real upsert metadata, and idempotent re-staging
  • Full ddk lib suite with the postgres feature (46 passed) and ddk-manager lib suite (41 passed); clippy and fmt clean
  • Note for deploys: migration 0004 rewrites the block primary key — on databases with a large block table (staging is ~843k rows) the first boot after upgrade will spend some time in the migration

fetch_one on a DELETE with no RETURNING always returns RowNotFound, so
every call errored and rolled back before deleting anything. Use
execute instead, removing contract_data before contract_metadata so the
foreign key never blocks the delete.
BDK's local chain maps height to hash, and a reorg replaces the hash at
a height. Keying block rows by hash let both hashes accumulate at one
height and the reader picked one nondeterministically, so the wallet
could resurrect an orphaned block. The anchor_tx foreign key also made
reorg deletes roll back the whole persist transaction; anchors carry
their block in the JSONB payload and may reference blocks that left the
sparse chain, so the constraint was wrong. The migration deduplicates
existing rows, preferring the hash an anchor references.
BDK's merge rule for last_revealed keeps the greater index. A plain
UPDATE let a stale changeset write a smaller index, and after a restart
the wallet re-revealed already-used addresses. Clamp with GREATEST.
The tx_graph changeset also carries first_seen and last_evicted
timestamps, and the keychain indexer carries a derived-spk cache. All
three were dropped on persist while the staged changeset was cleared,
so the data was permanently lost; losing last_evicted can resurrect an
RBF-replaced or evicted transaction as unconfirmed after a restart.
Upserts follow the BDK merge rules: first_seen only decreases,
last_evicted only increases.
A changeset can carry last_seen for a txid whose row does not exist
yet; the plain UPDATE affected zero rows and silently dropped the
value. Insert-or-update, clamped with GREATEST since last_seen only
ever increases.
update_contract did a SELECT-then-INSERT/UPDATE, which races under
concurrent updates (both see missing, one dies on the unique
violation), and its insert arm hardcoded is_offer_party = false and
fee_rate_per_vb = 1 - corrupted rows then leaked into
get_contract_offers, which filters on is_offer_party. Replace the
branch with INSERT ... ON CONFLICT DO UPDATE and bind the contract's
real values via a new Contract::get_fee_rate_per_vb accessor.
Both were plain INSERTs, so re-staging either (the wallet re-create
path) hit a unique violation and rolled back the whole persist
transaction. Descriptors and network never change for a wallet, so
DO NOTHING is the correct conflict action and preserves last_revealed.
A fresh keychain row read back last_revealed = 0, which BDK interprets
as index 0 already revealed, so the wallet skipped its first address.
Existing rows at 0 are ambiguous and left untouched.
The address handlers dropped the persist result, so a failed persist
lost the revealed derivation index invisibly and enabled address reuse
after a restart. Return the error to the caller instead.
Consumers had to point sqlx-cli at the migration files deep inside the
crate source to apply or revert the schema. The migrations are already
compiled in via sqlx::migrate!, so expose the Migrator: MIGRATOR.run
applies up and MIGRATOR.undo reverts, against any database, with no
access to the source tree.
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.

1 participant