Skip to content

Commit c2bd4b3

Browse files
authored
Merge pull request #1114 from mohamedawnallah/fix-gettransaction-confirmations
wallet: resolve root cause of broken zero-conf and inflated confirmations in `GetTransaction` API
2 parents 9be80d3 + a04884f commit c2bd4b3

File tree

4 files changed

+253
-33
lines changed

4 files changed

+253
-33
lines changed

wallet/createtx.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -374,12 +374,12 @@ func (w *Wallet) findEligibleOutputs(dbtx walletdb.ReadTx,
374374
// Only include this output if it meets the required number of
375375
// confirmations. Coinbase transactions must have reached
376376
// maturity before their outputs may be spent.
377-
if !confirmed(minconf, output.Height, bs.Height) {
377+
if !hasMinConfs(minconf, output.Height, bs.Height) {
378378
continue
379379
}
380380
if output.FromCoinBase {
381381
target := int32(w.chainParams.CoinbaseMaturity)
382-
if !confirmed(target, output.Height, bs.Height) {
382+
if !hasMinConfs(target, output.Height, bs.Height) {
383383
continue
384384
}
385385
}

wallet/utxos.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ type OutputSelectionPolicy struct {
3232
RequiredConfirmations int32
3333
}
3434

35-
func (p *OutputSelectionPolicy) meetsRequiredConfs(txHeight, curHeight int32) bool {
36-
return confirmed(p.RequiredConfirmations, txHeight, curHeight)
35+
func (p *OutputSelectionPolicy) meetsRequiredConfs(txHeight,
36+
curHeight int32) bool {
37+
38+
return hasMinConfs(p.RequiredConfirmations, txHeight, curHeight)
3739
}
3840

3941
// UnspentOutputs fetches all unspent outputs from the wallet that match rules

wallet/wallet.go

Lines changed: 69 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1716,10 +1716,16 @@ func (w *Wallet) CalculateAccountBalances(account uint32, confirms int32) (Balan
17161716
}
17171717

17181718
bals.Total += output.Amount
1719-
if output.FromCoinBase && !confirmed(int32(w.chainParams.CoinbaseMaturity),
1720-
output.Height, syncBlock.Height) {
1719+
if output.FromCoinBase && !hasMinConfs(
1720+
int32(w.chainParams.CoinbaseMaturity),
1721+
output.Height, syncBlock.Height,
1722+
) {
1723+
17211724
bals.ImmatureReward += output.Amount
1722-
} else if confirmed(confirms, output.Height, syncBlock.Height) {
1725+
} else if hasMinConfs(
1726+
confirms, output.Height, syncBlock.Height,
1727+
) {
1728+
17231729
bals.Spendable += output.Amount
17241730
}
17251731
}
@@ -2120,8 +2126,11 @@ func (c CreditCategory) String() string {
21202126
// this package at a later time.
21212127
func RecvCategory(details *wtxmgr.TxDetails, syncHeight int32, net *chaincfg.Params) CreditCategory {
21222128
if blockchain.IsCoinBaseTx(&details.MsgTx) {
2123-
if confirmed(int32(net.CoinbaseMaturity), details.Block.Height,
2124-
syncHeight) {
2129+
if hasMinConfs(
2130+
int32(net.CoinbaseMaturity), details.Block.Height,
2131+
syncHeight,
2132+
) {
2133+
21252134
return CreditGenerate
21262135
}
21272136
return CreditImmature
@@ -2146,7 +2155,9 @@ func listTransactions(tx walletdb.ReadTx, details *wtxmgr.TxDetails, addrMgr *wa
21462155
if details.Block.Height != -1 {
21472156
blockHashStr = details.Block.Hash.String()
21482157
blockTime = details.Block.Time.Unix()
2149-
confirmations = int64(confirms(details.Block.Height, syncHeight))
2158+
confirmations = int64(
2159+
calcConf(details.Block.Height, syncHeight),
2160+
)
21502161
}
21512162

21522163
results := []btcjson.ListTransactionsResult{}
@@ -2596,15 +2607,24 @@ func (w *Wallet) GetTransaction(txHash chainhash.Hash) (*GetTransactionResult,
25962607

25972608
res = GetTransactionResult{
25982609
Summary: makeTxSummary(dbtx, w, txDetail),
2599-
Timestamp: txDetail.Block.Time.Unix(),
2600-
Confirmations: txDetail.Block.Height,
2610+
BlockHash: nil,
2611+
Height: -1,
2612+
Confirmations: 0,
2613+
Timestamp: 0,
26012614
}
26022615

26032616
// If it is a confirmed transaction we set the corresponding
2604-
// block height and hash.
2617+
// block height, timestamp, hash, and confirmations.
26052618
if txDetail.Block.Height != -1 {
26062619
res.Height = txDetail.Block.Height
2620+
res.Timestamp = txDetail.Block.Time.Unix()
26072621
res.BlockHash = &txDetail.Block.Hash
2622+
2623+
bestBlock := w.SyncedTo()
2624+
blockHeight := txDetail.Block.Height
2625+
res.Confirmations = calcConf(
2626+
blockHeight, bestBlock.Height,
2627+
)
26082628
}
26092629

26102630
return nil
@@ -2750,11 +2770,18 @@ func (w *Wallet) AccountBalances(scope waddrmgr.KeyScope,
27502770
}
27512771
for i := range unspentOutputs {
27522772
output := &unspentOutputs[i]
2753-
if !confirmed(requiredConfs, output.Height, syncBlock.Height) {
2773+
if !hasMinConfs(
2774+
requiredConfs, output.Height, syncBlock.Height,
2775+
) {
2776+
27542777
continue
27552778
}
2756-
if output.FromCoinBase && !confirmed(int32(w.ChainParams().CoinbaseMaturity),
2757-
output.Height, syncBlock.Height) {
2779+
2780+
if output.FromCoinBase && !hasMinConfs(
2781+
int32(w.ChainParams().CoinbaseMaturity),
2782+
output.Height, syncBlock.Height,
2783+
) {
2784+
27582785
continue
27592786
}
27602787
_, addrs, _, err := txscript.ExtractPkScriptAddrs(output.PkScript, w.chainParams)
@@ -2845,17 +2872,20 @@ func (w *Wallet) ListUnspent(minconf, maxconf int32,
28452872
for i := range unspent {
28462873
output := unspent[i]
28472874

2848-
// Outputs with fewer confirmations than the minimum or more
2849-
// confs than the maximum are excluded.
2850-
confs := confirms(output.Height, syncBlock.Height)
2875+
// Outputs with fewer confirmations than the minimum or
2876+
// more confs than the maximum are excluded.
2877+
confs := calcConf(output.Height, syncBlock.Height)
28512878
if confs < minconf || confs > maxconf {
28522879
continue
28532880
}
28542881

28552882
// Only mature coinbase outputs are included.
28562883
if output.FromCoinBase {
28572884
target := int32(w.ChainParams().CoinbaseMaturity)
2858-
if !confirmed(target, output.Height, syncBlock.Height) {
2885+
if !hasMinConfs(
2886+
target, output.Height, syncBlock.Height,
2887+
) {
2888+
28592889
continue
28602890
}
28612891
}
@@ -3330,19 +3360,27 @@ func (w *Wallet) newChangeAddress(addrmgrNs walletdb.ReadWriteBucket,
33303360
return addrs[0].Address(), nil
33313361
}
33323362

3333-
// confirmed checks whether a transaction at height txHeight has met minconf
3334-
// confirmations for a blockchain at height curHeight.
3335-
func confirmed(minconf, txHeight, curHeight int32) bool {
3336-
return confirms(txHeight, curHeight) >= minconf
3363+
// hasMinConfs returns whether a transaction has met at least minConf
3364+
// confirmations at the current block height.
3365+
func hasMinConfs(minConf, txHeight, curHeight int32) bool {
3366+
return calcConf(txHeight, curHeight) >= minConf
33373367
}
33383368

3339-
// confirms returns the number of confirmations for a transaction in a block at
3340-
// height txHeight (or -1 for an unconfirmed tx) given the chain height
3341-
// curHeight.
3342-
func confirms(txHeight, curHeight int32) int32 {
3369+
// calcConf returns the number of confirmations for a transaction given its
3370+
// containing block height and the current best block height. Unconfirmed
3371+
// transactions have a height of -1 and are considered to have 0 confirmations.
3372+
func calcConf(txHeight, curHeight int32) int32 {
33433373
switch {
3344-
case txHeight == -1, txHeight > curHeight:
3374+
// Unconfirmed transactions have 0 confirmations.
3375+
case txHeight == -1:
33453376
return 0
3377+
3378+
// A transaction in a block after the current best block is considered
3379+
// unconfirmed. This can happen during a chain reorg.
3380+
case txHeight > curHeight:
3381+
return 0
3382+
3383+
// Confirmed transactions have at least one confirmation.
33463384
default:
33473385
return curHeight - txHeight + 1
33483386
}
@@ -3414,8 +3452,12 @@ func (w *Wallet) TotalReceivedForAccounts(scope waddrmgr.KeyScope,
34143452
}
34153453
res := &results[acctIndex]
34163454
res.TotalReceived += cred.Amount
3417-
res.LastConfirmation = confirms(
3418-
detail.Block.Height, syncBlock.Height)
3455+
3456+
confs := calcConf(
3457+
detail.Block.Height,
3458+
syncBlock.Height,
3459+
)
3460+
res.LastConfirmation = confs
34193461
}
34203462
}
34213463
}

wallet/wallet_test.go

Lines changed: 178 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,9 @@ func TestGetTransaction(t *testing.T) {
247247
expectedErr error
248248
}{
249249
{
250-
name: "existing unmined transaction",
251-
txid: *TstTxHash,
250+
name: "existing unmined transaction",
251+
txid: *TstTxHash,
252+
expectedHeight: -1,
252253
// We write txdetail for the tx to disk.
253254
f: func(s *wtxmgr.Store, ns walletdb.ReadWriteBucket) (
254255
*wtxmgr.Store, error) {
@@ -313,6 +314,181 @@ func TestGetTransaction(t *testing.T) {
313314
}
314315
}
315316

317+
// TestGetTransactionConfirmations tests that GetTransaction correctly
318+
// calculates confirmations for both confirmed and unconfirmed transactions.
319+
// This is a regression test for a bug where confirmations were set to the
320+
// block height instead of being calculated as currentHeight - blockHeight + 1.
321+
//
322+
// The bug had several negative impacts:
323+
// - Unconfirmed transactions showed -1 confirmations instead of 0, breaking
324+
// zero-conf (accepting transactions before block inclusion)
325+
// - Confirmed transactions showed block height instead of actual confirmation
326+
// count
327+
// - LND and other consumers would make incorrect decisions based on wrong
328+
// counts
329+
func TestGetTransactionConfirmations(t *testing.T) {
330+
t.Parallel()
331+
332+
rec, err := wtxmgr.NewTxRecord(TstSerializedTx, time.Now())
333+
require.NoError(t, err)
334+
335+
tests := []struct {
336+
name string
337+
338+
// Block height where transaction is mined (-1 for unmined).
339+
txBlockHeight int32
340+
341+
// Current wallet sync height.
342+
currentHeight int32
343+
344+
// Expected confirmations.
345+
expectedConfirmations int32
346+
347+
// Expected height in result.
348+
expectedHeight int32
349+
350+
// Whether to check for non-zero timestamp.
351+
expectTimestamp bool
352+
}{
353+
{
354+
name: "unconfirmed tx",
355+
txBlockHeight: -1,
356+
currentHeight: 100,
357+
expectedConfirmations: 0,
358+
expectedHeight: -1,
359+
expectTimestamp: false,
360+
},
361+
{
362+
name: "tx with 1 confirmation",
363+
txBlockHeight: 100,
364+
currentHeight: 100,
365+
expectedConfirmations: 1,
366+
expectedHeight: 100,
367+
expectTimestamp: true,
368+
},
369+
{
370+
name: "tx with 3 confirmations",
371+
txBlockHeight: 8,
372+
currentHeight: 10,
373+
expectedConfirmations: 3,
374+
expectedHeight: 8,
375+
expectTimestamp: true,
376+
},
377+
{
378+
name: "old tx with many confirmations",
379+
txBlockHeight: 1,
380+
currentHeight: 1000,
381+
expectedConfirmations: 1000,
382+
expectedHeight: 1,
383+
expectTimestamp: true,
384+
},
385+
{
386+
name: "tx in future block",
387+
txBlockHeight: 105,
388+
currentHeight: 100,
389+
expectedConfirmations: 0,
390+
expectedHeight: 105,
391+
expectTimestamp: true,
392+
},
393+
}
394+
395+
for _, tt := range tests {
396+
t.Run(tt.name, func(t *testing.T) {
397+
t.Parallel()
398+
w, cleanup := testWallet(t)
399+
t.Cleanup(cleanup)
400+
401+
// Set the wallet's synced height.
402+
err := walletdb.Update(
403+
w.db, func(tx walletdb.ReadWriteTx) error {
404+
addrmgrNs := tx.ReadWriteBucket(
405+
waddrmgrNamespaceKey,
406+
)
407+
bs := &waddrmgr.BlockStamp{
408+
Height: tt.currentHeight,
409+
Hash: chainhash.Hash{},
410+
}
411+
412+
return w.Manager.SetSyncedTo(
413+
addrmgrNs, bs,
414+
)
415+
},
416+
)
417+
require.NoError(t, err)
418+
419+
// Insert transaction into wallet.
420+
err = walletdb.Update(
421+
w.db, func(tx walletdb.ReadWriteTx) error {
422+
ns := tx.ReadWriteBucket(
423+
wtxmgrNamespaceKey,
424+
)
425+
426+
// Create block metadata if transaction
427+
// is mined.
428+
var blockMeta *wtxmgr.BlockMeta
429+
if tt.txBlockHeight != -1 {
430+
hash := chainhash.Hash{}
431+
height := tt.txBlockHeight
432+
block := wtxmgr.Block{
433+
Hash: hash,
434+
Height: height,
435+
}
436+
blockMeta = &wtxmgr.BlockMeta{
437+
Block: block,
438+
Time: time.Now(),
439+
}
440+
}
441+
442+
return w.TxStore.InsertTx(
443+
ns, rec, blockMeta,
444+
)
445+
},
446+
)
447+
require.NoError(t, err)
448+
449+
result, err := w.GetTransaction(*TstTxHash)
450+
require.NoError(t, err)
451+
452+
require.Equal(
453+
t, tt.expectedConfirmations,
454+
result.Confirmations,
455+
)
456+
457+
require.Equal(t, tt.expectedHeight, result.Height)
458+
459+
if tt.expectTimestamp {
460+
require.NotZero(t, result.Timestamp)
461+
} else {
462+
require.Zero(t, result.Timestamp)
463+
}
464+
465+
// Additional checks for unconfirmed transactions.
466+
if tt.txBlockHeight == -1 {
467+
require.Nil(t, result.BlockHash)
468+
require.Equal(t, int32(0), result.Confirmations)
469+
} else {
470+
require.NotNil(t, result.BlockHash)
471+
// Only expect positive confirmations when tx is
472+
// not in a future block.
473+
if tt.txBlockHeight <= tt.currentHeight {
474+
require.Positive(
475+
t, result.Confirmations,
476+
)
477+
} else {
478+
// Confirmed txns in future blocks for
479+
// example due to reorg should be
480+
// treated as unconfirmed and have 0
481+
// confirmations.
482+
require.Equal(
483+
t, int32(0),
484+
result.Confirmations,
485+
)
486+
}
487+
}
488+
})
489+
}
490+
}
491+
316492
// TestDuplicateAddressDerivation tests that duplicate addresses are not
317493
// derived when multiple goroutines are concurrently requesting new addresses.
318494
func TestDuplicateAddressDerivation(t *testing.T) {

0 commit comments

Comments
 (0)