Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 216 additions & 0 deletions wallet/internal/sql/pg/legacy_schema_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
//go:build test_db_postgres

package pg

import (
"context"
"crypto/sha256"
"database/sql"
"testing"
"time"

"github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)

// TestLegacySchemaQueries verifies the legacy ciphertext and sticky address
// state through the generated PostgreSQL queries.
func TestLegacySchemaQueries(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)
db, err := Open(ctx, Config{DSN: dsn})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, db.Close())
})
require.NoError(t, ApplyMigrations(db))
requireNoPostgresPlaintextColumns(t, db)

queries := sqlc.New(db)
genesisHash := pgBytesOf(0x01, 32)
birthdayHash := pgBytesOf(0x02, 32)
require.NoError(t, queries.InsertBlock(ctx, sqlc.InsertBlockParams{
BlockHeight: 0,
HeaderHash: genesisHash,
BlockTimestamp: 1,
}))
require.NoError(t, queries.InsertBlock(ctx, sqlc.InsertBlockParams{
BlockHeight: 100,
HeaderHash: birthdayHash,
BlockTimestamp: 2,
}))

walletID, err := queries.CreateWallet(ctx, sqlc.CreateWalletParams{
WalletName: "watch-only",
ManagerVersion: 8,
ManagerCreatedAt: 123,
IsWatchOnly: true,
MasterPubParams: []byte("encrypted-master-public-params"),
EncryptedCryptoPubKey: []byte("encrypted-crypto-public-key"),
})
require.NoError(t, err)
wallet, err := queries.GetWalletByName(ctx, "watch-only")
require.NoError(t, err)
require.Equal(t, int64(123), wallet.ManagerCreatedAt)
require.Nil(t, wallet.MasterPrivParams)
require.Nil(t, wallet.EncryptedCryptoPrivKey)
require.Equal(t, []byte("encrypted-crypto-public-key"),
wallet.EncryptedCryptoPubKey)

require.NoError(t, queries.PutWalletSyncState(
ctx, sqlc.PutWalletSyncStateParams{
WalletID: walletID,
StartBlockHeight: 0,
SyncedBlockHeight: 100,
BirthdayTimestamp: 1234,
BirthdayBlockHeight: sql.NullInt32{Int32: 100, Valid: true},
BirthdayBlockVerified: true,
},
))
syncState, err := queries.GetWalletSyncState(ctx, walletID)
require.NoError(t, err)
require.Equal(t, genesisHash, syncState.StartBlockHash)
require.Equal(t, birthdayHash, syncState.BirthdayBlockHash)

scopeID, err := queries.CreateKeyScope(ctx, sqlc.CreateKeyScopeParams{
WalletID: walletID,
Purpose: 84,
CoinType: 0,
ExternalAddrType: 4,
InternalAddrType: 4,
})
require.NoError(t, err)
rows, err := queries.UpdateLastAccountNumber(
ctx, sqlc.UpdateLastAccountNumberParams{
LastAccountNumber: sql.NullInt64{Int64: 7, Valid: true},
ID: scopeID,
WalletID: walletID,
},
)
require.NoError(t, err)
require.Equal(t, int64(1), rows)
scope, err := queries.GetKeyScope(ctx, sqlc.GetKeyScopeParams{
WalletID: walletID,
Purpose: 84,
CoinType: 0,
})
require.NoError(t, err)
require.Equal(t, int64(7), scope.LastAccountNumber.Int64)
err = queries.CreateAccount(ctx, sqlc.CreateAccountParams{
WalletID: walletID,
ScopeID: scopeID,
AccountNumber: 7,
AccountType: 1,
AccountName: "imported",
EncryptedPubKey: []byte("encrypted-account-public-key"),
MasterKeyFingerprint: sql.NullInt64{Int64: 42, Valid: true},
ExternalAddrType: sql.NullInt16{Int16: 3, Valid: true},
InternalAddrType: sql.NullInt16{Int16: 4, Valid: true},
})
require.NoError(t, err)

watchOnlyScriptHash := sha256.Sum256([]byte("watch-only-script-address-id"))
require.NoError(t, queries.CreateAddress(ctx, sqlc.CreateAddressParams{
WalletID: walletID,
ScopeID: scopeID,
AddressHash: watchOnlyScriptHash[:],
AccountNumber: 7,
AddressType: 2,
AddedAt: 12,
SyncStatus: 2,
EncryptedHash: []byte("encrypted-watch-only-script-hash"),
}))

