diff --git a/resolutions/docs.md b/resolutions/docs.md new file mode 100644 index 0000000..75a473a --- /dev/null +++ b/resolutions/docs.md @@ -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. \ No newline at end of file diff --git a/resolutions/fees.go b/resolutions/fees.go new file mode 100644 index 0000000..a0d93a2 --- /dev/null +++ b/resolutions/fees.go @@ -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 +} diff --git a/resolutions/fees_test.go b/resolutions/fees_test.go new file mode 100644 index 0000000..c3fc401 --- /dev/null +++ b/resolutions/fees_test.go @@ -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) + }) + } +} diff --git a/resolutions/resolutions.go b/resolutions/resolutions.go new file mode 100644 index 0000000..cb3e35d --- /dev/null +++ b/resolutions/resolutions.go @@ -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 +} diff --git a/resolutions/resolutions_test.go b/resolutions/resolutions_test.go new file mode 100644 index 0000000..136bb37 --- /dev/null +++ b/resolutions/resolutions_test.go @@ -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") + } +}