mirror of
https://github.com/lightninglabs/pool.git
synced 2026-08-15 12:50:29 +02:00
This commit prepares for a change in lndclient, where each sub server client needs to implement a specific method. Because of that requirement we cannot have a single mock that implements two of those methods and we need to split the wallet and signer mocks apart.
1632 lines
43 KiB
Go
1632 lines
43 KiB
Go
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
|
|
signer *mockSigner
|
|
auctioneer *mockAuctioneer
|
|
manager Manager
|
|
}
|
|
|
|
func newTestHarness(t *testing.T) *testHarness {
|
|
store := newMockStore()
|
|
wallet := newMockWallet()
|
|
signer := newMockSigner()
|
|
notifier := newMockChainNotifier()
|
|
auctioneer := newMockAuctioneer()
|
|
|
|
return &testHarness{
|
|
t: t,
|
|
store: store,
|
|
wallet: wallet,
|
|
signer: signer,
|
|
notifier: notifier,
|
|
auctioneer: auctioneer,
|
|
manager: NewManager(&ManagerConfig{
|
|
Store: store,
|
|
Auctioneer: auctioneer,
|
|
Wallet: wallet,
|
|
Signer: signer,
|
|
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.signer.Lock()
|
|
defer h.signer.Unlock()
|
|
|
|
require.Len(h.t, h.signer.muSig2Sessions, 0)
|
|
require.Len(h.t, h.signer.muSig2RemovedSessions, 1)
|
|
|
|
var sessionInfo *input.MuSig2SessionInfo
|
|
for _, info := range h.signer.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,
|
|
},
|
|
{
|
|
name: "happy path version 2",
|
|
version: VersionMuSig2V100RC2,
|
|
},
|
|
}
|
|
|
|
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,
|
|
},
|
|
{
|
|
name: "happy path version 2",
|
|
version: VersionMuSig2V100RC2,
|
|
},
|
|
}
|
|
|
|
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 v2 single wallet P2WKH output",
|
|
feeExpr: &OutputWithFee{
|
|
PkScript: nil,
|
|
FeeRate: chainfee.FeePerKwFloor,
|
|
},
|
|
fee: btcutil.Amount(100),
|
|
version: VersionMuSig2V100RC2,
|
|
},
|
|
{
|
|
name: "taproot single wallet P2TR output",
|
|
feeExpr: &OutputWithFee{
|
|
PkScript: p2tr,
|
|
FeeRate: chainfee.FeePerKwFloor,
|
|
},
|
|
fee: btcutil.Amount(112),
|
|
version: VersionTaprootEnabled,
|
|
},
|
|
{
|
|
name: "taproot v2 single wallet P2TR output",
|
|
feeExpr: &OutputWithFee{
|
|
PkScript: p2tr,
|
|
FeeRate: chainfee.FeePerKwFloor,
|
|
},
|
|
fee: btcutil.Amount(112),
|
|
version: VersionMuSig2V100RC2,
|
|
},
|
|
}
|
|
|
|
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,
|
|
},
|
|
{
|
|
name: "happy path version 2",
|
|
version: VersionMuSig2V100RC2,
|
|
},
|
|
}
|
|
|
|
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 to p2tr v1 " +
|
|
"during batch",
|
|
version: VersionInitialNoVersion,
|
|
newVersion: VersionTaprootEnabled,
|
|
},
|
|
{
|
|
name: "happy path version upgrade to p2tr v2 " +
|
|
"during batch",
|
|
version: VersionInitialNoVersion,
|
|
newVersion: VersionMuSig2V100RC2,
|
|
},
|
|
{
|
|
name: "happy path version upgrade from p2tr v1 to v2 " +
|
|
"during batch",
|
|
version: VersionTaprootEnabled,
|
|
newVersion: VersionMuSig2V100RC2,
|
|
},
|
|
{
|
|
name: "happy path version 2",
|
|
version: VersionMuSig2V100RC2,
|
|
newVersion: VersionMuSig2V100RC2,
|
|
},
|
|
}
|
|
|
|
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 to p2tr v1 " +
|
|
"during batch",
|
|
version: VersionInitialNoVersion,
|
|
newVersion: VersionTaprootEnabled,
|
|
fee: 260,
|
|
},
|
|
{
|
|
name: "happy path version upgrade to p2tr v2 " +
|
|
"during batch",
|
|
version: VersionInitialNoVersion,
|
|
newVersion: VersionMuSig2V100RC2,
|
|
fee: 260,
|
|
},
|
|
{
|
|
name: "happy path version upgrade from p2tr v1 to v2 " +
|
|
"during batch",
|
|
version: VersionTaprootEnabled,
|
|
newVersion: VersionMuSig2V100RC2,
|
|
fee: 219,
|
|
},
|
|
{
|
|
name: "happy path version 2",
|
|
version: VersionMuSig2V100RC2,
|
|
newVersion: VersionMuSig2V100RC2,
|
|
fee: 219,
|
|
},
|
|
}
|
|
|
|
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
|
|
const depositAndOutputFees = 236
|
|
|
|
cases := []*testCase{
|
|
{
|
|
name: "happy path version 0",
|
|
fundedOutputAmount: depositAmount + accountInputFees,
|
|
fundingTxFee: accountInputFees + depositAndOutputFees,
|
|
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 accountInputFees + depositAndOutputFees 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,
|
|
fundingTxFee: accountInputFeesTaproot +
|
|
depositAndOutputFees,
|
|
utxo: &lnwallet.Utxo{
|
|
AddressType: lnwallet.WitnessPubKey,
|
|
Value: utxoAmount,
|
|
PkScript: p2wpkh,
|
|
OutPoint: wire.OutPoint{Index: 1},
|
|
},
|
|
version: VersionTaprootEnabled,
|
|
newVersion: VersionTaprootEnabled,
|
|
},
|
|
{
|
|
name: "happy path version upgrade to p2tr during " +
|
|
"deposit",
|
|
fundedOutputAmount: depositAmount + accountInputFees,
|
|
fundingTxFee: accountInputFees + depositAndOutputFees,
|
|
utxo: &lnwallet.Utxo{
|
|
AddressType: lnwallet.WitnessPubKey,
|
|
Value: utxoAmount,
|
|
PkScript: p2wpkh,
|
|
OutPoint: wire.OutPoint{Index: 1},
|
|
},
|
|
version: VersionInitialNoVersion,
|
|
newVersion: VersionTaprootEnabled,
|
|
},
|
|
{
|
|
name: "happy path version 2",
|
|
fundedOutputAmount: depositAmount +
|
|
accountInputFeesTaproot,
|
|
fundingTxFee: accountInputFeesTaproot +
|
|
depositAndOutputFees,
|
|
utxo: &lnwallet.Utxo{
|
|
AddressType: lnwallet.WitnessPubKey,
|
|
Value: utxoAmount,
|
|
PkScript: p2wpkh,
|
|
OutPoint: wire.OutPoint{Index: 1},
|
|
},
|
|
version: VersionMuSig2V100RC2,
|
|
newVersion: VersionMuSig2V100RC2,
|
|
},
|
|
{
|
|
name: "happy path version upgrade to p2tr v2 during " +
|
|
"deposit",
|
|
fundedOutputAmount: depositAmount + accountInputFees,
|
|
fundingTxFee: accountInputFees + depositAndOutputFees,
|
|
utxo: &lnwallet.Utxo{
|
|
AddressType: lnwallet.WitnessPubKey,
|
|
Value: utxoAmount,
|
|
PkScript: p2wpkh,
|
|
OutPoint: wire.OutPoint{Index: 1},
|
|
},
|
|
version: VersionInitialNoVersion,
|
|
newVersion: VersionMuSig2V100RC2,
|
|
},
|
|
{
|
|
name: "happy path version upgrade from p2tr v1 to v2 " +
|
|
"during deposit",
|
|
fundedOutputAmount: depositAmount +
|
|
accountInputFeesTaproot,
|
|
fundingTxFee: accountInputFees + depositAndOutputFees,
|
|
utxo: &lnwallet.Utxo{
|
|
AddressType: lnwallet.WitnessPubKey,
|
|
Value: utxoAmount,
|
|
PkScript: p2wpkh,
|
|
OutPoint: wire.OutPoint{Index: 1},
|
|
},
|
|
version: VersionTaprootEnabled,
|
|
newVersion: VersionMuSig2V100RC2,
|
|
},
|
|
}
|
|
|
|
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,
|
|
},
|
|
{
|
|
name: "happy path version upgrade to p2tr v1 " +
|
|
"during batch",
|
|
version: VersionInitialNoVersion,
|
|
newVersion: VersionTaprootEnabled,
|
|
},
|
|
{
|
|
name: "happy path version upgrade to p2tr v2 " +
|
|
"during batch",
|
|
version: VersionInitialNoVersion,
|
|
newVersion: VersionMuSig2V100RC2,
|
|
},
|
|
{
|
|
name: "happy path version upgrade from p2tr v1 to v2 " +
|
|
"during batch",
|
|
version: VersionTaprootEnabled,
|
|
newVersion: VersionMuSig2V100RC2,
|
|
},
|
|
{
|
|
name: "happy path version 2",
|
|
version: VersionMuSig2V100RC2,
|
|
newVersion: VersionMuSig2V100RC2,
|
|
},
|
|
}
|
|
|
|
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,
|
|
},
|
|
{
|
|
name: "happy path version upgrade to p2tr v1 " +
|
|
"during batch",
|
|
version: VersionInitialNoVersion,
|
|
newVersion: VersionTaprootEnabled,
|
|
},
|
|
{
|
|
name: "happy path version upgrade to p2tr v2 " +
|
|
"during batch",
|
|
version: VersionInitialNoVersion,
|
|
newVersion: VersionMuSig2V100RC2,
|
|
},
|
|
{
|
|
name: "happy path version upgrade from p2tr v1 to v2 " +
|
|
"during batch",
|
|
version: VersionTaprootEnabled,
|
|
newVersion: VersionMuSig2V100RC2,
|
|
},
|
|
{
|
|
name: "happy path version 2",
|
|
version: VersionMuSig2V100RC2,
|
|
},
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|