package account import ( "bytes" "context" "encoding/hex" "errors" "fmt" "reflect" "strings" "testing" "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/davecgh/go-spew/spew" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnrpc/verrpc" "github.com/lightningnetwork/lnd/lntest/wait" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/stretchr/testify/require" ) const ( timeout = 500 * time.Millisecond maxAccountValue = 2 * btcutil.SatoshiPerBitcoin bestHeight = 100 ) var ( p2wsh, _ = hex.DecodeString("00208c2865c87ffd33fc5d698c7df9cf2d0fb39d93103c637a06dea32c848ebc3e1d") p2tr, _ = hex.DecodeString("5120bb91443dd777945ef4422cffddec00d5feed2aca2562a902fbe8d2a258b337da") p2wpkh, _ = hex.DecodeString("0014ccdeffed4f9c91d5bf45c34e4b8f03a5025ec062") np2wpkh, _ = hex.DecodeString("a91458c11505b54582ab04e96d36908f85a8b689459787") // defaultFeeExpr is a fee expression noting that the minimum fee rate // should be used for a wallet output. defaultFeeExpr = OutputWithFee{ FeeRate: chainfee.FeePerKwFloor, } ) type testCase struct { name string feeExpr FeeExpr fee btcutil.Amount version Version newVersion Version expectedErr string action Action isExpirySpend bool // The following fields are only used by deposit tests. fundedOutputAmount btcutil.Amount fundingTxFee btcutil.Amount utxo *lnwallet.Utxo } func runSubTests(t *testing.T, testCases []*testCase, runTest func(t *testing.T, h *testHarness, tc *testCase)) { for _, tc := range testCases { tc := tc success := t.Run(tc.name, func(tt *testing.T) { tt.Parallel() h := newTestHarness(tt) h.start() defer h.stop() runTest(tt, h, tc) }) if !success { return } } } type testHarness struct { t *testing.T store *mockStore notifier *mockChainNotifier wallet *mockWallet auctioneer *mockAuctioneer manager Manager } func newTestHarness(t *testing.T) *testHarness { store := newMockStore() wallet := newMockWallet() notifier := newMockChainNotifier() auctioneer := newMockAuctioneer() return &testHarness{ t: t, store: store, wallet: wallet, notifier: notifier, auctioneer: auctioneer, manager: NewManager(&ManagerConfig{ Store: store, Auctioneer: auctioneer, Wallet: wallet, Signer: wallet, ChainNotifier: notifier, TxSource: wallet, TxFeeEstimator: wallet, ChainParams: &chaincfg.TestNet3Params, LndVersion: &verrpc.Version{ AppMajor: 0, AppMinor: 15, AppPatch: 1, }, }), } } func (h *testHarness) start() { require.NoError(h.t, h.manager.Start()) } func (h *testHarness) stop() { h.manager.Stop() } func (h *testHarness) assertAccountSubscribed(traderKey *btcec.PublicKey) { h.t.Helper() var rawTraderKey [33]byte copy(rawTraderKey[:], traderKey.SerializeCompressed()) h.auctioneer.mu.Lock() defer h.auctioneer.mu.Unlock() _, ok := h.auctioneer.subscribed[rawTraderKey] require.Truef(h.t, ok, "account %x not subscribed", rawTraderKey) } func (h *testHarness) assertAccountNotSubscribed(traderKey *btcec.PublicKey) { h.t.Helper() var rawTraderKey [33]byte copy(rawTraderKey[:], traderKey.SerializeCompressed()) h.auctioneer.mu.Lock() defer h.auctioneer.mu.Unlock() _, ok := h.auctioneer.subscribed[rawTraderKey] require.Falsef(h.t, ok, "account %x not subscribed", rawTraderKey) } func (h *testHarness) assertAccountExists(expected *Account) { h.t.Helper() err := wait.NoError(func() error { found, err := h.store.Account(expected.TraderKey.PubKey) if err != nil { return err } if !reflect.DeepEqual(found, expected) { return fmt.Errorf("expected account: %v\ngot: %v", spew.Sdump(expected), spew.Sdump(found)) } return nil }, 10*timeout) require.NoError(h.t, err) } func (h *testHarness) openAccount(value btcutil.Amount, expiry uint32, // nolint:unparam bestHeight uint32, version Version) *Account { // nolint:unparam h.t.Helper() // Create a new account. Its initial state should be StatePendingOpen. ctx := context.Background() account, err := h.manager.InitAccount( ctx, value, version, chainfee.FeePerKwFloor, expiry, bestHeight, ) require.NoError(h.t, err) // The same account should be found in the store. h.assertAccountExists(account) // Since the account is still pending confirmation, it should not be // subscribed to updates from the auctioneer yet. h.assertAccountNotSubscribed(account.TraderKey.PubKey) // Notify the confirmation of the account. confHeight := bestHeight + 6 h.notifier.confChan <- &chainntnfs.TxConfirmation{ BlockHeight: confHeight, } // This should prompt the account to now be in a StateOpen state and // the subscription for updates should now be realized. account.State = StateOpen account.HeightHint = confHeight h.assertAccountExists(account) h.assertAccountSubscribed(account.TraderKey.PubKey) return account } func (h *testHarness) expireAccount(account *Account) { h.t.Helper() // Notify the height at which the account expires. h.notifier.blockChan <- int32(account.Expiry) // This should prompt the account to now be in a StateExpired state. account.State = StateExpired h.assertAccountExists(account) } func (h *testHarness) closeAccount(account *Account, feeExpr FeeExpr, bestHeight uint32) { h.t.Helper() // Close the account with the auctioneer. go func() { _, err := h.manager.CloseAccount( context.Background(), account.TraderKey.PubKey, feeExpr, bestHeight, ) require.NoError(h.t, err) }() // This should prompt the account's closing transaction to be broadcast // and its state transitioned to StatePendingClosed. closeTx := h.assertSpendTxBroadcast( account, nil, nil, nil, account.Version, ) account.Value = 0 account.State = StatePendingClosed account.HeightHint = bestHeight account.LatestTx = closeTx h.assertAccountExists(account) // Notify the transaction as a spend of the account. spendHeight := bestHeight + 6 h.notifier.spendChan <- &chainntnfs.SpendDetail{ SpendingTx: closeTx, SpendingHeight: int32(spendHeight), } // This should prompt the account to now be in a StateClosed state. account.State = StateClosed account.HeightHint = spendHeight h.assertAccountExists(account) } func (h *testHarness) assertSpendTxBroadcast(accountBeforeSpend *Account, externalInputs []*lnwallet.Utxo, externalOutputs []*wire.TxOut, newValue *btcutil.Amount, newVersion Version) *wire.MsgTx { h.t.Helper() var spendTx *wire.MsgTx select { case spendTx = <-h.wallet.publishChan: case <-time.After(timeout): h.t.Fatal("expected spend transaction to be broadcast") } // The spending transaction should spend the account. foundAccountInput := false for _, txIn := range spendTx.TxIn { if txIn.PreviousOutPoint == accountBeforeSpend.OutPoint { foundAccountInput = true } } require.True(h.t, foundAccountInput, "account input in transaction") // If no outputs were provided and the account wasn't re-created, we // should expect to see a single wallet output. if len(externalOutputs) == 0 && newValue == nil { require.Len(h.t, spendTx.TxOut, 1) _, addrs, _, err := txscript.ExtractPkScriptAddrs( spendTx.TxOut[0].PkScript, &chaincfg.MainNetParams, ) require.NoError(h.t, err) require.Len(h.t, addrs, 1) addr, ok := addrs[0].(*btcutil.AddressWitnessPubKeyHash) if !ok { h.t.Fatalf("expected P2WPKH address, found %T", addr) } // Witness program of address returned by the mock // implementation of NextAddr. witnessProgram := btcutil.Hash160(testRawTraderKey) if !bytes.Equal(addr.WitnessProgram(), witnessProgram) { h.t.Fatalf("expected witness program %x, got %x", witnessProgram, addr.WitnessProgram()) } return spendTx } // Otherwise, the spending transaction should include the expected // inputs and outputs. If it recreates the account output, we should // also attempt to locate it. require.Len(h.t, spendTx.TxIn, len(externalInputs)+1) // Perhaps this is an in-flight account upgrade? upgradedAccount := accountBeforeSpend.Copy(VersionModifier(newVersion)) nextPkScript, err := upgradedAccount.NextOutputScript() require.NoError(h.t, err) outputs := externalOutputs if newValue != nil { outputs = append(outputs, &wire.TxOut{ Value: int64(*newValue), PkScript: nextPkScript, }) } require.Len(h.t, spendTx.TxOut, len(outputs)) // The input and output indices may not match due to BIP-69 sorting. nextInput: for _, input := range externalInputs { for _, txIn := range spendTx.TxIn { if txIn.PreviousOutPoint != input.OutPoint { continue } continue nextInput } h.t.Fatalf("expected input %v in spend transaction", input.OutPoint) } nextOutput: for _, output := range outputs { for _, txOut := range spendTx.TxOut { if !bytes.Equal(txOut.PkScript, output.PkScript) { continue } if txOut.Value != output.Value { h.t.Fatalf("expected value %v for output %x, "+ "got %v", output.Value, output.PkScript, txOut.Value) } continue nextOutput } h.t.Fatalf("expected output script %x in spend transaction", output.PkScript) } return spendTx } // assertAuctioneerReceived asserts that auctioneer has received the correct // information regarding an account modification. func (h *testHarness) assertAuctioneerReceived(inputs []*lnwallet.Utxo, outputs []*wire.TxOut) { h.t.Helper() h.auctioneer.mu.Lock() defer h.auctioneer.mu.Unlock() require.Len(h.t, inputs, len(h.auctioneer.inputsReceived)) for _, inp := range inputs { found := false for _, inputReceived := range h.auctioneer.inputsReceived { if inp.OutPoint == inputReceived.PreviousOutPoint { found = true break } } require.Truef(h.t, found, "input %v missing", inp.OutPoint) } for _, inp := range inputs { found := false for _, outReceived := range h.auctioneer.prevOutputsReceived { if bytes.Equal(inp.PkScript, outReceived.PkScript) && int64(inp.Value) == outReceived.Value { found = true break } } require.Truef( h.t, found, "input %v not in previous outputs", inp.OutPoint, ) } require.Len(h.t, outputs, len(h.auctioneer.outputsReceived)) for _, output := range outputs { found := false for _, outputReceived := range h.auctioneer.outputsReceived { if reflect.DeepEqual(*output, outputReceived) { found = true break } } require.Truef(h.t, found, "output %x missing", output.PkScript) } h.auctioneer.inputsReceived = nil h.auctioneer.outputsReceived = nil h.auctioneer.prevOutputsReceived = nil } // assertAuctioneerMuSig2NoncesReceived makes sure that the nonces we created for // our local MuSig2 session were sent to the auctioneer. func (h *testHarness) assertAuctioneerMuSig2NoncesReceived() { h.t.Helper() h.auctioneer.mu.Lock() defer h.auctioneer.mu.Unlock() h.wallet.Lock() defer h.wallet.Unlock() require.Len(h.t, h.wallet.muSig2Sessions, 0) require.Len(h.t, h.wallet.muSig2RemovedSessions, 1) var sessionInfo *input.MuSig2SessionInfo for _, info := range h.wallet.muSig2RemovedSessions { sessionInfo = info break } require.NotNil(h.t, sessionInfo) require.Equal( h.t, sessionInfo.PublicNonce[:], h.auctioneer.noncesReceived, ) } // assertAccountModification provides several assertions for an account // modification to determine whether it was successful. The assertions ensure // that the account is transitioned from its StatePendingUpdate state to // StateOpen. func (h *testHarness) assertAccountModification(account *Account, inputs []*lnwallet.Utxo, outputs []*wire.TxOut, newAccountValue btcutil.Amount, newAccountVersion Version, accountInputIdx, accountOutputIdx uint32, broadcastHeight uint32) { h.t.Helper() // We'll start by ensuring that the auctioneer received the intended // modification parameters. h.assertAuctioneerReceived(inputs, outputs) // Make sure a MuSig2 signing session was created for a Taproot/MuSig2 // spend. if account.Version >= VersionTaprootEnabled { h.assertAuctioneerMuSig2NoncesReceived() } // A proper spend transaction should have been broadcast that contains // the expected inputs and outputs from above, and the recreated account // output. spendTx := h.assertSpendTxBroadcast( account, inputs, outputs, &newAccountValue, newAccountVersion, ) // The account should be found within the store with the following // modifiers. mods := []Modifier{ ValueModifier(newAccountValue), StateModifier(StatePendingUpdate), OutPointModifier(wire.OutPoint{ Hash: spendTx.TxHash(), Index: accountOutputIdx, }), HeightHintModifier(broadcastHeight), IncrementBatchKey(), LatestTxModifier(spendTx), VersionModifier(newAccountVersion), } for _, mod := range mods { mod(account) } h.assertAccountExists(account) // Notify the transaction as a spend of the account. The account should // remain in StatePendingUpdate until it reaches the appropriate number // of confirmations. select { case h.notifier.spendChan <- &chainntnfs.SpendDetail{ SpendingTx: spendTx, SpenderInputIndex: accountInputIdx, }: case <-time.After(timeout): h.t.Fatalf("couldn't send tx spend notification") } h.assertAccountExists(account) // Notify the confirmation, causing the account to transition back to // StateOpen. confHeight := broadcastHeight + 6 select { case h.notifier.confChan <- &chainntnfs.TxConfirmation{ Tx: spendTx, BlockHeight: confHeight, }: case <-time.After(timeout): h.t.Fatalf("couldn't send tx confirmation") } StateModifier(StateOpen)(account) HeightHintModifier(confHeight)(account) h.assertAccountExists(account) } func (h *testHarness) restartManager() { h.t.Helper() h.manager.Stop() mgr, ok := h.manager.(*manager) require.True(h.t, ok, "invalid manager") auctioneer := newMockAuctioneer() h.auctioneer = auctioneer h.manager = NewManager(&ManagerConfig{ Store: mgr.cfg.Store, Auctioneer: auctioneer, Wallet: mgr.cfg.Wallet, Signer: mgr.cfg.Signer, ChainNotifier: mgr.cfg.ChainNotifier, TxSource: mgr.cfg.TxSource, ChainParams: mgr.cfg.ChainParams, LndVersion: mgr.cfg.LndVersion, }) require.NoError(h.t, h.manager.Start()) } // TestNewAccountHappyFlow ensures that we are able to create a new account // and close it throughout the happy flow. func TestNewAccountHappyFlow(t *testing.T) { t.Parallel() cases := []*testCase{ { name: "happy path version 0", version: VersionInitialNoVersion, }, { name: "happy path version 1", version: VersionTaprootEnabled, }, } runSubTests(t, cases, func(t *testing.T, h *testHarness, tc *testCase) { account := h.openAccount( maxAccountValue, bestHeight+maxAccountExpiry, bestHeight, tc.version, ) expr := defaultFeeExpr h.closeAccount(account, &expr, bestHeight+1) }) } // TestResumeAccountAfterRestart ensures we're able to properly create a new // account even if we've shut down during the process. func TestResumeAccountAfterRestart(t *testing.T) { t.Parallel() const ( value = maxAccountValue expiry = maxAccountExpiry ) cases := []*testCase{ { name: "happy path version 0", version: VersionInitialNoVersion, }, { name: "happy path version 1", version: VersionTaprootEnabled, }, } runSubTests(t, cases, func(t *testing.T, h *testHarness, tc *testCase) { // We'll create an interceptor for SendOutputs to simulate a // transaction being crafted and persisted for the account, but // it not being returned to the account manager because of a // crash, etc. sendOutputsChan := make(chan *wire.MsgTx) sendOutputsInterceptor := func(_ context.Context, outputs []*wire.TxOut, _ chainfee.SatPerKWeight) (*wire.MsgTx, error) { tx := &wire.MsgTx{ Version: 2, TxOut: outputs, } h.wallet.addTx(tx) sendOutputsChan <- tx return nil, errors.New("error") } h.wallet.interceptSendOutputs(sendOutputsInterceptor) // We'll then proceed to create a new account. We expect this to // fail given the interceptor above, but that's fine. We should // still have a persisted intent to create the account. go func() { _, _ = h.manager.InitAccount( context.Background(), value, tc.version, chainfee.FeePerKwFloor, expiry, bestHeight, ) }() var tx *wire.MsgTx select { case tx = <-sendOutputsChan: case <-time.After(timeout): t.Fatal("expected call to SendOutputs") } account := &Account{ Value: value, Expiry: expiry, TraderKey: testTraderKeyDesc, AuctioneerKey: testAuctioneerKey, BatchKey: testBatchKey, Secret: sharedSecret, HeightHint: bestHeight, State: StateInitiated, Version: tc.version, } h.assertAccountExists(account) // Then, we'll create a new interceptor to send us a signal if // SendOutputs is invoked again. This shouldn't happen as the // transaction originally crafted the first time should be in our // TxSource, so we should pick it from there. sendOutputsSignal := make(chan struct{}, 1) sendOutputsInterceptor = func(_ context.Context, outputs []*wire.TxOut, _ chainfee.SatPerKWeight) (*wire.MsgTx, error) { close(sendOutputsSignal) return nil, errors.New("error") } h.wallet.interceptSendOutputs(sendOutputsInterceptor) // Restart the manager. This should cause any pending accounts // to be resumed and their transaction rebroadcast/recreated. In // our case, we should have an account transaction in our // TxSource, so a new one shouldn't be created. h.restartManager() select { case accountTx := <-h.wallet.publishChan: require.Equal(t, accountTx.TxHash(), tx.TxHash()) case <-time.After(timeout): h.t.Fatal("expected account transaction to be " + "rebroadcast") } select { case <-sendOutputsSignal: t.Fatal("unexpected call to SendOutputs") case <-time.After(2 * timeout): } // With the account resumed, it should now be in a // StatePendingOpen state. account.State = StatePendingOpen account.LatestTx = tx account.OutPoint = wire.OutPoint{ Hash: tx.TxHash(), Index: 0, } h.assertAccountExists(account) // Notify the confirmation of the account. confHeight := uint32(bestHeight + 6) h.notifier.confChan <- &chainntnfs.TxConfirmation{ BlockHeight: confHeight, } // This should prompt the account to now be in a Confirmed // state. account.State = StateOpen account.HeightHint = confHeight h.assertAccountExists(account) }) } // TestAccountCloseFundsDestination ensures the different possible destinations // for the funds of an account being closed work as intended. func TestAccountClose(t *testing.T) { t.Parallel() const highFeeRate = 10_000 * chainfee.FeePerKwFloor cases := []*testCase{ { name: "below P2WKH dust limit", feeExpr: &OutputWithFee{ PkScript: p2wpkh, FeeRate: highFeeRate, }, expectedErr: "with 2530000 sat/kw results in dust", }, { name: "below P2SH dust limit", feeExpr: &OutputWithFee{ PkScript: np2wpkh, FeeRate: highFeeRate, }, expectedErr: "with 2530000 sat/kw results in dust", }, { name: "below P2WSH dust limit", feeExpr: &OutputWithFee{ PkScript: p2wsh, FeeRate: highFeeRate, }, expectedErr: "with 2530000 sat/kw results in dust", }, { name: "single wallet P2WKH output", feeExpr: &OutputWithFee{ PkScript: nil, FeeRate: chainfee.FeePerKwFloor, }, fee: btcutil.Amount(141), }, { name: "single external P2WKH output", feeExpr: &OutputWithFee{ PkScript: p2wpkh, FeeRate: 2 * chainfee.FeePerKwFloor, }, fee: btcutil.Amount(282), }, { name: "multiple external outputs", feeExpr: &OutputsWithImplicitFee{ { PkScript: p2wpkh, Value: 10_000, }, { PkScript: np2wpkh, Value: 10_000, }, { PkScript: p2wsh, Value: 10_000, }, }, fee: btcutil.Amount(70_000), }, { name: "taproot single wallet P2WKH output", feeExpr: &OutputWithFee{ PkScript: nil, FeeRate: chainfee.FeePerKwFloor, }, fee: btcutil.Amount(100), version: VersionTaprootEnabled, }, { name: "taproot single wallet P2TR output", feeExpr: &OutputWithFee{ PkScript: p2tr, FeeRate: chainfee.FeePerKwFloor, }, fee: btcutil.Amount(112), version: VersionTaprootEnabled, }, } runSubTests(t, cases, func(t *testing.T, h *testHarness, tc *testCase) { // We'll start by creating a new account of the minimum value // for each test. account := h.openAccount( MinAccountValue, bestHeight+maxAccountExpiry, bestHeight, tc.version, ) // We'll immediately attempt to close the account with the // test's fee expression. _, err := h.manager.CloseAccount( context.Background(), account.TraderKey.PubKey, tc.feeExpr, bestHeight, ) // If the test's fee expression is not valid, we should expect // to see an error. if tc.expectedErr != "" { require.Error(t, err) require.Contains(t, err.Error(), tc.expectedErr) return } // Otherwise, we'll proceed to check whether our fee expression // was honored. require.NoError(t, err) // Any external outputs (not sourced from the backing lnd node) // should be found in the closing transaction. var externalOutputs []*wire.TxOut switch feeExpr := tc.feeExpr.(type) { case *OutputWithFee: if feeExpr.PkScript != nil { output := &wire.TxOut{ PkScript: feeExpr.PkScript, Value: int64( account.Value - tc.fee, ), } externalOutputs = append( externalOutputs, output, ) } case *OutputsWithImplicitFee: externalOutputs = feeExpr.Outputs() default: t.Fatal("unhandled fee expr") } // The account's closing transaction should be broadcast. closeTx := h.assertSpendTxBroadcast( account, nil, externalOutputs, nil, tc.version, ) // Finally, compute the resulting fee of the transaction and // ensure it matches what we expect. var outputTotal btcutil.Amount for _, output := range closeTx.TxOut { outputTotal += btcutil.Amount(output.Value) } fee := account.Value - outputTotal require.Equal(t, fee, tc.fee) }) } // TestAccountExpiration ensures that we properly detect when an account expires // on-chain. As a result, the account should be marked as StateExpired in the // database. func TestAccountExpiration(t *testing.T) { t.Parallel() cases := []*testCase{ { name: "happy path version 0", version: VersionInitialNoVersion, }, { name: "happy path version 1", version: VersionTaprootEnabled, }, } runSubTests(t, cases, func(t *testing.T, h *testHarness, tc *testCase) { account := h.openAccount( maxAccountValue, bestHeight+maxAccountExpiry, bestHeight, tc.version, ) h.expireAccount(account) }) } // TestAccountSpendBatchNotFinalized ensures that if a pending batch exists at // the time of an account spend, then its updates are applied to the account in // order to properly locate the latest account output. func TestAccountSpendBatchNotFinalized(t *testing.T) { t.Parallel() cases := []*testCase{ { name: "happy path version 0", version: VersionInitialNoVersion, }, { name: "happy path version 1", version: VersionTaprootEnabled, newVersion: VersionTaprootEnabled, }, { name: "happy path version upgrade during batch", version: VersionInitialNoVersion, newVersion: VersionTaprootEnabled, }, } runSubTests(t, cases, func(t *testing.T, h *testHarness, tc *testCase) { account := h.openAccount( maxAccountValue, bestHeight+maxAccountExpiry, bestHeight, tc.version, ) // Create an account spend which we'll notify later. This spend // should take the multi-sig path to trigger the pending batch // logic. const newValue = maxAccountValue / 2 upgradedAccount := account.Copy(VersionModifier(tc.newVersion)) newPkScript, err := upgradedAccount.NextOutputScript() require.NoError(h.t, err) spendTx := &wire.MsgTx{ Version: 2, TxIn: []*wire.TxIn{{ PreviousOutPoint: account.OutPoint, Witness: wire.TxWitness{ {0x01}, // Use multi-sig path. {}, {}, }, }}, TxOut: []*wire.TxOut{{ Value: int64(newValue), PkScript: newPkScript, }}, } // Then, we'll simulate a pending batch by staging some account // updates that should be applied once the spend arrives. mods := []Modifier{ ValueModifier(newValue), StateModifier(StatePendingUpdate), OutPointModifier(wire.OutPoint{ Hash: spendTx.TxHash(), Index: 0, }), IncrementBatchKey(), VersionModifier(tc.newVersion), } h.store.setPendingBatch(func() error { return h.store.updateAccount(account, mods...) }) // Notify the spend. h.notifier.spendChan <- &chainntnfs.SpendDetail{ SpendingTx: spendTx, } // Assert that the account updates have been applied. Note that // it may seem like our account pointer hasn't had the updates // applied, but the updateAccount call above does so implicitly. h.assertAccountExists(account) }) } // TestAccountWithdrawal ensures that we can process an account withdrawal // through the happy flow. func TestAccountWithdrawal(t *testing.T) { t.Parallel() const feeRate = chainfee.FeePerKwFloor cases := []*testCase{ { name: "happy path version 0", version: VersionInitialNoVersion, // We'll use the lowest fee rate possible, which should // yield a transaction fee of 260 satoshis when taking // into account the outputs we'll be withdrawing to. fee: 260, }, { name: "happy path version 1", version: VersionTaprootEnabled, newVersion: VersionTaprootEnabled, fee: 219, }, { name: "happy path version upgrade during " + "withdrawal", version: VersionInitialNoVersion, newVersion: VersionTaprootEnabled, fee: 260, }, } runSubTests(t, cases, func(t *testing.T, h *testHarness, tc *testCase) { account := h.openAccount( maxAccountValue, bestHeight+maxAccountExpiry, bestHeight, tc.version, ) // With our account created, we'll start with an invalid // withdrawal to a dust output, which should fail. dustOutput := &wire.TxOut{Value: 0, PkScript: p2wsh} _, _, err := h.manager.WithdrawAccount( context.Background(), account.TraderKey.PubKey, []*wire.TxOut{dustOutput}, feeRate, bestHeight, 0, tc.newVersion, ) require.Error(t, err) require.Contains(t, err.Error(), "dust output") // We'll now attempt a withdrawal that should succeed. We'll // start by creating the outputs we'll withdraw our funds to. // We'll create three outputs, one of each supported output // type. Each output will have 1/4 of the account's value. valuePerOutput := account.Value / 4 outputs := []*wire.TxOut{ { Value: int64(valuePerOutput), PkScript: p2wsh, }, { Value: int64(valuePerOutput), PkScript: p2wpkh, }, { Value: int64(valuePerOutput), PkScript: np2wpkh, }, } // Attempt the withdrawal. // // If successful, we'll follow with a series of assertions to // ensure it was performed correctly. _, _, err = h.manager.WithdrawAccount( context.Background(), account.TraderKey.PubKey, outputs, feeRate, bestHeight, 0, tc.newVersion, ) require.NoError(h.t, err) // The value of the account after the withdrawal depends on the // transaction fee and the amount of each output withdrawn to. withdrawOutputSum := valuePerOutput * btcutil.Amount( len(outputs), ) valueAfterWithdrawal := account.Value - withdrawOutputSum - tc.fee h.assertAccountModification( account, nil, outputs, valueAfterWithdrawal, tc.newVersion, 0, 0, bestHeight, ) // Finally, close the account to ensure we can process another // spend after the withdrawal. expr := defaultFeeExpr h.closeAccount(account, &expr, bestHeight) }) } // TestAccountDeposit ensures that we can process an account deposit // through the happy flow. func TestAccountDeposit(t *testing.T) { t.Parallel() // We'll start by defining our initial account value and its value after // a successful deposit. const initialAccountValue = MinAccountValue const valueAfterDeposit = initialAccountValue * 2 const utxoAmount = initialAccountValue * 3 const depositAmount = valueAfterDeposit - initialAccountValue const feeRate = chainfee.FeePerKwFloor const accountInputFees = 110 const accountInputFeesTaproot = 68 cases := []*testCase{ { name: "happy path version 0", fundedOutputAmount: depositAmount + accountInputFees, // We'll use the lowest fee rate possible, which should // yield a transaction fee of 346 satoshis when taking // into account the additional inputs needed to satisfy // the deposit. fundingTxFee: accountInputFees + 236, utxo: &lnwallet.Utxo{ AddressType: lnwallet.WitnessPubKey, Value: utxoAmount, PkScript: p2wpkh, OutPoint: wire.OutPoint{Index: 1}, }, version: VersionInitialNoVersion, }, { name: "insufficient fees", fundedOutputAmount: depositAmount + accountInputFees, // We'll use a fee that is lower than the lowest fee // we can get with the lowest rate possible (which would // be 346 satoshis), so we should get an error. fundingTxFee: accountInputFees + 10, utxo: &lnwallet.Utxo{ AddressType: lnwallet.WitnessPubKey, Value: utxoAmount, PkScript: p2wpkh, OutPoint: wire.OutPoint{Index: 1}, }, version: VersionInitialNoVersion, expectedErr: "signed transaction only pays 120 sats " + "in fees while 255 are required for relay", }, { name: "happy path version 1", fundedOutputAmount: depositAmount + accountInputFeesTaproot, // We'll use the lowest fee rate possible, which should // yield a transaction fee of 304 satoshis when taking // into account the additional inputs needed to satisfy // the deposit. fundingTxFee: accountInputFeesTaproot + 236, utxo: &lnwallet.Utxo{ AddressType: lnwallet.WitnessPubKey, Value: utxoAmount, PkScript: p2wpkh, OutPoint: wire.OutPoint{Index: 1}, }, version: VersionTaprootEnabled, newVersion: VersionTaprootEnabled, }, { name: "happy path version upgrade during deposit", fundedOutputAmount: depositAmount + accountInputFees, // We'll use the lowest fee rate possible, which should // yield a transaction fee of 346 satoshis when taking // into account the additional inputs needed to satisfy // the deposit. fundingTxFee: accountInputFees + 236, utxo: &lnwallet.Utxo{ AddressType: lnwallet.WitnessPubKey, Value: utxoAmount, PkScript: p2wpkh, OutPoint: wire.OutPoint{Index: 1}, }, version: VersionInitialNoVersion, newVersion: VersionTaprootEnabled, }, } runSubTests(t, cases, func(t *testing.T, h *testHarness, tc *testCase) { account := h.openAccount( initialAccountValue, bestHeight+maxAccountExpiry, bestHeight, tc.version, ) upgradedAccount := account.Copy(VersionModifier(tc.newVersion)) accountOutputScript, _ := upgradedAccount.NextOutputScript() // We'll provide a funded PSBT to the manager that has a change // output. h.wallet.utxos = []*lnwallet.Utxo{tc.utxo} h.wallet.fundPsbt = &psbt.Packet{ UnsignedTx: &wire.MsgTx{ Version: 2, TxIn: []*wire.TxIn{{ PreviousOutPoint: tc.utxo.OutPoint, }}, TxOut: []*wire.TxOut{{ Value: int64(tc.fundedOutputAmount), PkScript: accountOutputScript, }, { Value: int64( tc.utxo.Value - depositAmount - tc.fundingTxFee, ), PkScript: np2wpkh, }}, }, Inputs: []psbt.PInput{{ WitnessUtxo: &wire.TxOut{ Value: int64(tc.utxo.Value), PkScript: tc.utxo.PkScript, }, // Normally the FinalizePsbt call would add the // signatures for the wallet UTXOs. Since we're // only using a mock, we add some signatures // manually. PartialSigs: []*psbt.PartialSig{{ Signature: []byte{1, 2, 3}, }}, }}, Outputs: []psbt.POutput{{}, {}}, } h.wallet.fundPsbtChangeIdx = 1 // Attempt the deposit. // // If successful, we'll follow with a series of assertions to // ensure it was performed correctly. _, _, err := h.manager.DepositAccount( context.Background(), account.TraderKey.PubKey, depositAmount, feeRate, bestHeight, 0, tc.newVersion, ) if tc.expectedErr != "" { require.Error(t, err) require.Contains(t, err.Error(), tc.expectedErr) return } require.NoError(t, err) // The transaction should have found the expected account input // and output at the following indices. const accountInputIdx = 1 const accountOutputIdx = 1 h.assertAccountModification( account, h.wallet.utxos, []*wire.TxOut{h.wallet.fundPsbt.UnsignedTx.TxOut[0]}, valueAfterDeposit, tc.newVersion, accountInputIdx, accountOutputIdx, bestHeight, ) // Finally, close the account to ensure we can process another // spend after the withdrawal. expr := defaultFeeExpr h.closeAccount(account, &expr, bestHeight) }) } // TestAccountConsecutiveBatches ensures that we can process an account update // through multiple consecutive batches that only confirm after we've already // updated our database state. func TestAccountConsecutiveBatches(t *testing.T) { t.Parallel() cases := []*testCase{ { name: "happy path version 0", version: VersionInitialNoVersion, }, { name: "happy path version 1", version: VersionTaprootEnabled, newVersion: VersionTaprootEnabled, }, } runSubTests(t, cases, func(t *testing.T, h *testHarness, tc *testCase) { account := h.openAccount( maxAccountValue, bestHeight+maxAccountExpiry, bestHeight, tc.version, ) // Then, we'll simulate the maximum number of unconfirmed // batches to happen that'll all confirm in the same block. const newValue = maxAccountValue / 2 const numBatches = 10 batchTxs := make([]*wire.MsgTx, numBatches) for i := 0; i < numBatches; i++ { newPkScript, err := account.NextOutputScript() require.NoError(t, err) // Create an account spend which we'll notify later. // This spend should take the multi-sig path to trigger // the logic to lookup previous outpoints. batchTx := &wire.MsgTx{ Version: 2, TxIn: []*wire.TxIn{{ PreviousOutPoint: account.OutPoint, Witness: wire.TxWitness{ {0x01}, // Use multi-sig path. {}, {}, }, }}, TxOut: []*wire.TxOut{{ Value: int64(newValue), PkScript: newPkScript, }}, } batchTxs[i] = batchTx mods := []Modifier{ ValueModifier(newValue - btcutil.Amount(i)), StateModifier(StatePendingBatch), OutPointModifier(wire.OutPoint{ Hash: batchTx.TxHash(), Index: 0, }), IncrementBatchKey(), VersionModifier(tc.newVersion), } err = h.store.updateAccount(account, mods...) require.NoError(t, err) // The RPC server will notify the manager each time a // batch is finalized, we do the same here. err = h.manager.WatchMatchedAccounts( context.Background(), []*btcec.PublicKey{account.TraderKey.PubKey}, ) require.NoError(t, err) } // Notify the confirmation, causing the account to transition // back to StateOpen. confHeight := bestHeight + 6 h.notifier.confChan <- &chainntnfs.TxConfirmation{ Tx: batchTxs[len(batchTxs)-1], BlockHeight: uint32(confHeight), } StateModifier(StateOpen)(account) HeightHintModifier(uint32(confHeight))(account) h.assertAccountExists(account) }) } // TestAccountUpdateSubscriptionOnRestart ensures that the account manager // subscribes accounts in certain states for auction updates after a restart. func TestAccountUpdateSubscriptionOnRestart(t *testing.T) { t.Parallel() cases := []*testCase{ { name: "happy path version 0", version: VersionInitialNoVersion, }, { name: "happy path version 1", version: VersionTaprootEnabled, }, } runSubTests(t, cases, func(t *testing.T, h *testHarness, tc *testCase) { account := h.openAccount( maxAccountValue, bestHeight+maxAccountExpiry, bestHeight, tc.version, ) // StateOpen case. h.restartManager() h.assertAccountSubscribed(account.TraderKey.PubKey) err := h.store.UpdateAccount( account, StateModifier(StatePendingBatch), ) require.NoError(t, err) // StatePendingBatch case. h.restartManager() h.assertAccountSubscribed(account.TraderKey.PubKey) err = h.store.UpdateAccount( account, StateModifier(StatePendingUpdate), ) require.NoError(t, err) // StatePendingUpdate case. h.restartManager() h.assertAccountNotSubscribed(account.TraderKey.PubKey) // Confirm the account. confHeight := uint32(bestHeight + 6) h.notifier.confChan <- &chainntnfs.TxConfirmation{ BlockHeight: confHeight, } account.State = StateOpen account.HeightHint = confHeight h.assertAccountExists(account) // It should now be subscribed since it's eligible for batch // execution. h.assertAccountSubscribed(account.TraderKey.PubKey) }) } // TestMakeTxnLabel tests that the label will be formatted properly, and also // truncated if needed. func TestMakeTxnLabel(t *testing.T) { t.Parallel() testCases := []struct { prefix string labelContext string label string }{ // Normal case, prefix present, should add a space. { prefix: "ok", labelContext: "important stuff", label: "ok important stuff", }, // No prefix, should be no leading space. { prefix: "", labelContext: "important stuff", label: "important stuff", }, // No prefix, prefix itself is so long the context can't fit, // should be truncated. { prefix: strings.Repeat("o", 500), labelContext: "will be left off", label: strings.Repeat("o", 500), }, // Prefix itself is too long, it'll be truncated as well. { prefix: strings.Repeat("o", 501), labelContext: "will be left off", label: strings.Repeat("o", 500), }, } for _, testCase := range testCases { genLabel := makeTxnLabel( testCase.prefix, testCase.labelContext, ) require.Equal(t, genLabel, testCase.label) } } // TestParseTxLabel tests whether an account transaction labels can be // parsed correctly. func TestParseTxLabel(t *testing.T) { t.Parallel() cases := []*testCase{ { fee: btcutil.Amount(1027), action: WITHDRAW, isExpirySpend: false, }, { fee: btcutil.Amount(42), action: DEPOSIT, isExpirySpend: false, }, { fee: btcutil.Amount(42), action: RENEW, isExpirySpend: true, }, } runSubTests(t, cases, func(t *testing.T, h *testHarness, tc *testCase) { expiryHeight := uint32(bestHeight + maxAccountExpiry) account := h.openAccount( maxAccountValue, expiryHeight, bestHeight, tc.version, ) acctKey := account.TraderKey.PubKey.SerializeCompressed() key := fmt.Sprintf("%x", acctKey) label := actionTxLabel( account, tc.action, tc.isExpirySpend, &tc.fee, btcutil.Amount(10000), ) actual, err := ParseTxLabel(label) expected := &TxLabel{ AccountTxLabel{ Key: key, Action: tc.action, ExpiryHeight: expiryHeight, OutputIndex: 0, IsExpirySpend: tc.isExpirySpend, TxFee: &tc.fee, BalanceDiff: btcutil.Amount(10000), }, } require.Nil(t, err) require.Equal(t, expected, actual) }) }