resolutions: add resolution report for cooperatively closed channel

This commit is contained in:
carla 2020-06-27 14:35:52 +02:00
parent 9f85678ac5
commit 9a58df81c9
No known key found for this signature in database
GPG key ID: 4CA7FE54A6213C91
5 changed files with 669 additions and 0 deletions

17
resolutions/docs.md Normal file
View file

@ -0,0 +1,17 @@
# Channel Reports
Channel reports provide details summaries of channels that we have closed on chain, including fees and on chain resolutions required to fully finalize a channel. Note that channels that are still pending close will not be considered for channel reports; you must wait until the channel is fully resolved on chain. Since these channels are opened, closed and resolved on chain, all units will be expressed in satoshis.
## Common Fields
- Channel Point: The funding txid: output index of of the output which created the channel.
- Channel Initiator: True if our node opened the channel.
- Close Type: The type of channel close - cooperative, local force, remote force, breach or justice.
- Open Fee: The fees we paid to open the channel in satoshis, note that this amount will be 0 if we did not open the channel.
- Close Fee: The fees we paid to close the channel in satoshis, not that this amount will be 0 if we did not open the channel.
### Cooperative Close
A cooperative close occurs when one party decides that they want to close the channel, and the other is online to cooperatively sign a close transaction. When this kind of close occurs, there are no on chain resolutions because the parties agree to wait for all htlcs to clear, and sign a close transaction which pays out each party without encumbering their funds behind a time lock.
Since this close type has no on chain resolutions, there are no fields in the report aside from the common fields listed above.
Known Omissions:
- The current implementation does not support generation of reports for channels that were created with batched funding transactions.

77
resolutions/fees.go Normal file
View file

@ -0,0 +1,77 @@
package resolutions
import (
"errors"
"github.com/btcsuite/btcd/btcjson"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcutil"
"github.com/shopspring/decimal"
)
// getTxDetails is a function which looks up transactions by hash.
type getTxDetails func(hash *chainhash.Hash) (*btcjson.TxRawResult, error)
// errBatchedTx is returned when we are trying to get the fees for a transaction
// but it is not of the format we expect (max 2 outputs, one change one output).
var errBatchedTx = errors.New("cannot calculate fees for batched " +
"transaction")
// totalFees returns the total fees for the transaction provided. Note that this
// function assumes that we have a maximum of two outputs (one regular and one
// change address, or two outputs from a cooperative close) and does not
// calculate fees for batched transactions (where we cannot split fees between
// outputs).
func totalFees(details getTxDetails, txid *chainhash.Hash) (decimal.Decimal,
error) {
var fees decimal.Decimal
tx, err := details(txid)
if err != nil {
return decimal.Zero, err
}
// Do a quick sanity check that our transaction does not have more than
// two outputs.
// TODO(carla): identify change address and split fees between outputs.
if len(tx.Vout) > 2 {
return decimal.Zero, errBatchedTx
}
// First, we minus total outputs from our fees.
for _, out := range tx.Vout {
amt, err := btcutil.NewAmount(out.Value)
if err != nil {
return decimal.Zero, err
}
fees = fees.Sub(decimal.NewFromInt(int64(amt)))
}
// Next, we lookup each of our inputs to figure out their values and
// minus them from our fees
for _, in := range tx.Vin {
prevOutHash, err := chainhash.NewHashFromStr(in.Txid)
if err != nil {
return decimal.Zero, err
}
tx, err := details(prevOutHash)
if err != nil {
return decimal.Zero, err
}
prevOut := tx.Vout[in.Vout]
amt, err := btcutil.NewAmount(prevOut.Value)
if err != nil {
return decimal.Zero, err
}
fees = fees.Add(decimal.NewFromInt(int64(amt)))
}
// Our fees are simply the difference between our input and output
// total.
return fees, nil
}

51
resolutions/fees_test.go Normal file
View file

@ -0,0 +1,51 @@
package resolutions
import (
"testing"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/require"
)
// TestTotalFees tests fee calculation for a transaction that we assume has a
// single output, and perhaps a change address.
func TestTotalFees(t *testing.T) {
tests := []struct {
name string
txid *chainhash.Hash
fee decimal.Decimal
err error
}{
{
name: "can calculate fees",
txid: txid1,
fee: tx1TotalFee,
err: nil,
},
{
name: "tx2",
txid: txid2,
fee: tx2TotalFee,
err: nil,
},
{
name: "too many outputs",
txid: txid0,
fee: decimal.Zero,
err: errBatchedTx,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
fee, err := totalFees(getDetails, test.txid)
require.Equal(t, test.err, err)
require.Equal(t, test.fee, fee)
})
}
}