addressHash := sha256.Sum256([]byte("imported-address-id"))
require.NoError(t, queries.CreateAddress(ctx, sqlc.CreateAddressParams{
WalletID: walletID,
ScopeID: scopeID,
AddressHash: addressHash[:],
AccountNumber: 7,
AddressType: 1,
AddedAt: 11,
SyncStatus: 2,
EncryptedPubKey: []byte("encrypted-imported-public-key"),
EncryptedPrivKey: []byte{},
}))
rows, err = queries.MarkAddressUsed(ctx, sqlc.MarkAddressUsedParams{
WalletID: walletID,
ScopeID: scopeID,
AddressHash: addressHash[:],
})
require.NoError(t, err)
require.Equal(t, int64(1), rows)
address, err := queries.GetAddress(ctx, sqlc.GetAddressParams{
WalletID: walletID,
ScopeID: scopeID,
AddressHash: addressHash[:],
})
require.NoError(t, err)
require.True(t, address.Used)
require.Equal(t, []byte("encrypted-imported-public-key"),
address.EncryptedPubKey)

_, err = db.ExecContext(ctx, `
UPDATE addresses SET used = FALSE
WHERE wallet_id = $1 AND scope_id = $2 AND address_hash = $3
`, walletID, scopeID, addressHash[:])
require.ErrorContains(t, err, "address used state cannot be cleared")
}

func requireNoPostgresPlaintextColumns(t *testing.T, db *sql.DB) {
t.Helper()

columns := map[string][]string{
"wallets": {"master_hd_pub_key"},
"key_scopes": {"coin_pub_key", "coin_priv_key"},
"accounts": {"public_key", "private_key"},
"addresses": {"address_id", "script_pub_key", "pub_key", "script"},
}
for table, tableColumns := range columns {
for _, column := range tableColumns {
var exists bool
err := db.QueryRow(`
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
AND column_name = $2
)
`, table, column).Scan(&exists)
require.NoError(t, err)
require.False(t, exists, "%s.%s", table, column)
}
}
}

func pgBytesOf(value byte, count int) []byte {
b := make([]byte, count)
for i := range b {
b[i] = value
}

return b
}
2 changes: 2 additions & 0 deletions wallet/internal/sql/pg/migrations/000002_wallets.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS wallet_sync_states;
DROP TABLE IF EXISTS wallets;
38 changes: 38 additions & 0 deletions wallet/internal/sql/pg/migrations/000002_wallets.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
-- The wallets table preserves the legacy waddrmgr encryption hierarchy.
CREATE TABLE wallets (
id BIGSERIAL PRIMARY KEY,
wallet_name TEXT NOT NULL UNIQUE,
manager_version BIGINT NOT NULL
CHECK (manager_version BETWEEN 0 AND 4294967295),
manager_created_at BIGINT NOT NULL CHECK (manager_created_at >= 0),
is_watch_only BOOLEAN NOT NULL,
master_pub_params BYTEA NOT NULL,
master_priv_params BYTEA,
encrypted_crypto_pub_key BYTEA NOT NULL,
encrypted_crypto_priv_key BYTEA,
encrypted_crypto_script_key BYTEA,
encrypted_master_hd_pub_key BYTEA,
encrypted_master_hd_priv_key BYTEA
);

-- The wallet sync state mirrors the block stamps and birthday metadata stored
-- by the legacy address manager.
CREATE TABLE wallet_sync_states (
wallet_id BIGINT PRIMARY KEY,
start_block_height INTEGER NOT NULL,
synced_block_height INTEGER NOT NULL,
birthday_timestamp BIGINT NOT NULL CHECK (birthday_timestamp >= 0),
birthday_block_height INTEGER,
birthday_block_verified BOOLEAN NOT NULL DEFAULT FALSE,
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
)
);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS address_types;
16 changes: 16 additions & 0 deletions wallet/internal/sql/pg/migrations/000003_address_types.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Address types are the stable public waddrmgr.AddressType values used by key
-- scope address schemas.
CREATE TABLE address_types (
id SMALLINT PRIMARY KEY CHECK (id BETWEEN 0 AND 7),
type_name TEXT NOT NULL UNIQUE
);

