|
1 | 1 | package kvdb |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "math" |
4 | 5 | "testing" |
5 | 6 | "time" |
6 | 7 |
|
@@ -323,6 +324,86 @@ func TestApplyTxBatchCreditNilFallback(t *testing.T) { |
323 | 324 | addrStore.AssertNotCalled(t, "MarkUsed", mock.Anything, mock.Anything) |
324 | 325 | } |
325 | 326 |
|
| 327 | +// TestApplyScanBatchRecordsTxAndSyncedBlocks verifies that kvdb.Store applies |
| 328 | +// scan-discovered transactions and connected sync blocks in one write batch. |
| 329 | +func TestApplyScanBatchRecordsTxAndSyncedBlocks(t *testing.T) { |
| 330 | + t.Parallel() |
| 331 | + |
| 332 | + dbConn, cleanup := newTestDB(t) |
| 333 | + t.Cleanup(cleanup) |
| 334 | + |
| 335 | + newAddrmgrNamespace(t, dbConn) |
| 336 | + txStore := newTxStore(t, dbConn) |
| 337 | + |
| 338 | + addr, script := newTestAddressScript(t) |
| 339 | + managedAddr := &bwmock.ManagedAddress{} |
| 340 | + managedAddr.On("Internal").Return(false).Maybe() |
| 341 | + managedAddr.On("Address").Return(addr).Maybe() |
| 342 | + managedAddr.On("AddrType").Return(waddrmgr.WitnessPubKey).Maybe() |
| 343 | + managedAddr.On("Imported").Return(false).Maybe() |
| 344 | + managedAddr.On("InternalAccount").Return(uint32(0)).Maybe() |
| 345 | + managedAddr.On("Compressed").Return(true).Maybe() |
| 346 | + managedAddr.On("AddrHash").Return([]byte(nil)).Maybe() |
| 347 | + managedAddr.On("Used", mock.Anything).Return(false).Maybe() |
| 348 | + |
| 349 | + addrStore := &bwmock.AddrStore{} |
| 350 | + addrStore.On("ChainParams").Return(&chaincfg.RegressionNetParams) |
| 351 | + addrStore.On("Address", mock.Anything, mock.Anything). |
| 352 | + Return(managedAddr, nil) |
| 353 | + addrStore.On("MarkUsed", mock.Anything, mock.Anything).Return(nil) |
| 354 | + addrStore.On("SetSyncedTo", mock.Anything, mock.Anything).Return(nil) |
| 355 | + store := NewStore(dbConn, txStore, addrStore) |
| 356 | + |
| 357 | + txMsg := &wire.MsgTx{Version: 1} |
| 358 | + txMsg.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ |
| 359 | + Hash: chainhash.Hash{65}, |
| 360 | + }}) |
| 361 | + txMsg.AddTxOut(&wire.TxOut{Value: 11_000, PkScript: script}) |
| 362 | + |
| 363 | + syncedBlocks := []db.Block{{ |
| 364 | + Hash: chainhash.Hash{66}, |
| 365 | + Height: 145, |
| 366 | + Timestamp: time.Unix(1710003300, 0), |
| 367 | + }, { |
| 368 | + Hash: chainhash.Hash{67}, |
| 369 | + Height: 146, |
| 370 | + Timestamp: time.Unix(1710003400, 0), |
| 371 | + }} |
| 372 | + err := store.ApplyScanBatch(t.Context(), db.ScanBatchParams{ |
| 373 | + WalletID: 0, |
| 374 | + Transactions: []db.CreateTxParams{{ |
| 375 | + WalletID: 0, |
| 376 | + Tx: txMsg, |
| 377 | + Received: time.Unix(1710003350, 0), |
| 378 | + Status: db.TxStatusPublished, |
| 379 | + Credits: map[uint32]btcutil.Address{0: addr}, |
| 380 | + }}, |
| 381 | + SyncedBlocks: syncedBlocks, |
| 382 | + }) |
| 383 | + require.NoError(t, err) |
| 384 | + |
| 385 | + txid := txMsg.TxHash() |
| 386 | + err = walletdb.View(dbConn, func(tx walletdb.ReadTx) error { |
| 387 | + ns := tx.ReadBucket(wtxmgrNamespaceKey) |
| 388 | + require.NotNil(t, ns) |
| 389 | + |
| 390 | + details, err := txStore.TxDetails(ns, &txid) |
| 391 | + require.NoError(t, err) |
| 392 | + require.NotNil(t, details) |
| 393 | + require.Len(t, details.Credits, 1) |
| 394 | + |
| 395 | + return nil |
| 396 | + }) |
| 397 | + require.NoError(t, err) |
| 398 | + addrStore.AssertCalled(t, "MarkUsed", mock.Anything, mock.Anything) |
| 399 | + addrStore.AssertCalled( |
| 400 | + t, "SetSyncedTo", mock.Anything, |
| 401 | + mock.MatchedBy(func(bs *waddrmgr.BlockStamp) bool { |
| 402 | + return bs.Height == int32(146) |
| 403 | + }), |
| 404 | + ) |
| 405 | +} |
| 406 | + |
326 | 407 | // TestCreateTxCreditAddrMismatch verifies that crediting an output with an |
327 | 408 | // address that the output script does not pay to is rejected, so a caller |
328 | 409 | // cannot corrupt UTXO ownership by mislabeling a credit. |
@@ -1169,3 +1250,111 @@ func newMultisigScript(t *testing.T) (btcutil.Address, []byte) { |
1169 | 1250 |
|
1170 | 1251 | return memberAddr, script |
1171 | 1252 | } |
| 1253 | + |
| 1254 | +// TestApplyScanBatchHorizonRollbackSafety verifies that when a recovery scan |
| 1255 | +// batch fails after its horizon extension has already advanced the scoped |
| 1256 | +// manager's in-memory address state, the live manager does not keep reporting |
| 1257 | +// the unpersisted horizon advance once walletdb rolls the batch back. |
| 1258 | +func TestApplyScanBatchHorizonRollbackSafety(t *testing.T) { |
| 1259 | + t.Parallel() |
| 1260 | + |
| 1261 | + dbConn, cleanup := newTestDB(t) |
| 1262 | + t.Cleanup(cleanup) |
| 1263 | + |
| 1264 | + // newSpendableAddrMgr creates the addrmgr namespace and a real manager |
| 1265 | + // with the default account already present on every default scope. |
| 1266 | + addrStore := newSpendableAddrMgr(t, dbConn) |
| 1267 | + t.Cleanup(func() { |
| 1268 | + _ = addrStore.Lock() |
| 1269 | + addrStore.Close() |
| 1270 | + }) |
| 1271 | + txStore := newTxStore(t, dbConn) |
| 1272 | + store := NewStore(dbConn, txStore, addrStore) |
| 1273 | + |
| 1274 | + err := walletdb.View(dbConn, func(tx walletdb.ReadTx) error { |
| 1275 | + ns := tx.ReadBucket(waddrmgr.NamespaceKey) |
| 1276 | + |
| 1277 | + return addrStore.Unlock(ns, testPrivPass) |
| 1278 | + }) |
| 1279 | + require.NoError(t, err) |
| 1280 | + |
| 1281 | + const account = waddrmgr.DefaultAccountNum |
| 1282 | + |
| 1283 | + scope := waddrmgr.KeyScopeBIP0084 |
| 1284 | + scopedMgr, err := addrStore.FetchScopedKeyManager(scope) |
| 1285 | + require.NoError(t, err) |
| 1286 | + |
| 1287 | + // The fresh default account has not derived any external addresses yet. |
| 1288 | + require.Zero(t, externalKeyCount(t, dbConn, scopedMgr, account)) |
| 1289 | + |
| 1290 | + const horizonIndex = 9 |
| 1291 | + |
| 1292 | + horizon := db.ScanHorizon{ |
| 1293 | + Scope: db.KeyScope(scope), |
| 1294 | + Account: account, |
| 1295 | + Branch: waddrmgr.ExternalBranch, |
| 1296 | + Index: horizonIndex, |
| 1297 | + } |
| 1298 | + |
| 1299 | + // A synced block whose height overflows the legacy int32 height domain |
| 1300 | + // fails the synced-block step, which runs after the horizon extension |
| 1301 | + // has already mutated the manager's in-memory address state. |
| 1302 | + overflowBlock := db.Block{ |
| 1303 | + Hash: chainhash.Hash{0xAB}, |
| 1304 | + Height: uint32(math.MaxInt32) + 1, |
| 1305 | + Timestamp: time.Unix(1_700_000_000, 0), |
| 1306 | + } |
| 1307 | + |
| 1308 | + err = store.ApplyScanBatch(t.Context(), db.ScanBatchParams{ |
| 1309 | + WalletID: 0, |
| 1310 | + Horizons: []db.ScanHorizon{horizon}, |
| 1311 | + SyncedBlocks: []db.Block{overflowBlock}, |
| 1312 | + }) |
| 1313 | + require.Error(t, err) |
| 1314 | + |
| 1315 | + // The live manager must not observe the rolled-back horizon advance, and |
| 1316 | + // the persisted addrmgr bucket must not record any derived external |
| 1317 | + // addresses either. |
| 1318 | + require.Zero(t, externalKeyCount(t, dbConn, scopedMgr, account)) |
| 1319 | + |
| 1320 | + // As a positive control, the same horizon extension applied without the |
| 1321 | + // failing block must succeed and advance the external next index past the |
| 1322 | + // recovered child, proving the rollback path above did not simply skip the |
| 1323 | + // extension and that the invalidated cache reloads cleanly. |
| 1324 | + err = store.ApplyScanBatch(t.Context(), db.ScanBatchParams{ |
| 1325 | + WalletID: 0, |
| 1326 | + Horizons: []db.ScanHorizon{horizon}, |
| 1327 | + }) |
| 1328 | + require.NoError(t, err) |
| 1329 | + require.Equal( |
| 1330 | + t, uint32(horizonIndex+1), |
| 1331 | + externalKeyCount(t, dbConn, scopedMgr, account), |
| 1332 | + ) |
| 1333 | +} |
| 1334 | + |
| 1335 | +// externalKeyCount reports the number of external keys the scoped manager |
| 1336 | +// considers derived for the account, read through the live manager so any |
| 1337 | +// unpersisted in-memory advance would be observable. |
| 1338 | +func externalKeyCount(t *testing.T, dbConn walletdb.DB, |
| 1339 | + scopedMgr waddrmgr.AccountStore, account uint32) uint32 { |
| 1340 | + |
| 1341 | + t.Helper() |
| 1342 | + |
| 1343 | + var count uint32 |
| 1344 | + |
| 1345 | + err := walletdb.View(dbConn, func(tx walletdb.ReadTx) error { |
| 1346 | + ns := tx.ReadBucket(waddrmgr.NamespaceKey) |
| 1347 | + |
| 1348 | + props, err := scopedMgr.AccountProperties(ns, account) |
| 1349 | + if err != nil { |
| 1350 | + return err |
| 1351 | + } |
| 1352 | + |
| 1353 | + count = props.ExternalKeyCount |
| 1354 | + |
| 1355 | + return nil |
| 1356 | + }) |
| 1357 | + require.NoError(t, err) |
| 1358 | + |
| 1359 | + return count |
| 1360 | +} |
0 commit comments