205
resolutions/resolutions.go Normal file
View file

@ -0,0 +1,205 @@
package resolutions
import (
"errors"
"fmt"
"github.com/btcsuite/btcd/btcjson"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/faraday/utils"
"github.com/lightninglabs/lndclient"
"github.com/shopspring/decimal"
)
var (
// ErrChannelNotClosed is returned when we get a request for a close
// channel report for a channel that is not present in lnd's list of
// closed channels.
ErrChannelNotClosed = errors.New("channel not closed, cannot create " +
"report")
// ErrCloseTypeNotSupported is returned when we do not yet support
// creation of close reports for the channel type provided.
ErrCloseTypeNotSupported = errors.New("close reports for type not " +
"supported")
)
// Config provides all the external functions and parameters required to produce
// reports on closed channels.
type Config struct {
// ClosedChannels returns a list of our currently closed channels.
ClosedChannels func() ([]lndclient.ClosedChannel, error)
// WalletTransactions returns a list of transactions that are relevant
// to our wallet.
WalletTransactions func() ([]lndclient.Transaction, error)
// GetTxDetail looks up an on chain transaction and returns the raw
// tx result which contains a detailed set of information about the
// transaction.
GetTxDetail func(txHash *chainhash.Hash) (*btcjson.TxRawResult, error)
}
// ChannelCloseReport returns a full report on a closed channel.
func ChannelCloseReport(cfg *Config, chanPoint string) (*CloseReport, error) {
// First, get our set of closed channels and make sure that the
closed, err := cfg.ClosedChannels()
if err != nil {
return nil, err
}
var (
closedChannel lndclient.ClosedChannel
found bool
)
for _, channel := range closed {
if channel.ChannelPoint == chanPoint {
closedChannel = channel
found = true
break
}
}
if !found {
return nil, ErrChannelNotClosed
}
switch closedChannel.CloseType {
case lndclient.CloseTypeCooperative:
return coopCloseReport(cfg, &closedChannel)
default:
return nil, ErrCloseTypeNotSupported
}
}
// CloseReport represents a closed channel.
type CloseReport struct {
// ChannelPoint is the outpoint of the funding transaction.
ChannelPoint *wire.OutPoint
// ChannelInitiator is true if we opened the channel.
ChannelInitiator bool
// CloseType reflects the type of close that occurred.
CloseType lndclient.CloseType
// CloseTxid is the transaction ID of the channel close.
CloseTxid string
// OpenFee is the amount of fees we paid to open the channel in
// satoshis. Note that this will be zero for the current protocol where
// the initiating party pays for the channel to be opened.
OpenFee decimal.Decimal
// CloseFee is the amount of fees we paid to close the channel in
// satoshis. Note that this will be zero for the current protocol where
// the initiating party pays for the channel to be closed.
CloseFee decimal.Decimal
}
// coopCloseReport creates a channel report for a cooperatively closed channel
// where we do not need to worry about on chain resolutions.
func coopCloseReport(cfg *Config,
channel *lndclient.ClosedChannel) (*CloseReport, error) {
chanPoint, err := utils.GetOutPointFromString(channel.ChannelPoint)
if err != nil {
return nil, err
}
report := &CloseReport{
ChannelPoint: chanPoint,
ChannelInitiator: false,
CloseType: channel.CloseType,
CloseTxid: channel.ClosingTxHash,
OpenFee: decimal.Zero,
CloseFee: decimal.Zero,
}
// We pay fees based on whether we opened the channel or not, so we
// switch on our open initiator field (which may be unknown) to decide
// whether we need to get fee information.
switch channel.OpenInitiator {
// If the remote party opened the channel, we do not need to get any
// further information about the open and close fees, because we know
// the remote party paid them. We can just return our report as is.
case lndclient.InitiatorRemote:
return report, nil
// If we know we opened the channel, we fallthrough to get our open and
// close fees.
case lndclient.InitiatorLocal:
report.ChannelInitiator = true
// If we do not know whether we opened the channel or not, we lookup our
// funding outpoint with our wallet to determine whether is is ours or
// not. If it isn't ours, we just return the report as is. If it is, we
// fallthrough to get additional information about the close.
case lndclient.InitiatorUnrecorded:
report.ChannelInitiator, err = getCloseInitiatorFromWallet(
cfg, report.ChannelPoint.Hash.String(),
)
if err != nil {
return nil, err
}
// If we did not open the channel, we can just return here
// because we do not need to record any fees (we did not pay
// them). If we did open the channel, we fallthrough to get
// our fee information.
if !report.ChannelInitiator {
return report, nil
}
default:
return nil, fmt.Errorf("unknown inititor: %v",
channel.OpenInitiator)
}
// At this stage, we know that we opened the channel. We now lookup our
// open and close transactions to get the fees we paid for them.
report.OpenFee, err = totalFees(cfg.GetTxDetail, &chanPoint.Hash)
if err != nil {
return nil, err
}
closeHash, err := chainhash.NewHashFromStr(channel.ClosingTxHash)
if err != nil {
return nil, err
}
// Get the fees for our closing transaction. Since we will have to pay
// for the full close transaction (regardless of whether we have an
// output), we get our total fees for this transaction rather than for
// a specific outpoint.
report.CloseFee, err = totalFees(cfg.GetTxDetail, closeHash)
if err != nil {
return nil, err
}
return report, nil
}
// getCloseInitiatorFromWallet figures out whether we initiated opening a
// channel by checking whether the opening transaction is in our set of wallet
// relevant transactions. If it is present, we contributed funds or published
// (in the case of psbt) the channel, so we were the opening party.
func getCloseInitiatorFromWallet(cfg *Config, openTx string) (bool,
error) {
txns, err := cfg.WalletTransactions()
if err != nil {
return false, err
}
for _, tx := range txns {
if tx.TxHash == openTx {
return true, nil
}
}
return false, nil
}