INSERT INTO address_types (id, type_name) VALUES
(0, 'pubkey-hash'),
(1, 'script'),
(2, 'raw-pubkey'),
(3, 'nested-witness-pubkey'),
(4, 'witness-pubkey'),
(5, 'witness-script'),
(6, 'taproot-pubkey'),
(7, 'taproot-script');
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS key_scopes;
21 changes: 21 additions & 0 deletions wallet/internal/sql/pg/migrations/000004_key_scopes.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Key scopes preserve encrypted coin keys and the address schema associated
-- with each purpose and coin tuple.
CREATE TABLE key_scopes (
id BIGSERIAL PRIMARY KEY,
wallet_id BIGINT NOT NULL,
purpose BIGINT NOT NULL CHECK (purpose BETWEEN 0 AND 4294967295),
coin_type BIGINT NOT NULL CHECK (coin_type BETWEEN 0 AND 4294967295),
encrypted_coin_pub_key BYTEA,
encrypted_coin_priv_key BYTEA,
last_account_number BIGINT
CHECK (last_account_number BETWEEN 0 AND 4294967295),
external_addr_type SMALLINT NOT NULL,
internal_addr_type SMALLINT NOT NULL,
FOREIGN KEY (wallet_id) REFERENCES wallets (id) ON DELETE RESTRICT,
FOREIGN KEY (external_addr_type) REFERENCES address_types (id)
ON DELETE RESTRICT,
FOREIGN KEY (internal_addr_type) REFERENCES address_types (id)
ON DELETE RESTRICT,
UNIQUE (wallet_id, purpose, coin_type),
UNIQUE (id, wallet_id)
);
1 change: 1 addition & 0 deletions wallet/internal/sql/pg/migrations/000005_accounts.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS accounts;
47 changes: 47 additions & 0 deletions wallet/internal/sql/pg/migrations/000005_accounts.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
-- Accounts preserve the two stable legacy account encodings. Every account,
-- including an imported watch-only account, retains its uint32 account number.
CREATE TABLE accounts (
wallet_id BIGINT NOT NULL,
scope_id BIGINT NOT NULL,
account_number BIGINT NOT NULL
CHECK (account_number BETWEEN 0 AND 4294967295),
account_type SMALLINT NOT NULL CHECK (account_type IN (0, 1)),
account_name TEXT NOT NULL,
encrypted_pub_key BYTEA NOT NULL,
encrypted_priv_key BYTEA,
master_key_fingerprint BIGINT
CHECK (
master_key_fingerprint IS NULL
OR master_key_fingerprint BETWEEN 0 AND 4294967295
),
next_external_index BIGINT NOT NULL DEFAULT 0
CHECK (next_external_index BETWEEN 0 AND 4294967295),
next_internal_index BIGINT NOT NULL DEFAULT 0
CHECK (next_internal_index BETWEEN 0 AND 4294967295),
external_addr_type SMALLINT,
internal_addr_type SMALLINT,
FOREIGN KEY (scope_id, wallet_id) REFERENCES key_scopes (id, wallet_id)
ON DELETE RESTRICT,
FOREIGN KEY (external_addr_type) REFERENCES address_types (id)
ON DELETE RESTRICT,
FOREIGN KEY (internal_addr_type) REFERENCES address_types (id)
ON DELETE RESTRICT,
PRIMARY KEY (scope_id, account_number),
UNIQUE (scope_id, account_name),
UNIQUE (scope_id, account_number, wallet_id),
CHECK (
(external_addr_type IS NULL AND internal_addr_type IS NULL)
OR (external_addr_type IS NOT NULL AND internal_addr_type IS NOT NULL)
),
CHECK (
(account_type = 0
AND master_key_fingerprint IS NULL
AND external_addr_type IS NULL
AND internal_addr_type IS NULL)
OR (
account_type = 1
AND encrypted_priv_key IS NULL
AND master_key_fingerprint IS NOT NULL
)
)
);
3 changes: 3 additions & 0 deletions wallet/internal/sql/pg/migrations/000006_addresses.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
DROP TRIGGER IF EXISTS trg_addresses_used_is_monotonic ON addresses;
DROP FUNCTION IF EXISTS assert_address_used_is_monotonic();
DROP TABLE IF EXISTS addresses;
Loading
Loading