From 29eabff6727dae39467eb7f0ac71c2d18977ac72 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 14 Jul 2026 13:49:46 -0700 Subject: [PATCH 1/4] wallet/sql: preserve birthday verification state In this commit, we separate the birthday block verification bit from the optional birthday block itself. The legacy address manager keeps the bit when the block is deleted, so the SQL schema needs to retain the same state instead of clearing it as a side effect. Both backends carry the migration in both directions. The down migration clears only the states that the old constraint cannot represent. Extracted-from: PR #1125 (d9bd945bc52a15c49d47bbb80bcd914db97a713b) Extracted-from: PR #1134 (e773de5b082d11d5720b09502b891c184794a7df) Co-authored-by: Mohamed Awnallah --- ...independent_birthday_verification.down.sql | 11 +++++ ...0_independent_birthday_verification.up.sql | 4 ++ ...independent_birthday_verification.down.sql | 45 +++++++++++++++++++ ...0_independent_birthday_verification.up.sql | 38 ++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 wallet/internal/sql/pg/migrations/000010_independent_birthday_verification.down.sql create mode 100644 wallet/internal/sql/pg/migrations/000010_independent_birthday_verification.up.sql create mode 100644 wallet/internal/sql/sqlite/migrations/000010_independent_birthday_verification.down.sql create mode 100644 wallet/internal/sql/sqlite/migrations/000010_independent_birthday_verification.up.sql diff --git a/wallet/internal/sql/pg/migrations/000010_independent_birthday_verification.down.sql b/wallet/internal/sql/pg/migrations/000010_independent_birthday_verification.down.sql new file mode 100644 index 0000000000..ddf11984e2 --- /dev/null +++ b/wallet/internal/sql/pg/migrations/000010_independent_birthday_verification.down.sql @@ -0,0 +1,11 @@ +-- Reintroducing the old constraint requires clearing a retained verification +-- bit when no birthday block is present. +UPDATE wallet_sync_states +SET birthday_block_verified = FALSE +WHERE birthday_block_height IS NULL; + +ALTER TABLE wallet_sync_states +ADD CHECK ( + birthday_block_verified = FALSE + OR birthday_block_height IS NOT NULL +); diff --git a/wallet/internal/sql/pg/migrations/000010_independent_birthday_verification.up.sql b/wallet/internal/sql/pg/migrations/000010_independent_birthday_verification.up.sql new file mode 100644 index 0000000000..f2964f2d46 --- /dev/null +++ b/wallet/internal/sql/pg/migrations/000010_independent_birthday_verification.up.sql @@ -0,0 +1,4 @@ +-- The legacy address manager retains the verification bit when the birthday +-- block is removed, so the fields must remain independent. +ALTER TABLE wallet_sync_states +DROP CONSTRAINT wallet_sync_states_check; diff --git a/wallet/internal/sql/sqlite/migrations/000010_independent_birthday_verification.down.sql b/wallet/internal/sql/sqlite/migrations/000010_independent_birthday_verification.down.sql new file mode 100644 index 0000000000..cdfa17a15e --- /dev/null +++ b/wallet/internal/sql/sqlite/migrations/000010_independent_birthday_verification.down.sql @@ -0,0 +1,45 @@ +-- Reintroducing the old constraint requires clearing a retained verification +-- bit when no birthday block is present. +CREATE TABLE wallet_sync_states_old ( + wallet_id INTEGER PRIMARY KEY, + start_block_height INTEGER NOT NULL, + synced_block_height INTEGER NOT NULL, + birthday_timestamp INTEGER NOT NULL CHECK (birthday_timestamp >= 0), + birthday_block_height INTEGER, + birthday_block_verified BOOLEAN NOT NULL DEFAULT FALSE + CHECK (birthday_block_verified IN (FALSE, TRUE)), + FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, + FOREIGN KEY (start_block_height) REFERENCES blocks (block_height) + ON DELETE RESTRICT, + FOREIGN KEY (synced_block_height) REFERENCES blocks (block_height) + ON DELETE RESTRICT, + FOREIGN KEY (birthday_block_height) REFERENCES blocks (block_height) + ON DELETE RESTRICT, + CHECK ( + birthday_block_verified = FALSE + OR birthday_block_height IS NOT NULL + ) +); + +INSERT INTO wallet_sync_states_old ( + wallet_id, + start_block_height, + synced_block_height, + birthday_timestamp, + birthday_block_height, + birthday_block_verified +) +SELECT + wallet_id, + start_block_height, + synced_block_height, + birthday_timestamp, + birthday_block_height, + CASE + WHEN birthday_block_height IS NULL THEN FALSE + ELSE birthday_block_verified + END +FROM wallet_sync_states; + +DROP TABLE wallet_sync_states; +ALTER TABLE wallet_sync_states_old RENAME TO wallet_sync_states; diff --git a/wallet/internal/sql/sqlite/migrations/000010_independent_birthday_verification.up.sql b/wallet/internal/sql/sqlite/migrations/000010_independent_birthday_verification.up.sql new file mode 100644 index 0000000000..b8d64e06c7 --- /dev/null +++ b/wallet/internal/sql/sqlite/migrations/000010_independent_birthday_verification.up.sql @@ -0,0 +1,38 @@ +-- The legacy address manager retains the verification bit when the birthday +-- block is removed. Rebuild the table without coupling those two fields. +CREATE TABLE wallet_sync_states_new ( + wallet_id INTEGER PRIMARY KEY, + start_block_height INTEGER NOT NULL, + synced_block_height INTEGER NOT NULL, + birthday_timestamp INTEGER NOT NULL CHECK (birthday_timestamp >= 0), + birthday_block_height INTEGER, + birthday_block_verified BOOLEAN NOT NULL DEFAULT FALSE + CHECK (birthday_block_verified IN (FALSE, TRUE)), + FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT, + FOREIGN KEY (start_block_height) REFERENCES blocks (block_height) + ON DELETE RESTRICT, + FOREIGN KEY (synced_block_height) REFERENCES blocks (block_height) + ON DELETE RESTRICT, + FOREIGN KEY (birthday_block_height) REFERENCES blocks (block_height) + ON DELETE RESTRICT +); + +INSERT INTO wallet_sync_states_new ( + wallet_id, + start_block_height, + synced_block_height, + birthday_timestamp, + birthday_block_height, + birthday_block_verified +) +SELECT + wallet_id, + start_block_height, + synced_block_height, + birthday_timestamp, + birthday_block_height, + birthday_block_verified +FROM wallet_sync_states; + +DROP TABLE wallet_sync_states; +ALTER TABLE wallet_sync_states_new RENAME TO wallet_sync_states; From 2e2dd75bf11fda722016f62bd4fc4ae694fe6a36 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 14 Jul 2026 13:50:23 -0700 Subject: [PATCH 2/4] wallet/sql: add manager transaction queries In this commit, we add the narrow SQL query surface needed by the address and transaction manager boundary. The address side can read or replace one block stamp, restore the start block for SetSyncedTo(nil), and update only the wallet sync tip. The transaction side lists mined incidences in rollback order, detaches non-coinbase rows, clears mined spend edges, and finds unmined descendants of a disconnected coinbase. The generated SQLite and PostgreSQL bindings stay in the same commit as their source queries. Extracted-from: PR #1125 (21a8f2fa0f988b48c71e4db4d1e38a74e455c092) Extracted-from: PR #1125 (ad53b24fc165572d44204364be41caf9bed1d1a2) Extracted-from: PR #1125 (70e726259ada8cb7de399f05b5e6dea4a79e4879) Extracted-from: PR #1134 (e773de5b082d11d5720b09502b891c184794a7df) Co-authored-by: Mohamed Awnallah --- wallet/internal/sql/pg/queries/blocks.sql | 7 + .../internal/sql/pg/queries/transactions.sql | 25 ++++ wallet/internal/sql/pg/queries/wallets.sql | 11 ++ wallet/internal/sql/pg/sqlc/blocks.sql.go | 19 +++ wallet/internal/sql/pg/sqlc/db.go | 70 ++++++++++ wallet/internal/sql/pg/sqlc/querier.go | 7 + .../internal/sql/pg/sqlc/transactions.sql.go | 122 ++++++++++++++++++ wallet/internal/sql/pg/sqlc/wallets.sql.go | 33 +++++ wallet/internal/sql/sqlite/queries/blocks.sql | 7 + .../sql/sqlite/queries/transactions.sql | 25 ++++ .../internal/sql/sqlite/queries/wallets.sql | 11 ++ wallet/internal/sql/sqlite/sqlc/blocks.sql.go | 19 +++ wallet/internal/sql/sqlite/sqlc/db.go | 70 ++++++++++ wallet/internal/sql/sqlite/sqlc/querier.go | 7 + .../sql/sqlite/sqlc/transactions.sql.go | 122 ++++++++++++++++++ .../internal/sql/sqlite/sqlc/wallets.sql.go | 33 +++++ 16 files changed, 588 insertions(+) diff --git a/wallet/internal/sql/pg/queries/blocks.sql b/wallet/internal/sql/pg/queries/blocks.sql index 7b06a12905..f8e820caa5 100644 --- a/wallet/internal/sql/pg/queries/blocks.sql +++ b/wallet/internal/sql/pg/queries/blocks.sql @@ -23,6 +23,13 @@ INSERT INTO blocks (block_height, header_hash, block_timestamp) VALUES ($1, $2, $3) ON CONFLICT (block_height) DO NOTHING; +-- name: PutBlock :exec +INSERT INTO blocks (block_height, header_hash, block_timestamp) +VALUES ($1, $2, $3) +ON CONFLICT (block_height) DO UPDATE SET + header_hash = excluded.header_hash, + block_timestamp = excluded.block_timestamp; + -- name: DeleteBlock :exec DELETE FROM blocks WHERE block_height = $1; diff --git a/wallet/internal/sql/pg/queries/transactions.sql b/wallet/internal/sql/pg/queries/transactions.sql index 8bd9aa72a4..a306fcc0ef 100644 --- a/wallet/internal/sql/pg/queries/transactions.sql +++ b/wallet/internal/sql/pg/queries/transactions.sql @@ -20,6 +20,31 @@ SELECT id, wallet_id, tx_hash, raw_tx, received_unix, block_height, FROM transactions WHERE wallet_id = $1 AND tx_hash = $2 AND block_height IS NULL; +-- name: ListMinedTransactionsFromHeight :many +SELECT id, tx_hash, is_coinbase +FROM transactions +WHERE wallet_id = sqlc.arg('wallet_id') + AND block_height >= sqlc.arg('height')::INTEGER +ORDER BY block_height DESC, confirmed_order DESC, id DESC; + +-- name: DetachMinedTransaction :execrows +UPDATE transactions +SET block_height = NULL, confirmed_order = NULL +WHERE wallet_id = $1 AND id = $2 AND block_height IS NOT NULL; + +-- name: DeleteCreditSpendsBySpendingTx :execrows +DELETE FROM credit_spends +WHERE wallet_id = $1 AND spending_tx_id = $2; + +-- name: ListUnminedSpendersByPrevHash :many +SELECT DISTINCT spender.id, spender.tx_hash +FROM transaction_inputs AS input +INNER JOIN transactions AS spender ON spender.id = input.spending_tx_id +WHERE spender.wallet_id = sqlc.arg('wallet_id') + AND spender.block_height IS NULL + AND input.prev_tx_hash = sqlc.arg('prev_tx_hash') +ORDER BY spender.id; + -- name: GetMinedTransactionByIncidence :one SELECT t.id, t.wallet_id, t.tx_hash, t.raw_tx, t.received_unix, t.block_height, t.confirmed_order, t.is_coinbase diff --git a/wallet/internal/sql/pg/queries/wallets.sql b/wallet/internal/sql/pg/queries/wallets.sql index f3a487ef39..8de6144775 100644 --- a/wallet/internal/sql/pg/queries/wallets.sql +++ b/wallet/internal/sql/pg/queries/wallets.sql @@ -73,6 +73,12 @@ LEFT JOIN blocks AS birthday_block ON s.birthday_block_height = birthday_block.block_height WHERE s.wallet_id = $1; +-- name: GetWalletStartBlock :one +SELECT b.block_height, b.header_hash, b.block_timestamp +FROM wallet_sync_states AS s +INNER JOIN blocks AS b ON b.block_height = s.start_block_height +WHERE s.wallet_id = $1; + -- name: UpdateWalletSyncState :execrows UPDATE wallet_sync_states SET @@ -82,3 +88,8 @@ SET birthday_block_height = $4, birthday_block_verified = $5 WHERE wallet_id = $6; + +-- name: SetWalletSyncedTo :execrows +UPDATE wallet_sync_states +SET synced_block_height = $1 +WHERE wallet_id = $2; diff --git a/wallet/internal/sql/pg/sqlc/blocks.sql.go b/wallet/internal/sql/pg/sqlc/blocks.sql.go index 7c3e819490..602583cbf5 100644 --- a/wallet/internal/sql/pg/sqlc/blocks.sql.go +++ b/wallet/internal/sql/pg/sqlc/blocks.sql.go @@ -92,3 +92,22 @@ func (q *Queries) InsertBlock(ctx context.Context, arg InsertBlockParams) error _, err := q.exec(ctx, q.insertBlockStmt, InsertBlock, arg.BlockHeight, arg.HeaderHash, arg.BlockTimestamp) return err } + +const PutBlock = `-- name: PutBlock :exec +INSERT INTO blocks (block_height, header_hash, block_timestamp) +VALUES ($1, $2, $3) +ON CONFLICT (block_height) DO UPDATE SET + header_hash = excluded.header_hash, + block_timestamp = excluded.block_timestamp +` + +type PutBlockParams struct { + BlockHeight int32 + HeaderHash []byte + BlockTimestamp int64 +} + +func (q *Queries) PutBlock(ctx context.Context, arg PutBlockParams) error { + _, err := q.exec(ctx, q.putBlockStmt, PutBlock, arg.BlockHeight, arg.HeaderHash, arg.BlockTimestamp) + return err +} diff --git a/wallet/internal/sql/pg/sqlc/db.go b/wallet/internal/sql/pg/sqlc/db.go index 89fc59c0f1..e7bb52fd53 100644 --- a/wallet/internal/sql/pg/sqlc/db.go +++ b/wallet/internal/sql/pg/sqlc/db.go @@ -45,6 +45,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteCreditSpendStmt, err = db.PrepareContext(ctx, DeleteCreditSpend); err != nil { return nil, fmt.Errorf("error preparing query DeleteCreditSpend: %w", err) } + if q.deleteCreditSpendsBySpendingTxStmt, err = db.PrepareContext(ctx, DeleteCreditSpendsBySpendingTx); err != nil { + return nil, fmt.Errorf("error preparing query DeleteCreditSpendsBySpendingTx: %w", err) + } if q.deleteExpiredOutputLeasesStmt, err = db.PrepareContext(ctx, DeleteExpiredOutputLeases); err != nil { return nil, fmt.Errorf("error preparing query DeleteExpiredOutputLeases: %w", err) } @@ -54,6 +57,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteTransactionByIDStmt, err = db.PrepareContext(ctx, DeleteTransactionByID); err != nil { return nil, fmt.Errorf("error preparing query DeleteTransactionByID: %w", err) } + if q.detachMinedTransactionStmt, err = db.PrepareContext(ctx, DetachMinedTransaction); err != nil { + return nil, fmt.Errorf("error preparing query DetachMinedTransaction: %w", err) + } if q.getAccountStmt, err = db.PrepareContext(ctx, GetAccount); err != nil { return nil, fmt.Errorf("error preparing query GetAccount: %w", err) } @@ -87,6 +93,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getWalletByNameStmt, err = db.PrepareContext(ctx, GetWalletByName); err != nil { return nil, fmt.Errorf("error preparing query GetWalletByName: %w", err) } + if q.getWalletStartBlockStmt, err = db.PrepareContext(ctx, GetWalletStartBlock); err != nil { + return nil, fmt.Errorf("error preparing query GetWalletStartBlock: %w", err) + } if q.getWalletSyncStateStmt, err = db.PrepareContext(ctx, GetWalletSyncState); err != nil { return nil, fmt.Errorf("error preparing query GetWalletSyncState: %w", err) } @@ -117,6 +126,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listMinedTransactionsForwardStmt, err = db.PrepareContext(ctx, ListMinedTransactionsForward); err != nil { return nil, fmt.Errorf("error preparing query ListMinedTransactionsForward: %w", err) } + if q.listMinedTransactionsFromHeightStmt, err = db.PrepareContext(ctx, ListMinedTransactionsFromHeight); err != nil { + return nil, fmt.Errorf("error preparing query ListMinedTransactionsFromHeight: %w", err) + } if q.listMinedTransactionsReverseStmt, err = db.PrepareContext(ctx, ListMinedTransactionsReverse); err != nil { return nil, fmt.Errorf("error preparing query ListMinedTransactionsReverse: %w", err) } @@ -132,6 +144,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listUnminedSpendersStmt, err = db.PrepareContext(ctx, ListUnminedSpenders); err != nil { return nil, fmt.Errorf("error preparing query ListUnminedSpenders: %w", err) } + if q.listUnminedSpendersByPrevHashStmt, err = db.PrepareContext(ctx, ListUnminedSpendersByPrevHash); err != nil { + return nil, fmt.Errorf("error preparing query ListUnminedSpendersByPrevHash: %w", err) + } if q.listUnminedTransactionsStmt, err = db.PrepareContext(ctx, ListUnminedTransactions); err != nil { return nil, fmt.Errorf("error preparing query ListUnminedTransactions: %w", err) } @@ -144,6 +159,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.promoteUnminedTransactionStmt, err = db.PrepareContext(ctx, PromoteUnminedTransaction); err != nil { return nil, fmt.Errorf("error preparing query PromoteUnminedTransaction: %w", err) } + if q.putBlockStmt, err = db.PrepareContext(ctx, PutBlock); err != nil { + return nil, fmt.Errorf("error preparing query PutBlock: %w", err) + } if q.putTransactionLabelStmt, err = db.PrepareContext(ctx, PutTransactionLabel); err != nil { return nil, fmt.Errorf("error preparing query PutTransactionLabel: %w", err) } @@ -159,6 +177,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.setActiveCreditIncidenceStmt, err = db.PrepareContext(ctx, SetActiveCreditIncidence); err != nil { return nil, fmt.Errorf("error preparing query SetActiveCreditIncidence: %w", err) } + if q.setWalletSyncedToStmt, err = db.PrepareContext(ctx, SetWalletSyncedTo); err != nil { + return nil, fmt.Errorf("error preparing query SetWalletSyncedTo: %w", err) + } if q.updateAccountIndexesStmt, err = db.PrepareContext(ctx, UpdateAccountIndexes); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountIndexes: %w", err) } @@ -214,6 +235,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteCreditSpendStmt: %w", cerr) } } + if q.deleteCreditSpendsBySpendingTxStmt != nil { + if cerr := q.deleteCreditSpendsBySpendingTxStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteCreditSpendsBySpendingTxStmt: %w", cerr) + } + } if q.deleteExpiredOutputLeasesStmt != nil { if cerr := q.deleteExpiredOutputLeasesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteExpiredOutputLeasesStmt: %w", cerr) @@ -229,6 +255,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteTransactionByIDStmt: %w", cerr) } } + if q.detachMinedTransactionStmt != nil { + if cerr := q.detachMinedTransactionStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing detachMinedTransactionStmt: %w", cerr) + } + } if q.getAccountStmt != nil { if cerr := q.getAccountStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAccountStmt: %w", cerr) @@ -284,6 +315,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getWalletByNameStmt: %w", cerr) } } + if q.getWalletStartBlockStmt != nil { + if cerr := q.getWalletStartBlockStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getWalletStartBlockStmt: %w", cerr) + } + } if q.getWalletSyncStateStmt != nil { if cerr := q.getWalletSyncStateStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getWalletSyncStateStmt: %w", cerr) @@ -334,6 +370,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listMinedTransactionsForwardStmt: %w", cerr) } } + if q.listMinedTransactionsFromHeightStmt != nil { + if cerr := q.listMinedTransactionsFromHeightStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listMinedTransactionsFromHeightStmt: %w", cerr) + } + } if q.listMinedTransactionsReverseStmt != nil { if cerr := q.listMinedTransactionsReverseStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listMinedTransactionsReverseStmt: %w", cerr) @@ -359,6 +400,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listUnminedSpendersStmt: %w", cerr) } } + if q.listUnminedSpendersByPrevHashStmt != nil { + if cerr := q.listUnminedSpendersByPrevHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listUnminedSpendersByPrevHashStmt: %w", cerr) + } + } if q.listUnminedTransactionsStmt != nil { if cerr := q.listUnminedTransactionsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listUnminedTransactionsStmt: %w", cerr) @@ -379,6 +425,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing promoteUnminedTransactionStmt: %w", cerr) } } + if q.putBlockStmt != nil { + if cerr := q.putBlockStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing putBlockStmt: %w", cerr) + } + } if q.putTransactionLabelStmt != nil { if cerr := q.putTransactionLabelStmt.Close(); cerr != nil { err = fmt.Errorf("error closing putTransactionLabelStmt: %w", cerr) @@ -404,6 +455,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing setActiveCreditIncidenceStmt: %w", cerr) } } + if q.setWalletSyncedToStmt != nil { + if cerr := q.setWalletSyncedToStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing setWalletSyncedToStmt: %w", cerr) + } + } if q.updateAccountIndexesStmt != nil { if cerr := q.updateAccountIndexesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateAccountIndexesStmt: %w", cerr) @@ -475,9 +531,11 @@ type Queries struct { createWalletStmt *sql.Stmt deleteBlockStmt *sql.Stmt deleteCreditSpendStmt *sql.Stmt + deleteCreditSpendsBySpendingTxStmt *sql.Stmt deleteExpiredOutputLeasesStmt *sql.Stmt deleteOutputLeaseStmt *sql.Stmt deleteTransactionByIDStmt *sql.Stmt + detachMinedTransactionStmt *sql.Stmt getAccountStmt *sql.Stmt getAddressStmt *sql.Stmt getBlockByHeightStmt *sql.Stmt @@ -489,6 +547,7 @@ type Queries struct { getTransactionLabelStmt *sql.Stmt getUnminedTransactionByHashStmt *sql.Stmt getWalletByNameStmt *sql.Stmt + getWalletStartBlockStmt *sql.Stmt getWalletSyncStateStmt *sql.Stmt insertBlockStmt *sql.Stmt insertCreditStmt *sql.Stmt @@ -499,20 +558,24 @@ type Queries struct { listActiveOutputLeasesStmt *sql.Stmt listAddressTypesStmt *sql.Stmt listMinedTransactionsForwardStmt *sql.Stmt + listMinedTransactionsFromHeightStmt *sql.Stmt listMinedTransactionsReverseStmt *sql.Stmt listOutputsToWatchStmt *sql.Stmt listTransactionCreditsStmt *sql.Stmt listTransactionIncidencesByHashStmt *sql.Stmt listUnminedSpendersStmt *sql.Stmt + listUnminedSpendersByPrevHashStmt *sql.Stmt listUnminedTransactionsStmt *sql.Stmt listUnspentCreditsStmt *sql.Stmt markAddressUsedStmt *sql.Stmt promoteUnminedTransactionStmt *sql.Stmt + putBlockStmt *sql.Stmt putTransactionLabelStmt *sql.Stmt putWalletSyncStateStmt *sql.Stmt recordCreditSpendStmt *sql.Stmt renameAccountStmt *sql.Stmt setActiveCreditIncidenceStmt *sql.Stmt + setWalletSyncedToStmt *sql.Stmt updateAccountIndexesStmt *sql.Stmt updateKeyScopeKeysStmt *sql.Stmt updateLastAccountNumberStmt *sql.Stmt @@ -531,9 +594,11 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { createWalletStmt: q.createWalletStmt, deleteBlockStmt: q.deleteBlockStmt, deleteCreditSpendStmt: q.deleteCreditSpendStmt, + deleteCreditSpendsBySpendingTxStmt: q.deleteCreditSpendsBySpendingTxStmt, deleteExpiredOutputLeasesStmt: q.deleteExpiredOutputLeasesStmt, deleteOutputLeaseStmt: q.deleteOutputLeaseStmt, deleteTransactionByIDStmt: q.deleteTransactionByIDStmt, + detachMinedTransactionStmt: q.detachMinedTransactionStmt, getAccountStmt: q.getAccountStmt, getAddressStmt: q.getAddressStmt, getBlockByHeightStmt: q.getBlockByHeightStmt, @@ -545,6 +610,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getTransactionLabelStmt: q.getTransactionLabelStmt, getUnminedTransactionByHashStmt: q.getUnminedTransactionByHashStmt, getWalletByNameStmt: q.getWalletByNameStmt, + getWalletStartBlockStmt: q.getWalletStartBlockStmt, getWalletSyncStateStmt: q.getWalletSyncStateStmt, insertBlockStmt: q.insertBlockStmt, insertCreditStmt: q.insertCreditStmt, @@ -555,20 +621,24 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listActiveOutputLeasesStmt: q.listActiveOutputLeasesStmt, listAddressTypesStmt: q.listAddressTypesStmt, listMinedTransactionsForwardStmt: q.listMinedTransactionsForwardStmt, + listMinedTransactionsFromHeightStmt: q.listMinedTransactionsFromHeightStmt, listMinedTransactionsReverseStmt: q.listMinedTransactionsReverseStmt, listOutputsToWatchStmt: q.listOutputsToWatchStmt, listTransactionCreditsStmt: q.listTransactionCreditsStmt, listTransactionIncidencesByHashStmt: q.listTransactionIncidencesByHashStmt, listUnminedSpendersStmt: q.listUnminedSpendersStmt, + listUnminedSpendersByPrevHashStmt: q.listUnminedSpendersByPrevHashStmt, listUnminedTransactionsStmt: q.listUnminedTransactionsStmt, listUnspentCreditsStmt: q.listUnspentCreditsStmt, markAddressUsedStmt: q.markAddressUsedStmt, promoteUnminedTransactionStmt: q.promoteUnminedTransactionStmt, + putBlockStmt: q.putBlockStmt, putTransactionLabelStmt: q.putTransactionLabelStmt, putWalletSyncStateStmt: q.putWalletSyncStateStmt, recordCreditSpendStmt: q.recordCreditSpendStmt, renameAccountStmt: q.renameAccountStmt, setActiveCreditIncidenceStmt: q.setActiveCreditIncidenceStmt, + setWalletSyncedToStmt: q.setWalletSyncedToStmt, updateAccountIndexesStmt: q.updateAccountIndexesStmt, updateKeyScopeKeysStmt: q.updateKeyScopeKeysStmt, updateLastAccountNumberStmt: q.updateLastAccountNumberStmt, diff --git a/wallet/internal/sql/pg/sqlc/querier.go b/wallet/internal/sql/pg/sqlc/querier.go index f8ea7e9482..f7e49878cc 100644 --- a/wallet/internal/sql/pg/sqlc/querier.go +++ b/wallet/internal/sql/pg/sqlc/querier.go @@ -16,9 +16,11 @@ type Querier interface { CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int32) error DeleteCreditSpend(ctx context.Context, arg DeleteCreditSpendParams) (int64, error) + DeleteCreditSpendsBySpendingTx(ctx context.Context, arg DeleteCreditSpendsBySpendingTxParams) (int64, error) DeleteExpiredOutputLeases(ctx context.Context, arg DeleteExpiredOutputLeasesParams) (int64, error) DeleteOutputLease(ctx context.Context, arg DeleteOutputLeaseParams) (int64, error) DeleteTransactionByID(ctx context.Context, arg DeleteTransactionByIDParams) (int64, error) + DetachMinedTransaction(ctx context.Context, arg DetachMinedTransactionParams) (int64, error) GetAccount(ctx context.Context, arg GetAccountParams) (Account, error) GetAddress(ctx context.Context, arg GetAddressParams) (Address, error) GetBlockByHeight(ctx context.Context, blockHeight int32) (Block, error) @@ -30,6 +32,7 @@ type Querier interface { GetTransactionLabel(ctx context.Context, arg GetTransactionLabelParams) ([]byte, error) GetUnminedTransactionByHash(ctx context.Context, arg GetUnminedTransactionByHashParams) (Transaction, error) GetWalletByName(ctx context.Context, walletName string) (Wallet, error) + GetWalletStartBlock(ctx context.Context, walletID int64) (Block, error) GetWalletSyncState(ctx context.Context, walletID int64) (GetWalletSyncStateRow, error) InsertBlock(ctx context.Context, arg InsertBlockParams) error InsertCredit(ctx context.Context, arg InsertCreditParams) (int64, error) @@ -40,20 +43,24 @@ type Querier interface { ListActiveOutputLeases(ctx context.Context, arg ListActiveOutputLeasesParams) ([]UtxoLease, error) ListAddressTypes(ctx context.Context) ([]AddressType, error) ListMinedTransactionsForward(ctx context.Context, arg ListMinedTransactionsForwardParams) ([]ListMinedTransactionsForwardRow, error) + ListMinedTransactionsFromHeight(ctx context.Context, arg ListMinedTransactionsFromHeightParams) ([]ListMinedTransactionsFromHeightRow, error) ListMinedTransactionsReverse(ctx context.Context, arg ListMinedTransactionsReverseParams) ([]ListMinedTransactionsReverseRow, error) ListOutputsToWatch(ctx context.Context, walletID int64) ([]ListOutputsToWatchRow, error) ListTransactionCredits(ctx context.Context, arg ListTransactionCreditsParams) ([]ListTransactionCreditsRow, error) ListTransactionIncidencesByHash(ctx context.Context, arg ListTransactionIncidencesByHashParams) ([]Transaction, error) ListUnminedSpenders(ctx context.Context, arg ListUnminedSpendersParams) ([]ListUnminedSpendersRow, error) + ListUnminedSpendersByPrevHash(ctx context.Context, arg ListUnminedSpendersByPrevHashParams) ([]ListUnminedSpendersByPrevHashRow, error) ListUnminedTransactions(ctx context.Context, walletID int64) ([]Transaction, error) ListUnspentCredits(ctx context.Context, arg ListUnspentCreditsParams) ([]ListUnspentCreditsRow, error) MarkAddressUsed(ctx context.Context, arg MarkAddressUsedParams) (int64, error) PromoteUnminedTransaction(ctx context.Context, arg PromoteUnminedTransactionParams) (int64, error) + PutBlock(ctx context.Context, arg PutBlockParams) error PutTransactionLabel(ctx context.Context, arg PutTransactionLabelParams) error PutWalletSyncState(ctx context.Context, arg PutWalletSyncStateParams) error RecordCreditSpend(ctx context.Context, arg RecordCreditSpendParams) (int64, error) RenameAccount(ctx context.Context, arg RenameAccountParams) (int64, error) SetActiveCreditIncidence(ctx context.Context, arg SetActiveCreditIncidenceParams) error + SetWalletSyncedTo(ctx context.Context, arg SetWalletSyncedToParams) (int64, error) UpdateAccountIndexes(ctx context.Context, arg UpdateAccountIndexesParams) (int64, error) UpdateKeyScopeKeys(ctx context.Context, arg UpdateKeyScopeKeysParams) (int64, error) UpdateLastAccountNumber(ctx context.Context, arg UpdateLastAccountNumberParams) (int64, error) diff --git a/wallet/internal/sql/pg/sqlc/transactions.sql.go b/wallet/internal/sql/pg/sqlc/transactions.sql.go index d5e0726cae..fc876be786 100644 --- a/wallet/internal/sql/pg/sqlc/transactions.sql.go +++ b/wallet/internal/sql/pg/sqlc/transactions.sql.go @@ -10,6 +10,24 @@ import ( "database/sql" ) +const DeleteCreditSpendsBySpendingTx = `-- name: DeleteCreditSpendsBySpendingTx :execrows +DELETE FROM credit_spends +WHERE wallet_id = $1 AND spending_tx_id = $2 +` + +type DeleteCreditSpendsBySpendingTxParams struct { + WalletID int64 + SpendingTxID int64 +} + +func (q *Queries) DeleteCreditSpendsBySpendingTx(ctx context.Context, arg DeleteCreditSpendsBySpendingTxParams) (int64, error) { + result, err := q.exec(ctx, q.deleteCreditSpendsBySpendingTxStmt, DeleteCreditSpendsBySpendingTx, arg.WalletID, arg.SpendingTxID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const DeleteTransactionByID = `-- name: DeleteTransactionByID :execrows DELETE FROM transactions WHERE wallet_id = $1 AND id = $2 ` @@ -27,6 +45,25 @@ func (q *Queries) DeleteTransactionByID(ctx context.Context, arg DeleteTransacti return result.RowsAffected() } +const DetachMinedTransaction = `-- name: DetachMinedTransaction :execrows +UPDATE transactions +SET block_height = NULL, confirmed_order = NULL +WHERE wallet_id = $1 AND id = $2 AND block_height IS NOT NULL +` + +type DetachMinedTransactionParams struct { + WalletID int64 + ID int64 +} + +func (q *Queries) DetachMinedTransaction(ctx context.Context, arg DetachMinedTransactionParams) (int64, error) { + result, err := q.exec(ctx, q.detachMinedTransactionStmt, DetachMinedTransaction, arg.WalletID, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const GetMinedTransactionByIncidence = `-- name: GetMinedTransactionByIncidence :one SELECT t.id, t.wallet_id, t.tx_hash, t.raw_tx, t.received_unix, t.block_height, t.confirmed_order, t.is_coinbase @@ -235,6 +272,48 @@ func (q *Queries) ListMinedTransactionsForward(ctx context.Context, arg ListMine return items, nil } +const ListMinedTransactionsFromHeight = `-- name: ListMinedTransactionsFromHeight :many +SELECT id, tx_hash, is_coinbase +FROM transactions +WHERE wallet_id = $1 + AND block_height >= $2::INTEGER +ORDER BY block_height DESC, confirmed_order DESC, id DESC +` + +type ListMinedTransactionsFromHeightParams struct { + WalletID int64 + Height int32 +} + +type ListMinedTransactionsFromHeightRow struct { + ID int64 + TxHash []byte + IsCoinbase bool +} + +func (q *Queries) ListMinedTransactionsFromHeight(ctx context.Context, arg ListMinedTransactionsFromHeightParams) ([]ListMinedTransactionsFromHeightRow, error) { + rows, err := q.query(ctx, q.listMinedTransactionsFromHeightStmt, ListMinedTransactionsFromHeight, arg.WalletID, arg.Height) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListMinedTransactionsFromHeightRow + for rows.Next() { + var i ListMinedTransactionsFromHeightRow + if err := rows.Scan(&i.ID, &i.TxHash, &i.IsCoinbase); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ListMinedTransactionsReverse = `-- name: ListMinedTransactionsReverse :many SELECT t.id, t.wallet_id, t.tx_hash, t.raw_tx, t.received_unix, t.block_height, t.confirmed_order, t.is_coinbase, @@ -391,6 +470,49 @@ func (q *Queries) ListUnminedSpenders(ctx context.Context, arg ListUnminedSpende return items, nil } +const ListUnminedSpendersByPrevHash = `-- name: ListUnminedSpendersByPrevHash :many +SELECT DISTINCT spender.id, spender.tx_hash +FROM transaction_inputs AS input +INNER JOIN transactions AS spender ON spender.id = input.spending_tx_id +WHERE spender.wallet_id = $1 + AND spender.block_height IS NULL + AND input.prev_tx_hash = $2 +ORDER BY spender.id +` + +type ListUnminedSpendersByPrevHashParams struct { + WalletID int64 + PrevTxHash []byte +} + +type ListUnminedSpendersByPrevHashRow struct { + ID int64 + TxHash []byte +} + +func (q *Queries) ListUnminedSpendersByPrevHash(ctx context.Context, arg ListUnminedSpendersByPrevHashParams) ([]ListUnminedSpendersByPrevHashRow, error) { + rows, err := q.query(ctx, q.listUnminedSpendersByPrevHashStmt, ListUnminedSpendersByPrevHash, arg.WalletID, arg.PrevTxHash) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUnminedSpendersByPrevHashRow + for rows.Next() { + var i ListUnminedSpendersByPrevHashRow + if err := rows.Scan(&i.ID, &i.TxHash); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ListUnminedTransactions = `-- name: ListUnminedTransactions :many SELECT id, wallet_id, tx_hash, raw_tx, received_unix, block_height, confirmed_order, is_coinbase diff --git a/wallet/internal/sql/pg/sqlc/wallets.sql.go b/wallet/internal/sql/pg/sqlc/wallets.sql.go index d7cef6f3a2..a0c8695898 100644 --- a/wallet/internal/sql/pg/sqlc/wallets.sql.go +++ b/wallet/internal/sql/pg/sqlc/wallets.sql.go @@ -98,6 +98,20 @@ func (q *Queries) GetWalletByName(ctx context.Context, walletName string) (Walle return i, err } +const GetWalletStartBlock = `-- name: GetWalletStartBlock :one +SELECT b.block_height, b.header_hash, b.block_timestamp +FROM wallet_sync_states AS s +INNER JOIN blocks AS b ON b.block_height = s.start_block_height +WHERE s.wallet_id = $1 +` + +func (q *Queries) GetWalletStartBlock(ctx context.Context, walletID int64) (Block, error) { + row := q.queryRow(ctx, q.getWalletStartBlockStmt, GetWalletStartBlock, walletID) + var i Block + err := row.Scan(&i.BlockHeight, &i.HeaderHash, &i.BlockTimestamp) + return i, err +} + const GetWalletSyncState = `-- name: GetWalletSyncState :one SELECT s.wallet_id, @@ -180,6 +194,25 @@ func (q *Queries) PutWalletSyncState(ctx context.Context, arg PutWalletSyncState return err } +const SetWalletSyncedTo = `-- name: SetWalletSyncedTo :execrows +UPDATE wallet_sync_states +SET synced_block_height = $1 +WHERE wallet_id = $2 +` + +type SetWalletSyncedToParams struct { + SyncedBlockHeight int32 + WalletID int64 +} + +func (q *Queries) SetWalletSyncedTo(ctx context.Context, arg SetWalletSyncedToParams) (int64, error) { + result, err := q.exec(ctx, q.setWalletSyncedToStmt, SetWalletSyncedTo, arg.SyncedBlockHeight, arg.WalletID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const UpdateWalletEncryption = `-- name: UpdateWalletEncryption :execrows UPDATE wallets SET diff --git a/wallet/internal/sql/sqlite/queries/blocks.sql b/wallet/internal/sql/sqlite/queries/blocks.sql index 29fe7828c2..70568609c6 100644 --- a/wallet/internal/sql/sqlite/queries/blocks.sql +++ b/wallet/internal/sql/sqlite/queries/blocks.sql @@ -21,6 +21,13 @@ ORDER BY block_height; INSERT OR IGNORE INTO blocks (block_height, header_hash, block_timestamp) VALUES (?, ?, ?); +-- name: PutBlock :exec +INSERT INTO blocks (block_height, header_hash, block_timestamp) +VALUES (?, ?, ?) +ON CONFLICT (block_height) DO UPDATE SET + header_hash = excluded.header_hash, + block_timestamp = excluded.block_timestamp; + -- name: DeleteBlock :exec DELETE FROM blocks WHERE block_height = ?; diff --git a/wallet/internal/sql/sqlite/queries/transactions.sql b/wallet/internal/sql/sqlite/queries/transactions.sql index 4c9f0a688f..58dbc6f50a 100644 --- a/wallet/internal/sql/sqlite/queries/transactions.sql +++ b/wallet/internal/sql/sqlite/queries/transactions.sql @@ -22,6 +22,31 @@ SELECT id, wallet_id, tx_hash, raw_tx, received_unix, block_height, FROM transactions WHERE wallet_id = ? AND tx_hash = ? AND block_height IS NULL; +-- name: ListMinedTransactionsFromHeight :many +SELECT id, tx_hash, is_coinbase +FROM transactions +WHERE wallet_id = sqlc.arg('wallet_id') + AND block_height >= cast(sqlc.arg('height') AS INTEGER) +ORDER BY block_height DESC, confirmed_order DESC, id DESC; + +-- name: DetachMinedTransaction :execrows +UPDATE transactions +SET block_height = NULL, confirmed_order = NULL +WHERE wallet_id = ? AND id = ? AND block_height IS NOT NULL; + +-- name: DeleteCreditSpendsBySpendingTx :execrows +DELETE FROM credit_spends +WHERE wallet_id = ? AND spending_tx_id = ?; + +-- name: ListUnminedSpendersByPrevHash :many +SELECT DISTINCT spender.id, spender.tx_hash +FROM transaction_inputs AS input +INNER JOIN transactions AS spender ON spender.id = input.spending_tx_id +WHERE spender.wallet_id = sqlc.arg('wallet_id') + AND spender.block_height IS NULL + AND input.prev_tx_hash = sqlc.arg('prev_tx_hash') +ORDER BY spender.id; + -- name: GetMinedTransactionByIncidence :one SELECT t.id, t.wallet_id, t.tx_hash, t.raw_tx, t.received_unix, t.block_height, t.confirmed_order, t.is_coinbase diff --git a/wallet/internal/sql/sqlite/queries/wallets.sql b/wallet/internal/sql/sqlite/queries/wallets.sql index bffc499171..8de0c59a64 100644 --- a/wallet/internal/sql/sqlite/queries/wallets.sql +++ b/wallet/internal/sql/sqlite/queries/wallets.sql @@ -73,6 +73,12 @@ LEFT JOIN blocks AS birthday_block ON s.birthday_block_height = birthday_block.block_height WHERE s.wallet_id = ?; +-- name: GetWalletStartBlock :one +SELECT b.block_height, b.header_hash, b.block_timestamp +FROM wallet_sync_states AS s +INNER JOIN blocks AS b ON b.block_height = s.start_block_height +WHERE s.wallet_id = ?; + -- name: UpdateWalletSyncState :execrows UPDATE wallet_sync_states SET @@ -82,3 +88,8 @@ SET birthday_block_height = ?, birthday_block_verified = ? WHERE wallet_id = ?; + +-- name: SetWalletSyncedTo :execrows +UPDATE wallet_sync_states +SET synced_block_height = ? +WHERE wallet_id = ?; diff --git a/wallet/internal/sql/sqlite/sqlc/blocks.sql.go b/wallet/internal/sql/sqlite/sqlc/blocks.sql.go index e5b8eeaa41..b19c0d70f9 100644 --- a/wallet/internal/sql/sqlite/sqlc/blocks.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/blocks.sql.go @@ -90,3 +90,22 @@ func (q *Queries) InsertBlock(ctx context.Context, arg InsertBlockParams) error _, err := q.exec(ctx, q.insertBlockStmt, InsertBlock, arg.BlockHeight, arg.HeaderHash, arg.BlockTimestamp) return err } + +const PutBlock = `-- name: PutBlock :exec +INSERT INTO blocks (block_height, header_hash, block_timestamp) +VALUES (?, ?, ?) +ON CONFLICT (block_height) DO UPDATE SET + header_hash = excluded.header_hash, + block_timestamp = excluded.block_timestamp +` + +type PutBlockParams struct { + BlockHeight int64 + HeaderHash []byte + BlockTimestamp int64 +} + +func (q *Queries) PutBlock(ctx context.Context, arg PutBlockParams) error { + _, err := q.exec(ctx, q.putBlockStmt, PutBlock, arg.BlockHeight, arg.HeaderHash, arg.BlockTimestamp) + return err +} diff --git a/wallet/internal/sql/sqlite/sqlc/db.go b/wallet/internal/sql/sqlite/sqlc/db.go index 89fc59c0f1..e7bb52fd53 100644 --- a/wallet/internal/sql/sqlite/sqlc/db.go +++ b/wallet/internal/sql/sqlite/sqlc/db.go @@ -45,6 +45,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteCreditSpendStmt, err = db.PrepareContext(ctx, DeleteCreditSpend); err != nil { return nil, fmt.Errorf("error preparing query DeleteCreditSpend: %w", err) } + if q.deleteCreditSpendsBySpendingTxStmt, err = db.PrepareContext(ctx, DeleteCreditSpendsBySpendingTx); err != nil { + return nil, fmt.Errorf("error preparing query DeleteCreditSpendsBySpendingTx: %w", err) + } if q.deleteExpiredOutputLeasesStmt, err = db.PrepareContext(ctx, DeleteExpiredOutputLeases); err != nil { return nil, fmt.Errorf("error preparing query DeleteExpiredOutputLeases: %w", err) } @@ -54,6 +57,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteTransactionByIDStmt, err = db.PrepareContext(ctx, DeleteTransactionByID); err != nil { return nil, fmt.Errorf("error preparing query DeleteTransactionByID: %w", err) } + if q.detachMinedTransactionStmt, err = db.PrepareContext(ctx, DetachMinedTransaction); err != nil { + return nil, fmt.Errorf("error preparing query DetachMinedTransaction: %w", err) + } if q.getAccountStmt, err = db.PrepareContext(ctx, GetAccount); err != nil { return nil, fmt.Errorf("error preparing query GetAccount: %w", err) } @@ -87,6 +93,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getWalletByNameStmt, err = db.PrepareContext(ctx, GetWalletByName); err != nil { return nil, fmt.Errorf("error preparing query GetWalletByName: %w", err) } + if q.getWalletStartBlockStmt, err = db.PrepareContext(ctx, GetWalletStartBlock); err != nil { + return nil, fmt.Errorf("error preparing query GetWalletStartBlock: %w", err) + } if q.getWalletSyncStateStmt, err = db.PrepareContext(ctx, GetWalletSyncState); err != nil { return nil, fmt.Errorf("error preparing query GetWalletSyncState: %w", err) } @@ -117,6 +126,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listMinedTransactionsForwardStmt, err = db.PrepareContext(ctx, ListMinedTransactionsForward); err != nil { return nil, fmt.Errorf("error preparing query ListMinedTransactionsForward: %w", err) } + if q.listMinedTransactionsFromHeightStmt, err = db.PrepareContext(ctx, ListMinedTransactionsFromHeight); err != nil { + return nil, fmt.Errorf("error preparing query ListMinedTransactionsFromHeight: %w", err) + } if q.listMinedTransactionsReverseStmt, err = db.PrepareContext(ctx, ListMinedTransactionsReverse); err != nil { return nil, fmt.Errorf("error preparing query ListMinedTransactionsReverse: %w", err) } @@ -132,6 +144,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listUnminedSpendersStmt, err = db.PrepareContext(ctx, ListUnminedSpenders); err != nil { return nil, fmt.Errorf("error preparing query ListUnminedSpenders: %w", err) } + if q.listUnminedSpendersByPrevHashStmt, err = db.PrepareContext(ctx, ListUnminedSpendersByPrevHash); err != nil { + return nil, fmt.Errorf("error preparing query ListUnminedSpendersByPrevHash: %w", err) + } if q.listUnminedTransactionsStmt, err = db.PrepareContext(ctx, ListUnminedTransactions); err != nil { return nil, fmt.Errorf("error preparing query ListUnminedTransactions: %w", err) } @@ -144,6 +159,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.promoteUnminedTransactionStmt, err = db.PrepareContext(ctx, PromoteUnminedTransaction); err != nil { return nil, fmt.Errorf("error preparing query PromoteUnminedTransaction: %w", err) } + if q.putBlockStmt, err = db.PrepareContext(ctx, PutBlock); err != nil { + return nil, fmt.Errorf("error preparing query PutBlock: %w", err) + } if q.putTransactionLabelStmt, err = db.PrepareContext(ctx, PutTransactionLabel); err != nil { return nil, fmt.Errorf("error preparing query PutTransactionLabel: %w", err) } @@ -159,6 +177,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.setActiveCreditIncidenceStmt, err = db.PrepareContext(ctx, SetActiveCreditIncidence); err != nil { return nil, fmt.Errorf("error preparing query SetActiveCreditIncidence: %w", err) } + if q.setWalletSyncedToStmt, err = db.PrepareContext(ctx, SetWalletSyncedTo); err != nil { + return nil, fmt.Errorf("error preparing query SetWalletSyncedTo: %w", err) + } if q.updateAccountIndexesStmt, err = db.PrepareContext(ctx, UpdateAccountIndexes); err != nil { return nil, fmt.Errorf("error preparing query UpdateAccountIndexes: %w", err) } @@ -214,6 +235,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteCreditSpendStmt: %w", cerr) } } + if q.deleteCreditSpendsBySpendingTxStmt != nil { + if cerr := q.deleteCreditSpendsBySpendingTxStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteCreditSpendsBySpendingTxStmt: %w", cerr) + } + } if q.deleteExpiredOutputLeasesStmt != nil { if cerr := q.deleteExpiredOutputLeasesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteExpiredOutputLeasesStmt: %w", cerr) @@ -229,6 +255,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteTransactionByIDStmt: %w", cerr) } } + if q.detachMinedTransactionStmt != nil { + if cerr := q.detachMinedTransactionStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing detachMinedTransactionStmt: %w", cerr) + } + } if q.getAccountStmt != nil { if cerr := q.getAccountStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getAccountStmt: %w", cerr) @@ -284,6 +315,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getWalletByNameStmt: %w", cerr) } } + if q.getWalletStartBlockStmt != nil { + if cerr := q.getWalletStartBlockStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getWalletStartBlockStmt: %w", cerr) + } + } if q.getWalletSyncStateStmt != nil { if cerr := q.getWalletSyncStateStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getWalletSyncStateStmt: %w", cerr) @@ -334,6 +370,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listMinedTransactionsForwardStmt: %w", cerr) } } + if q.listMinedTransactionsFromHeightStmt != nil { + if cerr := q.listMinedTransactionsFromHeightStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listMinedTransactionsFromHeightStmt: %w", cerr) + } + } if q.listMinedTransactionsReverseStmt != nil { if cerr := q.listMinedTransactionsReverseStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listMinedTransactionsReverseStmt: %w", cerr) @@ -359,6 +400,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listUnminedSpendersStmt: %w", cerr) } } + if q.listUnminedSpendersByPrevHashStmt != nil { + if cerr := q.listUnminedSpendersByPrevHashStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listUnminedSpendersByPrevHashStmt: %w", cerr) + } + } if q.listUnminedTransactionsStmt != nil { if cerr := q.listUnminedTransactionsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listUnminedTransactionsStmt: %w", cerr) @@ -379,6 +425,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing promoteUnminedTransactionStmt: %w", cerr) } } + if q.putBlockStmt != nil { + if cerr := q.putBlockStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing putBlockStmt: %w", cerr) + } + } if q.putTransactionLabelStmt != nil { if cerr := q.putTransactionLabelStmt.Close(); cerr != nil { err = fmt.Errorf("error closing putTransactionLabelStmt: %w", cerr) @@ -404,6 +455,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing setActiveCreditIncidenceStmt: %w", cerr) } } + if q.setWalletSyncedToStmt != nil { + if cerr := q.setWalletSyncedToStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing setWalletSyncedToStmt: %w", cerr) + } + } if q.updateAccountIndexesStmt != nil { if cerr := q.updateAccountIndexesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateAccountIndexesStmt: %w", cerr) @@ -475,9 +531,11 @@ type Queries struct { createWalletStmt *sql.Stmt deleteBlockStmt *sql.Stmt deleteCreditSpendStmt *sql.Stmt + deleteCreditSpendsBySpendingTxStmt *sql.Stmt deleteExpiredOutputLeasesStmt *sql.Stmt deleteOutputLeaseStmt *sql.Stmt deleteTransactionByIDStmt *sql.Stmt + detachMinedTransactionStmt *sql.Stmt getAccountStmt *sql.Stmt getAddressStmt *sql.Stmt getBlockByHeightStmt *sql.Stmt @@ -489,6 +547,7 @@ type Queries struct { getTransactionLabelStmt *sql.Stmt getUnminedTransactionByHashStmt *sql.Stmt getWalletByNameStmt *sql.Stmt + getWalletStartBlockStmt *sql.Stmt getWalletSyncStateStmt *sql.Stmt insertBlockStmt *sql.Stmt insertCreditStmt *sql.Stmt @@ -499,20 +558,24 @@ type Queries struct { listActiveOutputLeasesStmt *sql.Stmt listAddressTypesStmt *sql.Stmt listMinedTransactionsForwardStmt *sql.Stmt + listMinedTransactionsFromHeightStmt *sql.Stmt listMinedTransactionsReverseStmt *sql.Stmt listOutputsToWatchStmt *sql.Stmt listTransactionCreditsStmt *sql.Stmt listTransactionIncidencesByHashStmt *sql.Stmt listUnminedSpendersStmt *sql.Stmt + listUnminedSpendersByPrevHashStmt *sql.Stmt listUnminedTransactionsStmt *sql.Stmt listUnspentCreditsStmt *sql.Stmt markAddressUsedStmt *sql.Stmt promoteUnminedTransactionStmt *sql.Stmt + putBlockStmt *sql.Stmt putTransactionLabelStmt *sql.Stmt putWalletSyncStateStmt *sql.Stmt recordCreditSpendStmt *sql.Stmt renameAccountStmt *sql.Stmt setActiveCreditIncidenceStmt *sql.Stmt + setWalletSyncedToStmt *sql.Stmt updateAccountIndexesStmt *sql.Stmt updateKeyScopeKeysStmt *sql.Stmt updateLastAccountNumberStmt *sql.Stmt @@ -531,9 +594,11 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { createWalletStmt: q.createWalletStmt, deleteBlockStmt: q.deleteBlockStmt, deleteCreditSpendStmt: q.deleteCreditSpendStmt, + deleteCreditSpendsBySpendingTxStmt: q.deleteCreditSpendsBySpendingTxStmt, deleteExpiredOutputLeasesStmt: q.deleteExpiredOutputLeasesStmt, deleteOutputLeaseStmt: q.deleteOutputLeaseStmt, deleteTransactionByIDStmt: q.deleteTransactionByIDStmt, + detachMinedTransactionStmt: q.detachMinedTransactionStmt, getAccountStmt: q.getAccountStmt, getAddressStmt: q.getAddressStmt, getBlockByHeightStmt: q.getBlockByHeightStmt, @@ -545,6 +610,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getTransactionLabelStmt: q.getTransactionLabelStmt, getUnminedTransactionByHashStmt: q.getUnminedTransactionByHashStmt, getWalletByNameStmt: q.getWalletByNameStmt, + getWalletStartBlockStmt: q.getWalletStartBlockStmt, getWalletSyncStateStmt: q.getWalletSyncStateStmt, insertBlockStmt: q.insertBlockStmt, insertCreditStmt: q.insertCreditStmt, @@ -555,20 +621,24 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listActiveOutputLeasesStmt: q.listActiveOutputLeasesStmt, listAddressTypesStmt: q.listAddressTypesStmt, listMinedTransactionsForwardStmt: q.listMinedTransactionsForwardStmt, + listMinedTransactionsFromHeightStmt: q.listMinedTransactionsFromHeightStmt, listMinedTransactionsReverseStmt: q.listMinedTransactionsReverseStmt, listOutputsToWatchStmt: q.listOutputsToWatchStmt, listTransactionCreditsStmt: q.listTransactionCreditsStmt, listTransactionIncidencesByHashStmt: q.listTransactionIncidencesByHashStmt, listUnminedSpendersStmt: q.listUnminedSpendersStmt, + listUnminedSpendersByPrevHashStmt: q.listUnminedSpendersByPrevHashStmt, listUnminedTransactionsStmt: q.listUnminedTransactionsStmt, listUnspentCreditsStmt: q.listUnspentCreditsStmt, markAddressUsedStmt: q.markAddressUsedStmt, promoteUnminedTransactionStmt: q.promoteUnminedTransactionStmt, + putBlockStmt: q.putBlockStmt, putTransactionLabelStmt: q.putTransactionLabelStmt, putWalletSyncStateStmt: q.putWalletSyncStateStmt, recordCreditSpendStmt: q.recordCreditSpendStmt, renameAccountStmt: q.renameAccountStmt, setActiveCreditIncidenceStmt: q.setActiveCreditIncidenceStmt, + setWalletSyncedToStmt: q.setWalletSyncedToStmt, updateAccountIndexesStmt: q.updateAccountIndexesStmt, updateKeyScopeKeysStmt: q.updateKeyScopeKeysStmt, updateLastAccountNumberStmt: q.updateLastAccountNumberStmt, diff --git a/wallet/internal/sql/sqlite/sqlc/querier.go b/wallet/internal/sql/sqlite/sqlc/querier.go index 3a018e9e2c..8b0934429d 100644 --- a/wallet/internal/sql/sqlite/sqlc/querier.go +++ b/wallet/internal/sql/sqlite/sqlc/querier.go @@ -16,9 +16,11 @@ type Querier interface { CreateWallet(ctx context.Context, arg CreateWalletParams) (int64, error) DeleteBlock(ctx context.Context, blockHeight int64) error DeleteCreditSpend(ctx context.Context, arg DeleteCreditSpendParams) (int64, error) + DeleteCreditSpendsBySpendingTx(ctx context.Context, arg DeleteCreditSpendsBySpendingTxParams) (int64, error) DeleteExpiredOutputLeases(ctx context.Context, arg DeleteExpiredOutputLeasesParams) (int64, error) DeleteOutputLease(ctx context.Context, arg DeleteOutputLeaseParams) (int64, error) DeleteTransactionByID(ctx context.Context, arg DeleteTransactionByIDParams) (int64, error) + DetachMinedTransaction(ctx context.Context, arg DetachMinedTransactionParams) (int64, error) GetAccount(ctx context.Context, arg GetAccountParams) (Account, error) GetAddress(ctx context.Context, arg GetAddressParams) (Address, error) GetBlockByHeight(ctx context.Context, blockHeight int64) (Block, error) @@ -30,6 +32,7 @@ type Querier interface { GetTransactionLabel(ctx context.Context, arg GetTransactionLabelParams) ([]byte, error) GetUnminedTransactionByHash(ctx context.Context, arg GetUnminedTransactionByHashParams) (Transaction, error) GetWalletByName(ctx context.Context, walletName string) (Wallet, error) + GetWalletStartBlock(ctx context.Context, walletID int64) (Block, error) GetWalletSyncState(ctx context.Context, walletID int64) (GetWalletSyncStateRow, error) InsertBlock(ctx context.Context, arg InsertBlockParams) error InsertCredit(ctx context.Context, arg InsertCreditParams) (int64, error) @@ -40,20 +43,24 @@ type Querier interface { ListActiveOutputLeases(ctx context.Context, arg ListActiveOutputLeasesParams) ([]UtxoLease, error) ListAddressTypes(ctx context.Context) ([]AddressType, error) ListMinedTransactionsForward(ctx context.Context, arg ListMinedTransactionsForwardParams) ([]ListMinedTransactionsForwardRow, error) + ListMinedTransactionsFromHeight(ctx context.Context, arg ListMinedTransactionsFromHeightParams) ([]ListMinedTransactionsFromHeightRow, error) ListMinedTransactionsReverse(ctx context.Context, arg ListMinedTransactionsReverseParams) ([]ListMinedTransactionsReverseRow, error) ListOutputsToWatch(ctx context.Context, walletID int64) ([]ListOutputsToWatchRow, error) ListTransactionCredits(ctx context.Context, arg ListTransactionCreditsParams) ([]ListTransactionCreditsRow, error) ListTransactionIncidencesByHash(ctx context.Context, arg ListTransactionIncidencesByHashParams) ([]Transaction, error) ListUnminedSpenders(ctx context.Context, arg ListUnminedSpendersParams) ([]ListUnminedSpendersRow, error) + ListUnminedSpendersByPrevHash(ctx context.Context, arg ListUnminedSpendersByPrevHashParams) ([]ListUnminedSpendersByPrevHashRow, error) ListUnminedTransactions(ctx context.Context, walletID int64) ([]Transaction, error) ListUnspentCredits(ctx context.Context, arg ListUnspentCreditsParams) ([]ListUnspentCreditsRow, error) MarkAddressUsed(ctx context.Context, arg MarkAddressUsedParams) (int64, error) PromoteUnminedTransaction(ctx context.Context, arg PromoteUnminedTransactionParams) (int64, error) + PutBlock(ctx context.Context, arg PutBlockParams) error PutTransactionLabel(ctx context.Context, arg PutTransactionLabelParams) error PutWalletSyncState(ctx context.Context, arg PutWalletSyncStateParams) error RecordCreditSpend(ctx context.Context, arg RecordCreditSpendParams) (int64, error) RenameAccount(ctx context.Context, arg RenameAccountParams) (int64, error) SetActiveCreditIncidence(ctx context.Context, arg SetActiveCreditIncidenceParams) error + SetWalletSyncedTo(ctx context.Context, arg SetWalletSyncedToParams) (int64, error) UpdateAccountIndexes(ctx context.Context, arg UpdateAccountIndexesParams) (int64, error) UpdateKeyScopeKeys(ctx context.Context, arg UpdateKeyScopeKeysParams) (int64, error) UpdateLastAccountNumber(ctx context.Context, arg UpdateLastAccountNumberParams) (int64, error) diff --git a/wallet/internal/sql/sqlite/sqlc/transactions.sql.go b/wallet/internal/sql/sqlite/sqlc/transactions.sql.go index 9bc4864574..73f39670a5 100644 --- a/wallet/internal/sql/sqlite/sqlc/transactions.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/transactions.sql.go @@ -10,6 +10,24 @@ import ( "database/sql" ) +const DeleteCreditSpendsBySpendingTx = `-- name: DeleteCreditSpendsBySpendingTx :execrows +DELETE FROM credit_spends +WHERE wallet_id = ? AND spending_tx_id = ? +` + +type DeleteCreditSpendsBySpendingTxParams struct { + WalletID int64 + SpendingTxID int64 +} + +func (q *Queries) DeleteCreditSpendsBySpendingTx(ctx context.Context, arg DeleteCreditSpendsBySpendingTxParams) (int64, error) { + result, err := q.exec(ctx, q.deleteCreditSpendsBySpendingTxStmt, DeleteCreditSpendsBySpendingTx, arg.WalletID, arg.SpendingTxID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const DeleteTransactionByID = `-- name: DeleteTransactionByID :execrows DELETE FROM transactions WHERE wallet_id = ? AND id = ? ` @@ -27,6 +45,25 @@ func (q *Queries) DeleteTransactionByID(ctx context.Context, arg DeleteTransacti return result.RowsAffected() } +const DetachMinedTransaction = `-- name: DetachMinedTransaction :execrows +UPDATE transactions +SET block_height = NULL, confirmed_order = NULL +WHERE wallet_id = ? AND id = ? AND block_height IS NOT NULL +` + +type DetachMinedTransactionParams struct { + WalletID int64 + ID int64 +} + +func (q *Queries) DetachMinedTransaction(ctx context.Context, arg DetachMinedTransactionParams) (int64, error) { + result, err := q.exec(ctx, q.detachMinedTransactionStmt, DetachMinedTransaction, arg.WalletID, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const GetMinedTransactionByIncidence = `-- name: GetMinedTransactionByIncidence :one SELECT t.id, t.wallet_id, t.tx_hash, t.raw_tx, t.received_unix, t.block_height, t.confirmed_order, t.is_coinbase @@ -237,6 +274,48 @@ func (q *Queries) ListMinedTransactionsForward(ctx context.Context, arg ListMine return items, nil } +const ListMinedTransactionsFromHeight = `-- name: ListMinedTransactionsFromHeight :many +SELECT id, tx_hash, is_coinbase +FROM transactions +WHERE wallet_id = ?1 + AND block_height >= cast(?2 AS INTEGER) +ORDER BY block_height DESC, confirmed_order DESC, id DESC +` + +type ListMinedTransactionsFromHeightParams struct { + WalletID int64 + Height int64 +} + +type ListMinedTransactionsFromHeightRow struct { + ID int64 + TxHash []byte + IsCoinbase bool +} + +func (q *Queries) ListMinedTransactionsFromHeight(ctx context.Context, arg ListMinedTransactionsFromHeightParams) ([]ListMinedTransactionsFromHeightRow, error) { + rows, err := q.query(ctx, q.listMinedTransactionsFromHeightStmt, ListMinedTransactionsFromHeight, arg.WalletID, arg.Height) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListMinedTransactionsFromHeightRow + for rows.Next() { + var i ListMinedTransactionsFromHeightRow + if err := rows.Scan(&i.ID, &i.TxHash, &i.IsCoinbase); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ListMinedTransactionsReverse = `-- name: ListMinedTransactionsReverse :many SELECT t.id, t.wallet_id, t.tx_hash, t.raw_tx, t.received_unix, t.block_height, t.confirmed_order, t.is_coinbase, @@ -393,6 +472,49 @@ func (q *Queries) ListUnminedSpenders(ctx context.Context, arg ListUnminedSpende return items, nil } +const ListUnminedSpendersByPrevHash = `-- name: ListUnminedSpendersByPrevHash :many +SELECT DISTINCT spender.id, spender.tx_hash +FROM transaction_inputs AS input +INNER JOIN transactions AS spender ON spender.id = input.spending_tx_id +WHERE spender.wallet_id = ?1 + AND spender.block_height IS NULL + AND input.prev_tx_hash = ?2 +ORDER BY spender.id +` + +type ListUnminedSpendersByPrevHashParams struct { + WalletID int64 + PrevTxHash []byte +} + +type ListUnminedSpendersByPrevHashRow struct { + ID int64 + TxHash []byte +} + +func (q *Queries) ListUnminedSpendersByPrevHash(ctx context.Context, arg ListUnminedSpendersByPrevHashParams) ([]ListUnminedSpendersByPrevHashRow, error) { + rows, err := q.query(ctx, q.listUnminedSpendersByPrevHashStmt, ListUnminedSpendersByPrevHash, arg.WalletID, arg.PrevTxHash) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUnminedSpendersByPrevHashRow + for rows.Next() { + var i ListUnminedSpendersByPrevHashRow + if err := rows.Scan(&i.ID, &i.TxHash); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ListUnminedTransactions = `-- name: ListUnminedTransactions :many SELECT id, wallet_id, tx_hash, raw_tx, received_unix, block_height, confirmed_order, is_coinbase diff --git a/wallet/internal/sql/sqlite/sqlc/wallets.sql.go b/wallet/internal/sql/sqlite/sqlc/wallets.sql.go index b99ef58f75..1e45764c3a 100644 --- a/wallet/internal/sql/sqlite/sqlc/wallets.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/wallets.sql.go @@ -98,6 +98,20 @@ func (q *Queries) GetWalletByName(ctx context.Context, walletName string) (Walle return i, err } +const GetWalletStartBlock = `-- name: GetWalletStartBlock :one +SELECT b.block_height, b.header_hash, b.block_timestamp +FROM wallet_sync_states AS s +INNER JOIN blocks AS b ON b.block_height = s.start_block_height +WHERE s.wallet_id = ? +` + +func (q *Queries) GetWalletStartBlock(ctx context.Context, walletID int64) (Block, error) { + row := q.queryRow(ctx, q.getWalletStartBlockStmt, GetWalletStartBlock, walletID) + var i Block + err := row.Scan(&i.BlockHeight, &i.HeaderHash, &i.BlockTimestamp) + return i, err +} + const GetWalletSyncState = `-- name: GetWalletSyncState :one SELECT s.wallet_id, @@ -180,6 +194,25 @@ func (q *Queries) PutWalletSyncState(ctx context.Context, arg PutWalletSyncState return err } +const SetWalletSyncedTo = `-- name: SetWalletSyncedTo :execrows +UPDATE wallet_sync_states +SET synced_block_height = ? +WHERE wallet_id = ? +` + +type SetWalletSyncedToParams struct { + SyncedBlockHeight int64 + WalletID int64 +} + +func (q *Queries) SetWalletSyncedTo(ctx context.Context, arg SetWalletSyncedToParams) (int64, error) { + result, err := q.exec(ctx, q.setWalletSyncedToStmt, SetWalletSyncedTo, arg.SyncedBlockHeight, arg.WalletID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const UpdateWalletEncryption = `-- name: UpdateWalletEncryption :execrows UPDATE wallets SET From c4cec36bf3c86ec809588dc837c9c28f602a3d8b Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 14 Jul 2026 13:50:55 -0700 Subject: [PATCH 3/4] wallet/sql: implement manager transaction store In this commit, we implement the #1294 manager boundary for SQLite and PostgreSQL on top of the released lnd/sqldb v1.0.13 transaction executor. Each callback receives address and transaction views bound to the same SQL transaction, while context, database/sql handles, and generated query types remain behind the backend adapters. The address view preserves BlockHash and SetSyncedTo behavior, including the nil reset to the wallet start block. The transaction view moves disconnected non-coinbase incidences back to the unmined set, removes coinbase rows and their unmined descendants, clears mined spend edges, and keeps the active credit on the surviving incidence when duplicate history exists. Extracted-from: PR #1125 (d9bd945bc52a15c49d47bbb80bcd914db97a713b) Extracted-from: PR #1125 (807080399ff171c81f124a045ccab9a70c4f61c7) Extracted-from: PR #1125 (70e726259ada8cb7de399f05b5e6dea4a79e4879) Co-authored-by: yyforyongyu Co-authored-by: Mohamed Awnallah --- wallet/internal/db/pg/store.go | 212 +++++++++++++++ wallet/internal/db/sqlite/store.go | 212 +++++++++++++++ wallet/internal/db/sqlstore/doc.go | 2 + wallet/internal/db/sqlstore/queries.go | 52 ++++ wallet/internal/db/sqlstore/store.go | 361 +++++++++++++++++++++++++ 5 files changed, 839 insertions(+) create mode 100644 wallet/internal/db/pg/store.go create mode 100644 wallet/internal/db/sqlite/store.go create mode 100644 wallet/internal/db/sqlstore/doc.go create mode 100644 wallet/internal/db/sqlstore/queries.go create mode 100644 wallet/internal/db/sqlstore/store.go diff --git a/wallet/internal/db/pg/store.go b/wallet/internal/db/pg/store.go new file mode 100644 index 0000000000..1fa22be648 --- /dev/null +++ b/wallet/internal/db/pg/store.go @@ -0,0 +1,212 @@ +// Package pg implements the manager transaction store with PostgreSQL. +package pg + +import ( + "context" + "database/sql" + + "github.com/btcsuite/btcwallet/wallet/internal/db/sqlstore" + pgdb "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" +) + +// Store is the PostgreSQL manager transaction store. +type Store struct { + *sqlstore.Store +} + +// NewStore creates a PostgreSQL manager store for one wallet. +func NewStore(conn *sql.DB, walletID int64) *Store { + return &Store{ + Store: sqlstore.New( + conn, walletID, func(tx *sql.Tx) sqlstore.Queries { + return &queryAdapter{queries: pgdb.New(tx)} + }, + ), + } +} + +type queryAdapter struct { + queries *pgdb.Queries +} + +func (q *queryAdapter) PutBlock(ctx context.Context, + row sqlstore.BlockRow) error { + + return q.queries.PutBlock(ctx, pgdb.PutBlockParams{ + BlockHeight: row.Height, + HeaderHash: row.Hash, + BlockTimestamp: row.Timestamp, + }) +} + +func (q *queryAdapter) GetBlockByHeight(ctx context.Context, + height int32) (sqlstore.BlockRow, error) { + + row, err := q.queries.GetBlockByHeight(ctx, height) + if err != nil { + return sqlstore.BlockRow{}, err + } + + return sqlstore.BlockRow{ + Height: row.BlockHeight, + Hash: row.HeaderHash, + Timestamp: row.BlockTimestamp, + }, nil +} + +func (q *queryAdapter) GetWalletStartBlock(ctx context.Context, + walletID int64) (sqlstore.BlockRow, error) { + + row, err := q.queries.GetWalletStartBlock(ctx, walletID) + if err != nil { + return sqlstore.BlockRow{}, err + } + + return sqlstore.BlockRow{ + Height: row.BlockHeight, + Hash: row.HeaderHash, + Timestamp: row.BlockTimestamp, + }, nil +} + +func (q *queryAdapter) SetWalletSyncedTo(ctx context.Context, walletID int64, + height int32) (int64, error) { + + return q.queries.SetWalletSyncedTo( + ctx, pgdb.SetWalletSyncedToParams{ + SyncedBlockHeight: height, + WalletID: walletID, + }, + ) +} + +func (q *queryAdapter) ListMinedTransactionsFromHeight( + ctx context.Context, walletID int64, + height int32) ([]sqlstore.MinedTransactionRow, error) { + + rows, err := q.queries.ListMinedTransactionsFromHeight( + ctx, pgdb.ListMinedTransactionsFromHeightParams{ + WalletID: walletID, + Height: height, + }, + ) + if err != nil { + return nil, err + } + + transactions := make([]sqlstore.MinedTransactionRow, 0, len(rows)) + for _, row := range rows { + transactions = append(transactions, sqlstore.MinedTransactionRow{ + ID: row.ID, + Hash: row.TxHash, + IsCoinbase: row.IsCoinbase, + }) + } + + return transactions, nil +} + +func (q *queryAdapter) GetUnminedTransactionID(ctx context.Context, + walletID int64, hash []byte) (int64, error) { + + row, err := q.queries.GetUnminedTransactionByHash( + ctx, pgdb.GetUnminedTransactionByHashParams{ + WalletID: walletID, + TxHash: hash, + }, + ) + + return row.ID, err +} + +func (q *queryAdapter) DeleteCreditSpendsBySpendingTx( + ctx context.Context, walletID, transactionID int64) (int64, error) { + + return q.queries.DeleteCreditSpendsBySpendingTx( + ctx, pgdb.DeleteCreditSpendsBySpendingTxParams{ + WalletID: walletID, + SpendingTxID: transactionID, + }, + ) +} + +func (q *queryAdapter) DetachMinedTransaction(ctx context.Context, walletID, + transactionID int64) (int64, error) { + + return q.queries.DetachMinedTransaction( + ctx, pgdb.DetachMinedTransactionParams{ + WalletID: walletID, + ID: transactionID, + }, + ) +} + +func (q *queryAdapter) ListTransactionCreditIDs(ctx context.Context, walletID, + transactionID int64) ([]int64, error) { + + rows, err := q.queries.ListTransactionCredits( + ctx, pgdb.ListTransactionCreditsParams{ + WalletID: walletID, + TransactionID: transactionID, + }, + ) + if err != nil { + return nil, err + } + + creditIDs := make([]int64, 0, len(rows)) + for _, row := range rows { + creditIDs = append(creditIDs, row.ID) + } + + return creditIDs, nil +} + +func (q *queryAdapter) SetActiveCreditIncidence(ctx context.Context, walletID, + creditID int64) error { + + return q.queries.SetActiveCreditIncidence( + ctx, pgdb.SetActiveCreditIncidenceParams{ + WalletID: walletID, + ID: creditID, + }, + ) +} + +func (q *queryAdapter) ListUnminedSpendersByPrevHash( + ctx context.Context, walletID int64, + hash []byte) ([]sqlstore.UnminedSpenderRow, error) { + + rows, err := q.queries.ListUnminedSpendersByPrevHash( + ctx, pgdb.ListUnminedSpendersByPrevHashParams{ + WalletID: walletID, + PrevTxHash: hash, + }, + ) + if err != nil { + return nil, err + } + + spenders := make([]sqlstore.UnminedSpenderRow, 0, len(rows)) + for _, row := range rows { + spenders = append(spenders, sqlstore.UnminedSpenderRow{ + ID: row.ID, + Hash: row.TxHash, + }) + } + + return spenders, nil +} + +func (q *queryAdapter) DeleteTransaction(ctx context.Context, walletID, + transactionID int64) (int64, error) { + + return q.queries.DeleteTransactionByID( + ctx, pgdb.DeleteTransactionByIDParams{ + WalletID: walletID, + ID: transactionID, + }, + ) +} + +var _ sqlstore.Queries = (*queryAdapter)(nil) diff --git a/wallet/internal/db/sqlite/store.go b/wallet/internal/db/sqlite/store.go new file mode 100644 index 0000000000..1db52d468d --- /dev/null +++ b/wallet/internal/db/sqlite/store.go @@ -0,0 +1,212 @@ +// Package sqlite implements the manager transaction store with SQLite. +package sqlite + +import ( + "context" + "database/sql" + + "github.com/btcsuite/btcwallet/wallet/internal/db/sqlstore" + sqlitedb "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" +) + +// Store is the SQLite manager transaction store. +type Store struct { + *sqlstore.Store +} + +// NewStore creates a SQLite manager store for one wallet. +func NewStore(conn *sql.DB, walletID int64) *Store { + return &Store{ + Store: sqlstore.New( + conn, walletID, func(tx *sql.Tx) sqlstore.Queries { + return &queryAdapter{queries: sqlitedb.New(tx)} + }, + ), + } +} + +type queryAdapter struct { + queries *sqlitedb.Queries +} + +func (q *queryAdapter) PutBlock(ctx context.Context, + row sqlstore.BlockRow) error { + + return q.queries.PutBlock(ctx, sqlitedb.PutBlockParams{ + BlockHeight: int64(row.Height), + HeaderHash: row.Hash, + BlockTimestamp: row.Timestamp, + }) +} + +func (q *queryAdapter) GetBlockByHeight(ctx context.Context, + height int32) (sqlstore.BlockRow, error) { + + row, err := q.queries.GetBlockByHeight(ctx, int64(height)) + if err != nil { + return sqlstore.BlockRow{}, err + } + + return sqlstore.BlockRow{ + Height: int32(row.BlockHeight), + Hash: row.HeaderHash, + Timestamp: row.BlockTimestamp, + }, nil +} + +func (q *queryAdapter) GetWalletStartBlock(ctx context.Context, + walletID int64) (sqlstore.BlockRow, error) { + + row, err := q.queries.GetWalletStartBlock(ctx, walletID) + if err != nil { + return sqlstore.BlockRow{}, err + } + + return sqlstore.BlockRow{ + Height: int32(row.BlockHeight), + Hash: row.HeaderHash, + Timestamp: row.BlockTimestamp, + }, nil +} + +func (q *queryAdapter) SetWalletSyncedTo(ctx context.Context, walletID int64, + height int32) (int64, error) { + + return q.queries.SetWalletSyncedTo( + ctx, sqlitedb.SetWalletSyncedToParams{ + SyncedBlockHeight: int64(height), + WalletID: walletID, + }, + ) +} + +func (q *queryAdapter) ListMinedTransactionsFromHeight( + ctx context.Context, walletID int64, + height int32) ([]sqlstore.MinedTransactionRow, error) { + + rows, err := q.queries.ListMinedTransactionsFromHeight( + ctx, sqlitedb.ListMinedTransactionsFromHeightParams{ + WalletID: walletID, + Height: int64(height), + }, + ) + if err != nil { + return nil, err + } + + transactions := make([]sqlstore.MinedTransactionRow, 0, len(rows)) + for _, row := range rows { + transactions = append(transactions, sqlstore.MinedTransactionRow{ + ID: row.ID, + Hash: row.TxHash, + IsCoinbase: row.IsCoinbase, + }) + } + + return transactions, nil +} + +func (q *queryAdapter) GetUnminedTransactionID(ctx context.Context, + walletID int64, hash []byte) (int64, error) { + + row, err := q.queries.GetUnminedTransactionByHash( + ctx, sqlitedb.GetUnminedTransactionByHashParams{ + WalletID: walletID, + TxHash: hash, + }, + ) + + return row.ID, err +} + +func (q *queryAdapter) DeleteCreditSpendsBySpendingTx( + ctx context.Context, walletID, transactionID int64) (int64, error) { + + return q.queries.DeleteCreditSpendsBySpendingTx( + ctx, sqlitedb.DeleteCreditSpendsBySpendingTxParams{ + WalletID: walletID, + SpendingTxID: transactionID, + }, + ) +} + +func (q *queryAdapter) DetachMinedTransaction(ctx context.Context, walletID, + transactionID int64) (int64, error) { + + return q.queries.DetachMinedTransaction( + ctx, sqlitedb.DetachMinedTransactionParams{ + WalletID: walletID, + ID: transactionID, + }, + ) +} + +func (q *queryAdapter) ListTransactionCreditIDs(ctx context.Context, walletID, + transactionID int64) ([]int64, error) { + + rows, err := q.queries.ListTransactionCredits( + ctx, sqlitedb.ListTransactionCreditsParams{ + WalletID: walletID, + TransactionID: transactionID, + }, + ) + if err != nil { + return nil, err + } + + creditIDs := make([]int64, 0, len(rows)) + for _, row := range rows { + creditIDs = append(creditIDs, row.ID) + } + + return creditIDs, nil +} + +func (q *queryAdapter) SetActiveCreditIncidence(ctx context.Context, walletID, + creditID int64) error { + + return q.queries.SetActiveCreditIncidence( + ctx, sqlitedb.SetActiveCreditIncidenceParams{ + WalletID: walletID, + ID: creditID, + }, + ) +} + +func (q *queryAdapter) ListUnminedSpendersByPrevHash( + ctx context.Context, walletID int64, + hash []byte) ([]sqlstore.UnminedSpenderRow, error) { + + rows, err := q.queries.ListUnminedSpendersByPrevHash( + ctx, sqlitedb.ListUnminedSpendersByPrevHashParams{ + WalletID: walletID, + PrevTxHash: hash, + }, + ) + if err != nil { + return nil, err + } + + spenders := make([]sqlstore.UnminedSpenderRow, 0, len(rows)) + for _, row := range rows { + spenders = append(spenders, sqlstore.UnminedSpenderRow{ + ID: row.ID, + Hash: row.TxHash, + }) + } + + return spenders, nil +} + +func (q *queryAdapter) DeleteTransaction(ctx context.Context, walletID, + transactionID int64) (int64, error) { + + return q.queries.DeleteTransactionByID( + ctx, sqlitedb.DeleteTransactionByIDParams{ + WalletID: walletID, + ID: transactionID, + }, + ) +} + +var _ sqlstore.Queries = (*queryAdapter)(nil) diff --git a/wallet/internal/db/sqlstore/doc.go b/wallet/internal/db/sqlstore/doc.go new file mode 100644 index 0000000000..a7fde17ce2 --- /dev/null +++ b/wallet/internal/db/sqlstore/doc.go @@ -0,0 +1,2 @@ +// Package sqlstore implements the shared SQL manager transaction store. +package sqlstore diff --git a/wallet/internal/db/sqlstore/queries.go b/wallet/internal/db/sqlstore/queries.go new file mode 100644 index 0000000000..6e3f97679c --- /dev/null +++ b/wallet/internal/db/sqlstore/queries.go @@ -0,0 +1,52 @@ +package sqlstore + +import "context" + +// BlockRow is the backend-neutral representation of a blocks table row. +type BlockRow struct { + Height int32 + Hash []byte + Timestamp int64 +} + +// MinedTransactionRow identifies a mined transaction being disconnected. +type MinedTransactionRow struct { + ID int64 + Hash []byte + IsCoinbase bool +} + +// UnminedSpenderRow identifies an unmined transaction that spends a hash. +type UnminedSpenderRow struct { + ID int64 + Hash []byte +} + +// Queries is the generated-query subset required by the manager transaction +// store. Backend adapters normalize SQLite and PostgreSQL integer widths here. +// +//nolint:interfacebloat // One SQL transaction binds both manager domains. +type Queries interface { + PutBlock(ctx context.Context, row BlockRow) error + GetBlockByHeight(ctx context.Context, height int32) (BlockRow, error) + GetWalletStartBlock(ctx context.Context, walletID int64) (BlockRow, error) + SetWalletSyncedTo(ctx context.Context, walletID int64, + height int32) (int64, error) + + ListMinedTransactionsFromHeight(ctx context.Context, walletID int64, + height int32) ([]MinedTransactionRow, error) + GetUnminedTransactionID(ctx context.Context, walletID int64, + hash []byte) (int64, error) + DeleteCreditSpendsBySpendingTx(ctx context.Context, walletID, + transactionID int64) (int64, error) + DetachMinedTransaction(ctx context.Context, walletID, + transactionID int64) (int64, error) + ListTransactionCreditIDs(ctx context.Context, walletID, + transactionID int64) ([]int64, error) + SetActiveCreditIncidence(ctx context.Context, walletID, + creditID int64) error + ListUnminedSpendersByPrevHash(ctx context.Context, walletID int64, + hash []byte) ([]UnminedSpenderRow, error) + DeleteTransaction(ctx context.Context, walletID, + transactionID int64) (int64, error) +} diff --git a/wallet/internal/db/sqlstore/store.go b/wallet/internal/db/sqlstore/store.go new file mode 100644 index 0000000000..a0adb54054 --- /dev/null +++ b/wallet/internal/db/sqlstore/store.go @@ -0,0 +1,361 @@ +package sqlstore + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcwallet/waddrmgr" + walletstore "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/lightningnetwork/lnd/sqldb" + sqldbsqlc "github.com/lightningnetwork/lnd/sqldb/sqlc" +) + +// Store owns SQL transactions for one wallet's address and transaction +// managers. +type Store struct { + walletID int64 + executor *sqldb.TransactionExecutor[Queries] +} + +// New creates a manager store over an existing SQL connection. Connection +// setup, migrations, and wallet creation remain owned by the backend package. +func New(conn *sql.DB, walletID int64, + newQueries func(*sql.Tx) Queries) *Store { + + baseDB := &sqldb.BaseDB{ + DB: conn, + Queries: sqldbsqlc.New(conn), + } + + return &Store{ + walletID: walletID, + executor: sqldb.NewTransactionExecutor(baseDB, newQueries), + } +} + +// View executes body in a read-only SQL transaction. +func (s *Store) View(ctx context.Context, + body func(walletstore.ReadTx) error, reset func()) error { + + return s.executor.ExecTx( + ctx, sqldb.ReadTxOpt(), func(queries Queries) error { + return body(&readTx{ + addrStore: &addrStore{ + ctx: ctx, + walletID: s.walletID, + queries: queries, + }, + }) + }, nonNilReset(reset), + ) +} + +// Update executes body in a read/write SQL transaction. +func (s *Store) Update(ctx context.Context, + body func(walletstore.ReadWriteTx) error, reset func()) error { + + return s.executor.ExecTx( + ctx, sqldb.WriteTxOpt(), func(queries Queries) error { + return body(&readWriteTx{ + addrStore: &addrStore{ + ctx: ctx, + walletID: s.walletID, + queries: queries, + }, + txStore: &txStore{ + ctx: ctx, + walletID: s.walletID, + queries: queries, + }, + }) + }, nonNilReset(reset), + ) +} + +func nonNilReset(reset func()) func() { + if reset != nil { + return reset + } + + return func() {} +} + +type readTx struct { + addrStore walletstore.AddrReadStore +} + +// Addr returns the address-manager read view. +// +//nolint:ireturn // The transaction contract returns a domain interface. +func (t *readTx) Addr() walletstore.AddrReadStore { + return t.addrStore +} + +type readWriteTx struct { + addrStore walletstore.AddrReadWriteStore + txStore walletstore.TxReadWriteStore +} + +// Addr returns the address-manager read/write view. +// +//nolint:ireturn // The transaction contract returns a domain interface. +func (t *readWriteTx) Addr() walletstore.AddrReadWriteStore { + return t.addrStore +} + +// Tx returns the transaction-manager read/write view. +// +//nolint:ireturn // The transaction contract returns a domain interface. +func (t *readWriteTx) Tx() walletstore.TxReadWriteStore { + return t.txStore +} + +type addrStore struct { + // The manager view is scoped to the transaction callback that created it. + // + //nolint:containedctx // Domain methods intentionally omit backend context. + ctx context.Context + walletID int64 + queries Queries +} + +// BlockHash returns the block hash at a particular block height. +func (s *addrStore) BlockHash(height int32) (*chainhash.Hash, error) { + row, err := s.queries.GetBlockByHeight(s.ctx, height) + if errors.Is(err, sql.ErrNoRows) { + return nil, waddrmgr.ManagerError{ + ErrorCode: waddrmgr.ErrBlockNotFound, + Description: fmt.Sprintf( + "failed to fetch block hash for height %d", height, + ), + Err: err, + } + } + + if err != nil { + return nil, fmt.Errorf("get block %d: %w", height, err) + } + + hash, err := chainhash.NewHash(row.Hash) + if err != nil { + return nil, fmt.Errorf("decode block %d hash: %w", height, err) + } + + return hash, nil +} + +// SetSyncedTo marks the address manager as synced through the block. +func (s *addrStore) SetSyncedTo(block *waddrmgr.BlockStamp) error { + if block == nil { + row, err := s.queries.GetWalletStartBlock(s.ctx, s.walletID) + if err != nil { + return fmt.Errorf("get wallet start block: %w", err) + } + + hash, err := chainhash.NewHash(row.Hash) + if err != nil { + return fmt.Errorf("decode wallet start block hash: %w", err) + } + + block = &waddrmgr.BlockStamp{ + Height: row.Height, + Hash: *hash, + Timestamp: time.Unix(row.Timestamp, 0), + } + } + + err := s.queries.PutBlock(s.ctx, BlockRow{ + Height: block.Height, + Hash: block.Hash[:], + Timestamp: block.Timestamp.Unix(), + }) + if err != nil { + return fmt.Errorf("put synced-to block %d: %w", block.Height, err) + } + + rows, err := s.queries.SetWalletSyncedTo( + s.ctx, s.walletID, block.Height, + ) + if err != nil { + return fmt.Errorf("set wallet synced-to block: %w", err) + } + + if rows != 1 { + return fmt.Errorf("wallet %d sync state not found", s.walletID) + } + + return nil +} + +type txStore struct { + // The manager view is scoped to the transaction callback that created it. + // + //nolint:containedctx // Domain methods intentionally omit backend context. + ctx context.Context + walletID int64 + queries Queries +} + +// Rollback removes all mined transaction incidences at height onwards. The +// retained non-coinbase incidence becomes unmined, while coinbase transactions +// and any unmined descendants that spend them are removed. +func (s *txStore) Rollback(height int32) error { + rows, err := s.queries.ListMinedTransactionsFromHeight( + s.ctx, s.walletID, height, + ) + if err != nil { + return fmt.Errorf("list rollback transactions: %w", err) + } + + removed := make(map[int64]struct{}) + for _, row := range rows { + err := s.rollbackTransaction(row, removed) + if err != nil { + return err + } + } + + return nil +} + +func (s *txStore) rollbackTransaction(row MinedTransactionRow, + removed map[int64]struct{}) error { + + if row.IsCoinbase { + err := s.removeUnminedDescendants(row.Hash, removed) + if err != nil { + return err + } + + err = s.deleteTransaction(row.ID) + if err != nil { + return fmt.Errorf("delete coinbase transaction: %w", err) + } + + return nil + } + + _, err := s.queries.DeleteCreditSpendsBySpendingTx( + s.ctx, s.walletID, row.ID, + ) + if err != nil { + return fmt.Errorf("delete transaction credit spends: %w", err) + } + + unminedID, err := s.queries.GetUnminedTransactionID( + s.ctx, s.walletID, row.Hash, + ) + if errors.Is(err, sql.ErrNoRows) { + return s.detachMinedTransaction(row.ID) + } + + if err != nil { + return fmt.Errorf("get unmined transaction: %w", err) + } + + err = s.activateTransactionCredits(unminedID) + if err != nil { + return err + } + + err = s.deleteTransaction(row.ID) + if err != nil { + return fmt.Errorf("delete duplicate incidence: %w", err) + } + + return nil +} + +func (s *txStore) detachMinedTransaction(transactionID int64) error { + rows, err := s.queries.DetachMinedTransaction( + s.ctx, s.walletID, transactionID, + ) + if err != nil { + return fmt.Errorf("detach mined transaction: %w", err) + } + + if rows != 1 { + return fmt.Errorf("mined transaction %d not found", transactionID) + } + + return nil +} + +func (s *txStore) activateTransactionCredits(transactionID int64) error { + creditIDs, err := s.queries.ListTransactionCreditIDs( + s.ctx, s.walletID, transactionID, + ) + if err != nil { + return fmt.Errorf("list transaction credits: %w", err) + } + + for _, creditID := range creditIDs { + err := s.queries.SetActiveCreditIncidence( + s.ctx, s.walletID, creditID, + ) + if err != nil { + return fmt.Errorf("activate transaction credit: %w", err) + } + } + + return nil +} + +func (s *txStore) removeUnminedDescendants(hash []byte, + removed map[int64]struct{}) error { + + spenders, err := s.queries.ListUnminedSpendersByPrevHash( + s.ctx, s.walletID, hash, + ) + if err != nil { + return fmt.Errorf("list unmined descendants: %w", err) + } + + for _, spender := range spenders { + if _, ok := removed[spender.ID]; ok { + continue + } + + removed[spender.ID] = struct{}{} + + err := s.removeUnminedDescendants(spender.Hash, removed) + if err != nil { + return err + } + + err = s.deleteTransaction(spender.ID) + if err != nil { + return fmt.Errorf("delete unmined descendant: %w", err) + } + } + + return nil +} + +func (s *txStore) deleteTransaction(transactionID int64) error { + rows, err := s.queries.DeleteTransaction( + s.ctx, s.walletID, transactionID, + ) + if err != nil { + return err + } + + if rows != 1 { + return fmt.Errorf("transaction %d not found", transactionID) + } + + return nil +} + +var ( + _ walletstore.Store = (*Store)(nil) + _ walletstore.ReadTx = (*readTx)(nil) + _ walletstore.ReadWriteTx = (*readWriteTx)(nil) + _ walletstore.AddrReadStore = (*addrStore)(nil) + _ walletstore.AddrReadWriteStore = (*addrStore)(nil) + _ walletstore.TxReadWriteStore = (*txStore)(nil) +) From e0622ab600e58841aef404cdbc3dc07e04018d1c Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Tue, 14 Jul 2026 13:51:24 -0700 Subject: [PATCH 4/4] wallet/sql: test manager store parity In this commit, we run one manager-store conformance suite against SQLite and PostgreSQL. The suite covers callback reset and cancellation, missing block errors, block replacement, SetSyncedTo(nil), and the independent birthday verification state. The rollback cases exercise the state transitions that are easy to lose in a mechanical port: address and transaction rewinds commit or abort together, non-coinbase incidences return to the unmined set, mined spend edges are cleared, coinbase descendants are removed recursively, and duplicate incidences leave the active credit attached to the surviving row. Extracted-from: PR #1125 (e9223af2f61b53e084485ac276de693d3cc964df) Extracted-from: PR #1125 (202ac9c692079589ae272ab653014fce4123e1cd) Extracted-from: PR #1262 (21a8f2fa0f988b48c71e4db4d1e38a74e455c092) Co-authored-by: yyforyongyu --- .../internal/db/itest/manager_store_test.go | 554 ++++++++++++++++++ wallet/internal/db/itest/pg_test.go | 60 ++ wallet/internal/db/itest/sqlite_test.go | 36 ++ 3 files changed, 650 insertions(+) create mode 100644 wallet/internal/db/itest/manager_store_test.go create mode 100644 wallet/internal/db/itest/pg_test.go create mode 100644 wallet/internal/db/itest/sqlite_test.go diff --git a/wallet/internal/db/itest/manager_store_test.go b/wallet/internal/db/itest/manager_store_test.go new file mode 100644 index 0000000000..8296cb6fa9 --- /dev/null +++ b/wallet/internal/db/itest/manager_store_test.go @@ -0,0 +1,554 @@ +package itest + +import ( + "context" + "database/sql" + "encoding/binary" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +type managerStoreHarness struct { + conn *sql.DB + postgres bool + newStore func(int64) db.Store +} + +func testManagerStore(t *testing.T, harness *managerStoreHarness) { + t.Helper() + + t.Run("manager transaction", func(t *testing.T) { + testManagerTransaction(t, harness) + }) + t.Run("rollback", func(t *testing.T) { + testRollback(t, harness) + }) + t.Run("rollback transaction", func(t *testing.T) { + testRollbackTransaction(t, harness) + }) + t.Run("duplicate incidence", func(t *testing.T) { + testDuplicateIncidence(t, harness) + }) + t.Run("birthday verification", func(t *testing.T) { + testBirthdayVerification(t, harness) + }) +} + +func testManagerTransaction(t *testing.T, harness *managerStoreHarness) { + t.Helper() + + ctx := context.Background() + start := testBlock(100) + synced := testBlock(101) + walletID := harness.createWallet(t, "manager-transaction", start, synced) + store := harness.newStore(walletID) + + var resetCount int + + err := store.View(ctx, func(tx db.ReadTx) error { + hash, err := tx.Addr().BlockHash(start.Height) + require.NoError(t, err) + require.Equal(t, start.Hash, *hash) + + return nil + }, func() { + resetCount++ + }) + require.NoError(t, err) + require.Equal(t, 1, resetCount) + + missingHeight := int32(99) + err = store.View(ctx, func(tx db.ReadTx) error { + _, err := tx.Addr().BlockHash(missingHeight) + require.True(t, waddrmgr.IsError(err, waddrmgr.ErrBlockNotFound)) + + return nil + }, func() {}) + require.NoError(t, err) + + replacement := testBlock(101) + replacement.Hash = testHash(201) + replacement.Timestamp = time.Unix(2201, 0) + err = store.Update(ctx, func(tx db.ReadWriteTx) error { + return tx.Addr().SetSyncedTo(&replacement) + }, func() {}) + require.NoError(t, err) + + require.Equal(t, replacement.Height, harness.syncedHeight(t, walletID)) + require.Equal(t, replacement.Hash, harness.blockHash(t, replacement.Height)) + + err = store.Update(ctx, func(tx db.ReadWriteTx) error { + return tx.Addr().SetSyncedTo(nil) + }, func() {}) + require.NoError(t, err) + require.Equal(t, start.Height, harness.syncedHeight(t, walletID)) + + canceledCtx, cancel := context.WithCancel(ctx) + cancel() + + var bodyCalled bool + + err = store.Update(canceledCtx, func(db.ReadWriteTx) error { + bodyCalled = true + + return nil + }, func() {}) + require.ErrorIs(t, err, context.Canceled) + require.False(t, bodyCalled) +} + +func testRollback(t *testing.T, harness *managerStoreHarness) { + t.Helper() + + ctx := context.Background() + walletID := harness.createWallet( + t, "rollback", testBlock(200), testBlock(201), + ) + store := harness.newStore(walletID) + + fundingHash := testHash(21) + fundingID := harness.insertTransaction( + t, walletID, fundingHash, 200, 0, false, + ) + fundingCreditID := harness.insertCredit(t, walletID, fundingID) + harness.setActiveCredit(t, walletID, fundingCreditID) + + spenderHash := testHash(22) + spenderID := harness.insertTransaction( + t, walletID, spenderHash, 201, 0, false, + ) + harness.insertInput(t, spenderID, 0, fundingHash, 0) + harness.insertCreditSpend(t, walletID, fundingCreditID, spenderID, 0) + + coinbaseHash := testHash(23) + coinbaseID := harness.insertTransaction( + t, walletID, coinbaseHash, 200, 1, true, + ) + coinbaseCreditID := harness.insertCredit(t, walletID, coinbaseID) + harness.setActiveCredit(t, walletID, coinbaseCreditID) + + childHash := testHash(24) + childID := harness.insertUnminedTransaction(t, walletID, childHash) + harness.insertInput(t, childID, 0, coinbaseHash, 0) + childCreditID := harness.insertCredit(t, walletID, childID) + harness.setActiveCredit(t, walletID, childCreditID) + + grandchildHash := testHash(25) + grandchildID := harness.insertUnminedTransaction( + t, walletID, grandchildHash, + ) + harness.insertInput(t, grandchildID, 0, childHash, 0) + + err := store.Update(ctx, func(tx db.ReadWriteTx) error { + return tx.Tx().Rollback(200) + }, func() {}) + require.NoError(t, err) + + require.False(t, harness.transactionMined(t, fundingID)) + require.False(t, harness.transactionMined(t, spenderID)) + require.True(t, harness.transactionExists(t, fundingID)) + require.True(t, harness.transactionExists(t, spenderID)) + require.False(t, harness.transactionExists(t, coinbaseID)) + require.False(t, harness.transactionExists(t, childID)) + require.False(t, harness.transactionExists(t, grandchildID)) + require.Equal(t, int64(0), harness.creditSpendCount(t, walletID)) + require.Equal(t, fundingCreditID, harness.activeCreditID( + t, walletID, fundingHash, 0, + )) +} + +func testRollbackTransaction(t *testing.T, harness *managerStoreHarness) { + t.Helper() + + ctx := context.Background() + start := testBlock(300) + synced := testBlock(301) + walletID := harness.createWallet(t, "rollback-transaction", start, synced) + store := harness.newStore(walletID) + + txID := harness.insertTransaction( + t, walletID, testHash(31), 301, 0, false, + ) + testErr := errors.New("abort manager transaction") + + err := store.Update(ctx, func(tx db.ReadWriteTx) error { + err := tx.Addr().SetSyncedTo(&start) + if err != nil { + return err + } + + err = tx.Tx().Rollback(301) + if err != nil { + return err + } + + return testErr + }, func() {}) + require.ErrorIs(t, err, testErr) + require.Equal(t, synced.Height, harness.syncedHeight(t, walletID)) + require.True(t, harness.transactionMined(t, txID)) +} + +func testDuplicateIncidence(t *testing.T, harness *managerStoreHarness) { + t.Helper() + + ctx := context.Background() + walletID := harness.createWallet( + t, "duplicate-incidence", testBlock(400), testBlock(401), + ) + store := harness.newStore(walletID) + + hash := testHash(41) + lowerID := harness.insertTransaction(t, walletID, hash, 400, 0, false) + lowerCreditID := harness.insertCredit(t, walletID, lowerID) + higherID := harness.insertTransaction(t, walletID, hash, 401, 0, false) + higherCreditID := harness.insertCredit(t, walletID, higherID) + harness.setActiveCredit(t, walletID, lowerCreditID) + + err := store.Update(ctx, func(tx db.ReadWriteTx) error { + return tx.Tx().Rollback(400) + }, func() {}) + require.NoError(t, err) + + require.False(t, harness.transactionExists(t, lowerID)) + require.True(t, harness.transactionExists(t, higherID)) + require.False(t, harness.transactionMined(t, higherID)) + require.Equal(t, higherCreditID, harness.activeCreditID( + t, walletID, hash, 0, + )) +} + +func testBirthdayVerification(t *testing.T, harness *managerStoreHarness) { + t.Helper() + + block := testBlock(500) + walletID := harness.createWallet( + t, "birthday-verification", block, block, + ) + + harness.exec(t, ` + UPDATE wallet_sync_states + SET birthday_block_height = ?, birthday_block_verified = TRUE + WHERE wallet_id = ? + `, block.Height, walletID) + harness.exec(t, ` + UPDATE wallet_sync_states + SET birthday_block_height = NULL + WHERE wallet_id = ? + `, walletID) + + var verified bool + + err := harness.queryRow(t, ` + SELECT birthday_block_verified + FROM wallet_sync_states + WHERE wallet_id = ? + `, walletID).Scan(&verified) + require.NoError(t, err) + require.True(t, verified) +} + +func (h *managerStoreHarness) createWallet(t *testing.T, name string, + start, synced waddrmgr.BlockStamp) int64 { + + t.Helper() + h.putBlock(t, start) + h.putBlock(t, synced) + + var walletID int64 + + err := h.queryRow(t, ` + INSERT INTO wallets ( + wallet_name, manager_version, manager_created_at, + is_watch_only, master_pub_params, encrypted_crypto_pub_key + ) VALUES (?, 1, 1, TRUE, ?, ?) + RETURNING id + `, name, []byte{1}, []byte{2}).Scan(&walletID) + require.NoError(t, err) + + h.exec(t, ` + INSERT INTO wallet_sync_states ( + wallet_id, start_block_height, synced_block_height, + birthday_timestamp, birthday_block_verified + ) VALUES (?, ?, ?, 1, FALSE) + `, walletID, start.Height, synced.Height) + + return walletID +} + +func (h *managerStoreHarness) putBlock(t *testing.T, + block waddrmgr.BlockStamp) { + + t.Helper() + h.exec(t, ` + INSERT INTO blocks (block_height, header_hash, block_timestamp) + VALUES (?, ?, ?) + ON CONFLICT (block_height) DO UPDATE SET + header_hash = excluded.header_hash, + block_timestamp = excluded.block_timestamp + `, block.Height, block.Hash[:], block.Timestamp.Unix()) +} + +func (h *managerStoreHarness) insertTransaction(t *testing.T, walletID int64, + hash chainhash.Hash, height int32, order int64, coinbase bool) int64 { + + t.Helper() + + var transactionID int64 + + err := h.queryRow(t, ` + INSERT INTO transactions ( + wallet_id, tx_hash, raw_tx, received_unix, block_height, + confirmed_order, is_coinbase + ) VALUES (?, ?, ?, 1, ?, ?, ?) + RETURNING id + `, walletID, hash[:], []byte{hash[0]}, height, order, coinbase). + Scan(&transactionID) + require.NoError(t, err) + + return transactionID +} + +func (h *managerStoreHarness) insertUnminedTransaction(t *testing.T, + walletID int64, hash chainhash.Hash) int64 { + + t.Helper() + + var transactionID int64 + + err := h.queryRow(t, ` + INSERT INTO transactions ( + wallet_id, tx_hash, raw_tx, received_unix, is_coinbase + ) VALUES (?, ?, ?, 1, FALSE) + RETURNING id + `, walletID, hash[:], []byte{hash[0]}).Scan(&transactionID) + require.NoError(t, err) + + return transactionID +} + +func (h *managerStoreHarness) insertInput(t *testing.T, transactionID int64, + inputIndex int64, prevHash chainhash.Hash, prevIndex int64) { + + t.Helper() + h.exec(t, ` + INSERT INTO transaction_inputs ( + spending_tx_id, input_index, prev_tx_hash, prev_output_index + ) VALUES (?, ?, ?, ?) + `, transactionID, inputIndex, prevHash[:], prevIndex) +} + +func (h *managerStoreHarness) insertCredit(t *testing.T, walletID, + transactionID int64) int64 { + + t.Helper() + + var creditID int64 + + err := h.queryRow(t, ` + INSERT INTO credits ( + wallet_id, transaction_id, output_index, amount, pk_script, + is_change + ) VALUES (?, ?, ?, 1000, ?, FALSE) + RETURNING id + `, walletID, transactionID, 0, []byte{0x51}).Scan(&creditID) + require.NoError(t, err) + + return creditID +} + +func (h *managerStoreHarness) setActiveCredit(t *testing.T, walletID, + creditID int64) { + + t.Helper() + h.exec(t, ` + INSERT INTO active_credit_incidences ( + wallet_id, tx_hash, output_index, credit_id + ) + SELECT c.wallet_id, tx.tx_hash, c.output_index, c.id + FROM credits AS c + INNER JOIN transactions AS tx ON tx.id = c.transaction_id + WHERE c.wallet_id = ? AND c.id = ? + ON CONFLICT (wallet_id, tx_hash, output_index) DO UPDATE SET + credit_id = excluded.credit_id + `, walletID, creditID) +} + +func (h *managerStoreHarness) insertCreditSpend(t *testing.T, walletID, + creditID, transactionID, inputIndex int64) { + + t.Helper() + h.exec(t, ` + INSERT INTO credit_spends ( + wallet_id, credit_id, spending_tx_id, input_index + ) VALUES (?, ?, ?, ?) + `, walletID, creditID, transactionID, inputIndex) +} + +func (h *managerStoreHarness) syncedHeight(t *testing.T, + walletID int64) int32 { + + t.Helper() + + var height int32 + + err := h.queryRow(t, ` + SELECT synced_block_height FROM wallet_sync_states + WHERE wallet_id = ? + `, walletID).Scan(&height) + require.NoError(t, err) + + return height +} + +func (h *managerStoreHarness) blockHash(t *testing.T, + height int32) chainhash.Hash { + + t.Helper() + + var hashBytes []byte + + err := h.queryRow(t, ` + SELECT header_hash FROM blocks WHERE block_height = ? + `, height).Scan(&hashBytes) + require.NoError(t, err) + + hash, err := chainhash.NewHash(hashBytes) + require.NoError(t, err) + + return *hash +} + +func (h *managerStoreHarness) transactionExists(t *testing.T, + transactionID int64) bool { + + t.Helper() + + var count int64 + + err := h.queryRow(t, ` + SELECT count(*) FROM transactions WHERE id = ? + `, transactionID).Scan(&count) + require.NoError(t, err) + + return count == 1 +} + +func (h *managerStoreHarness) transactionMined(t *testing.T, + transactionID int64) bool { + + t.Helper() + + var mined bool + + err := h.queryRow(t, ` + SELECT block_height IS NOT NULL FROM transactions WHERE id = ? + `, transactionID).Scan(&mined) + require.NoError(t, err) + + return mined +} + +func (h *managerStoreHarness) creditSpendCount(t *testing.T, + walletID int64) int64 { + + t.Helper() + + var count int64 + + err := h.queryRow(t, ` + SELECT count(*) FROM credit_spends WHERE wallet_id = ? + `, walletID).Scan(&count) + require.NoError(t, err) + + return count +} + +func (h *managerStoreHarness) activeCreditID(t *testing.T, walletID int64, + hash chainhash.Hash, outputIndex int64) int64 { + + t.Helper() + + var creditID int64 + + err := h.queryRow(t, ` + SELECT credit_id FROM active_credit_incidences + WHERE wallet_id = ? AND tx_hash = ? AND output_index = ? + `, walletID, hash[:], outputIndex).Scan(&creditID) + require.NoError(t, err) + + return creditID +} + +func (h *managerStoreHarness) exec(t *testing.T, query string, + args ...any) { + + t.Helper() + + _, err := h.conn.ExecContext( + context.Background(), h.bind(query), args..., + ) + require.NoError(t, err) +} + +func (h *managerStoreHarness) queryRow(t *testing.T, query string, + args ...any) *sql.Row { + + t.Helper() + + return h.conn.QueryRowContext( + context.Background(), h.bind(query), args..., + ) +} + +func (h *managerStoreHarness) bind(query string) string { + if !h.postgres { + return query + } + + var builder strings.Builder + + parameter := 1 + for _, char := range query { + if char != '?' { + builder.WriteRune(char) + + continue + } + + builder.WriteString(fmt.Sprintf("$%d", parameter)) + parameter++ + } + + return builder.String() +} + +func testBlock(height int32) waddrmgr.BlockStamp { + var hash chainhash.Hash + + hash[0] = 0xff + binary.BigEndian.PutUint32(hash[1:5], uint32(height)) + + return waddrmgr.BlockStamp{ + Height: height, + Hash: hash, + Timestamp: time.Unix(1000+int64(height), 0), + } +} + +func testHash(value byte) chainhash.Hash { + var hash chainhash.Hash + for i := range hash { + hash[i] = value + } + + return hash +} diff --git a/wallet/internal/db/itest/pg_test.go b/wallet/internal/db/itest/pg_test.go new file mode 100644 index 0000000000..cd08f11771 --- /dev/null +++ b/wallet/internal/db/itest/pg_test.go @@ -0,0 +1,60 @@ +//go:build test_db_postgres + +package itest + +import ( + "context" + "testing" + "time" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + dbpg "github.com/btcsuite/btcwallet/wallet/internal/db/pg" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +// TestPostgresManagerStore runs the manager transaction conformance suite +// against PostgreSQL. +// +//nolint:tparallel // The ordered conformance cases share one database. +func TestPostgresManagerStore(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + container, err := postgres.Run( + ctx, "postgres:18-alpine", + postgres.WithDatabase("btcwallet"), + postgres.WithUsername("postgres"), + postgres.WithPassword("postgres"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2).WithStartupTimeout(2*time.Minute), + ), + ) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, container.Terminate(context.Background())) + }) + + dsn, err := container.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + conn, err := pg.Open(ctx, pg.Config{DSN: dsn}) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, conn.Close()) + }) + require.NoError(t, pg.ApplyMigrations(conn)) + + testManagerStore(t, &managerStoreHarness{ + conn: conn, + postgres: true, + newStore: func(walletID int64) db.Store { + return dbpg.NewStore(conn, walletID) + }, + }) +} diff --git a/wallet/internal/db/itest/sqlite_test.go b/wallet/internal/db/itest/sqlite_test.go new file mode 100644 index 0000000000..e0f8a8de6a --- /dev/null +++ b/wallet/internal/db/itest/sqlite_test.go @@ -0,0 +1,36 @@ +package itest + +import ( + "context" + "path/filepath" + "testing" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + dbsqlite "github.com/btcsuite/btcwallet/wallet/internal/db/sqlite" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite" + "github.com/stretchr/testify/require" +) + +// TestSQLiteManagerStore runs the manager transaction conformance suite +// against SQLite. +// +//nolint:tparallel // The ordered conformance cases share one database. +func TestSQLiteManagerStore(t *testing.T) { + t.Parallel() + + conn, err := sqlite.Open(context.Background(), sqlite.Config{ + DBPath: filepath.Join(t.TempDir(), "wallet.db"), + }) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, conn.Close()) + }) + require.NoError(t, sqlite.ApplyMigrations(conn)) + + testManagerStore(t, &managerStoreHarness{ + conn: conn, + newStore: func(walletID int64) db.Store { + return dbsqlite.NewStore(conn, walletID) + }, + }) +}