View file

@ -0,0 +1,319 @@
package resolutions
import (
"fmt"
"testing"
"github.com/btcsuite/btcd/btcjson"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcutil"
"github.com/lightninglabs/lndclient"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/require"
)
var (
hash0 = "286ddb794170fafb73450db66b911f65823567ca3f9b88adc1c67b769951d7c2"
hash1 = "b3ee48d811b07dd4c6c089e49587ed45d313cf2333d3588468848c4d98e5940d"
hash2 = "ffa0c0191f491ac5193c6626db13d78e44d8b841530058a9eeb89d8fcea26c0d"
// Create three transaction ids, which we will setup in the sequence
// txid2 --spends from--> txid1 --spends from--> txid0.
txid0, _ = chainhash.NewHashFromStr(hash0)
txid1, _ = chainhash.NewHashFromStr(hash1)
txid2, _ = chainhash.NewHashFromStr(hash2)
tx0VOutValue = 3.1
tx0OtherOutputs = 1.1
// Create our first transaction, we do not need to set inputs here
// because we just spend from this tx in tests. We give this transaction
// three outputs so that it checks that we appropriately only use the
// value of a single output in our calculation, and so that it can be
// used to trigger error conditions (where we require >2 output).
tx0 = &btcjson.TxRawResult{
Hash: hash0,
Vout: []btcjson.Vout{
{
Value: tx0VOutValue,
},
{
Value: tx0OtherOutputs,
},
{
Value: tx0OtherOutputs,
},
},
}
// Set the amount that our next tx will have as an output. The fee for
// our first tx is therefore the original tx0VOutValue less this amount.
tx1VoutValue float64 = 3
tx1TotalFeeSat, _ = btcutil.NewAmount(
tx0VOutValue - tx1VoutValue,
)
tx1TotalFee = decimal.NewFromInt(int64(tx1TotalFeeSat))
// Create tx1 which spends all of the outputs from tx0, and will be
// spent by tx2.
tx1 = &btcjson.TxRawResult{
Hash: hash1,
Vin: []btcjson.Vin{
{
Txid: hash0,
Vout: 0,
},
},
Vout: []btcjson.Vout{
{
Value: tx1VoutValue,
},
},
}
// Set the output amounts that our final tx will create.
output1Value float64 = 2
output2Value = 0.5
// Our fee for our second transaction is therefore the output of tx1
// less our two outputs.
tx2TotalFeeSat, _ = btcutil.NewAmount(
tx1VoutValue - output1Value - output2Value,
)
tx2TotalFee = decimal.NewFromInt(int64(tx2TotalFeeSat))
// tx2 is a transaction that spends from only tx1 (value =3) and creates
// two new outpoints, with a total value of 2.5
tx2 = &btcjson.TxRawResult{
Hash: hash2,
Vin: []btcjson.Vin{
{
Txid: hash1,
Vout: 0,
},
},
Vout: []btcjson.Vout{
{
Value: output1Value,
},
{
Value: output2Value,
},
},
}
// Finally, we create channel point
tx1ChanPoint = &wire.OutPoint{
Hash: *txid1,
Index: 0,
}
tx2ChanPoint = &wire.OutPoint{
Hash: *txid2,
Index: 0,
}
)
// TestGetClosedReport tests creation of a a closed channel report.
func TestGetClosedReport(t *testing.T) {
tests := []struct {
name string
chanPoint string
closedChannels []lndclient.ClosedChannel
error error
}{
{
name: "channel found, wrong type",
chanPoint: tx2ChanPoint.String(),
closedChannels: []lndclient.ClosedChannel{
{
ChannelPoint: tx1ChanPoint.String(),
},
{
ChannelPoint: tx2ChanPoint.String(),
CloseType: lndclient.CloseTypeAbandoned,
},
},
error: ErrCloseTypeNotSupported,
},
{
name: "channel not found",
chanPoint: tx1ChanPoint.String(),
closedChannels: []lndclient.ClosedChannel{
{ChannelPoint: tx2ChanPoint.String()},
},
error: ErrChannelNotClosed,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
closedChannels := func() ([]lndclient.ClosedChannel,
error) {
return test.closedChannels, nil
}
_, err := ChannelCloseReport(
&Config{ClosedChannels: closedChannels},
test.chanPoint,
)
require.Equal(t, test.error, err)
})
}
}
// walletTransactions is a helper function which provides a WalletTransactions
// function that will return the txids provided.
func walletTransactions(txids []*chainhash.Hash) func() (
[]lndclient.Transaction, error) {
txns := make([]lndclient.Transaction, len(txids))
for i, tx := range txids {
txns[i] = lndclient.Transaction{
TxHash: tx.String(),
}
}
return func() ([]lndclient.Transaction, error) {
return txns, nil
}
}
// TestCoopCloseReport tests creation of a cooperative close report.
func TestCoopCloseReport(t *testing.T) {
tests := []struct {
name string
openInitiator lndclient.Initiator
initiator bool
chanPoint *wire.OutPoint
openFee decimal.Decimal
closeFee decimal.Decimal
// walletTxns is a list of transactions that belong to our
// wallet, our channel point should be in this list if it is
// expected to be locally initiated.
walletTxns []*chainhash.Hash
error error
}{
{
name: "remote opened",
openInitiator: lndclient.InitiatorRemote,
chanPoint: tx1ChanPoint,
initiator: false,
openFee: decimal.Zero,
closeFee: decimal.Zero,
error: nil,
},
{
name: "local initiator",
openInitiator: lndclient.InitiatorLocal,
chanPoint: tx1ChanPoint,
initiator: true,
openFee: tx1TotalFee,
closeFee: tx2TotalFee,
error: nil,
},
{
// Refer to tx0 (which has 3 outputs) as our open tx,
// this indicates that our close tx was batched, which
// we do not currently support.
name: "local initiator, batched open tx",
openInitiator: lndclient.InitiatorLocal,
chanPoint: wire.NewOutPoint(txid0, 0),
initiator: true,
openFee: decimal.Zero,
closeFee: decimal.Zero,
error: errBatchedTx,
},
{
name: "unknown initiator - lookup is remote",
openInitiator: lndclient.InitiatorUnrecorded,
chanPoint: tx1ChanPoint,
initiator: false,
openFee: decimal.Zero,
closeFee: decimal.Zero,
walletTxns: nil,
error: nil,
},
{
name: "unknown initiator - lookup is local",
openInitiator: lndclient.InitiatorUnrecorded,
chanPoint: tx1ChanPoint,
initiator: true,
openFee: tx1TotalFee,
closeFee: tx2TotalFee,
walletTxns: []*chainhash.Hash{txid1},
error: nil,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
// Create a coop-close with out outpoint and close
// initiator of choice.
chanClose := lndclient.ClosedChannel{
ChannelPoint: test.chanPoint.String(),
ClosingTxHash: hash2,
CloseType: lndclient.CloseTypeCooperative,
OpenInitiator: test.openInitiator,
}
cfg := &Config{
WalletTransactions: walletTransactions(
test.walletTxns,
),
GetTxDetail: getDetails,
}
report, err := coopCloseReport(cfg, &chanClose)
require.Equal(t, test.error, err)
// If we expect an error, we do not proceed to check our
// report against our expected output.
if err != nil {
require.Nil(t, report)
return
}
expected := &CloseReport{
ChannelPoint: &wire.OutPoint{
Hash: *txid1,
Index: 0,
},
ChannelInitiator: test.initiator,
CloseType: lndclient.CloseTypeCooperative,
CloseTxid: hash2,
OpenFee: test.openFee,
CloseFee: test.closeFee,
}
require.Equal(t, expected, report)
})
}
}
// getDetails mocks lookup for a node that has knowledge of tx1 and tx2.
func getDetails(txHash *chainhash.Hash) (*btcjson.TxRawResult, error) {
switch *txHash {
case *txid0:
return tx0, nil
case *txid1:
return tx1, nil
case *txid2:
return tx2, nil
default:
return nil, fmt.Errorf("transaction not found")
}
}