mirror of
https://github.com/lightninglabs/faraday.git
synced 2026-08-13 12:33:35 +02:00
428 lines
12 KiB
Go
428 lines
12 KiB
Go
package frdrpcserver
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
|
"github.com/lightninglabs/faraday/accounting"
|
|
"github.com/lightninglabs/faraday/fees"
|
|
"github.com/lightninglabs/faraday/fiat"
|
|
"github.com/lightninglabs/faraday/frdrpc"
|
|
"github.com/lightninglabs/lndclient"
|
|
"github.com/lightningnetwork/lnd/routing/route"
|
|
"github.com/shopspring/decimal"
|
|
)
|
|
|
|
// Since Bitcoin blocks are not guaranteed to be completely ordered
|
|
// by timestamp, and the timestamps can be manipulated by miners within a
|
|
// certain range, we will apply a buffer on the time range which we use to
|
|
// find start and end block heights. This should ensure we widen the block
|
|
// height range enough to fetch all relevant transactions within a time range.
|
|
const blockTimeRangeBuffer = time.Hour * 24
|
|
|
|
var (
|
|
// ErrNoCategoryName is returned if a category does not have a name.
|
|
ErrNoCategoryName = errors.New("category must have a name")
|
|
|
|
// ErrSetChain is returned when on on/off chain boolean is set for
|
|
// a category
|
|
ErrSetChain = errors.New("category must be for on chain, off chain " +
|
|
"or both")
|
|
)
|
|
|
|
// parseNodeAuditRequest parses a report request and returns the config
|
|
// required to produce a report containing on chain and off chain.
|
|
func parseNodeAuditRequest(ctx context.Context, cfg *Config,
|
|
req *frdrpc.NodeAuditRequest) (*accounting.OnChainConfig,
|
|
*accounting.OffChainConfig, error) {
|
|
|
|
start, end, err := validateTimes(req.StartTime, req.EndTime)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// We lookup our pubkey once so that our paid to self function does
|
|
// not need to do a lookup for every payment it checks.
|
|
info, err := cfg.Lnd.Client.GetInfo(ctx)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
priceSourceCfg, err := priceCfgFromRPC(
|
|
req.FiatBackend, req.Granularity, false, start, end,
|
|
req.CustomPrices,
|
|
)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
pubkey, err := route.NewVertexFromBytes(info.IdentityPubkey[:])
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if err := validateCustomCategories(req.CustomCategories); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
onChainCategories, offChainCategories, err := getCategories(
|
|
req.CustomCategories,
|
|
)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
offChain := accounting.NewOffChainConfig(
|
|
ctx, cfg.Lnd, uint64(maxInvoiceQueries),
|
|
uint64(maxPaymentQueries), uint64(maxForwardQueries),
|
|
pubkey, start, end, req.DisableFiat, priceSourceCfg,
|
|
offChainCategories,
|
|
)
|
|
|
|
// If we have a chain connection, set our tx lookup function. Otherwise
|
|
// log a warning.
|
|
var feeLookup fees.GetDetailsFunc
|
|
if cfg.BitcoinClient != nil {
|
|
feeLookup = cfg.BitcoinClient.GetTxDetail
|
|
} else {
|
|
log.Warn("creating accounting report without bitcoin " +
|
|
"backend, some fee entries will be missing (see logs)")
|
|
}
|
|
|
|
var blockRangeLookup func(start, end time.Time) (uint32, uint32, error)
|
|
|
|
// If a time range is set, we will use a block height lookup function
|
|
// to find the block heights for the start and end time.
|
|
timeRangeSet := req.StartTime > 0 || req.EndTime > 0
|
|
if timeRangeSet {
|
|
blockRangeLookup = func(start, end time.Time) (uint32, uint32, error) {
|
|
return resolveBlockHeightRange(
|
|
ctx, cfg.Lnd, info.BlockHeight, start, end,
|
|
)
|
|
}
|
|
}
|
|
|
|
onChain := accounting.NewOnChainConfig(
|
|
ctx, cfg.Lnd, start, end, blockRangeLookup, req.DisableFiat,
|
|
feeLookup, priceSourceCfg, onChainCategories,
|
|
)
|
|
|
|
return onChain, offChain, nil
|
|
}
|
|
|
|
// validateCustomCategories validates a set of custom categories. It checks that
|
|
// each has a name, and at least one bool indicating which transactions to
|
|
// classify, as well as checking that each regex provided is unique.
|
|
func validateCustomCategories(categories []*frdrpc.CustomCategory) error {
|
|
existing := make(map[string]struct{})
|
|
|
|
for _, category := range categories {
|
|
if category.Name == "" {
|
|
return ErrNoCategoryName
|
|
}
|
|
|
|
if !category.OffChain && !category.OnChain {
|
|
return ErrSetChain
|
|
}
|
|
|
|
for _, regex := range category.LabelPatterns {
|
|
_, ok := existing[regex]
|
|
if ok {
|
|
return fmt.Errorf("duplicate category regex: "+
|
|
"%v", regex)
|
|
}
|
|
|
|
existing[regex] = struct{}{}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func pricePointsFromRPC(prices []*frdrpc.BitcoinPrice) ([]*fiat.Price, error) {
|
|
res := make([]*fiat.Price, len(prices))
|
|
|
|
for i, p := range prices {
|
|
price, err := decimal.NewFromString(p.Price)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res[i] = &fiat.Price{
|
|
Timestamp: time.Unix(int64(p.PriceTimestamp), 0),
|
|
Price: price,
|
|
Currency: p.Currency,
|
|
}
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// validateCustomPricePoints checks that there is at lease one price point
|
|
// in the set before the given start time.
|
|
func validateCustomPricePoints(prices []*fiat.Price,
|
|
startTime time.Time) error {
|
|
|
|
for _, price := range prices {
|
|
if price.Timestamp.Before(startTime) {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return errors.New("expected at least one price point with a " +
|
|
"timestamp preceding the given start time")
|
|
}
|
|
|
|
func getCategories(
|
|
categories []*frdrpc.CustomCategory) ([]accounting.CustomCategory,
|
|
[]accounting.CustomCategory, error) {
|
|
|
|
var onChainCategories, offChainCategories []accounting.CustomCategory
|
|
|
|
for _, category := range categories {
|
|
cust, err := accounting.NewCustomCategory(
|
|
category.Name, category.LabelPatterns,
|
|
)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if category.OnChain {
|
|
onChainCategories = append(onChainCategories, *cust)
|
|
}
|
|
|
|
if category.OffChain {
|
|
offChainCategories = append(offChainCategories, *cust)
|
|
}
|
|
}
|
|
|
|
return onChainCategories, offChainCategories, nil
|
|
}
|
|
|
|
func rpcReportResponse(report accounting.Report) (*frdrpc.NodeAuditResponse,
|
|
error) {
|
|
|
|
entries := make([]*frdrpc.ReportEntry, len(report))
|
|
|
|
for i, entry := range report {
|
|
rpcEntry := &frdrpc.ReportEntry{
|
|
Timestamp: uint64(entry.Timestamp.Unix()),
|
|
OnChain: entry.OnChain,
|
|
CustomCategory: entry.Category,
|
|
Amount: uint64(entry.Amount),
|
|
Credit: entry.Credit,
|
|
Asset: "BTC",
|
|
Txid: entry.TxID,
|
|
Fiat: entry.FiatValue.String(),
|
|
Reference: entry.Reference,
|
|
Note: entry.Note,
|
|
BtcPrice: &frdrpc.BitcoinPrice{
|
|
Price: entry.BTCPrice.Price.String(),
|
|
Currency: entry.BTCPrice.Currency,
|
|
},
|
|
}
|
|
|
|
if !entry.BTCPrice.Timestamp.IsZero() {
|
|
rpcEntry.BtcPrice.PriceTimestamp = uint64(
|
|
entry.BTCPrice.Timestamp.Unix(),
|
|
)
|
|
}
|
|
|
|
rpcType, err := rpcEntryType(entry.Type)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rpcEntry.Type = rpcType
|
|
|
|
entries[i] = rpcEntry
|
|
}
|
|
|
|
// Sort report entries by timestamp.
|
|
sort.SliceStable(entries, func(i, j int) bool {
|
|
return entries[i].Timestamp < entries[j].Timestamp
|
|
})
|
|
|
|
return &frdrpc.NodeAuditResponse{Reports: entries}, nil
|
|
}
|
|
|
|
func rpcEntryType(t accounting.EntryType) (frdrpc.EntryType, error) {
|
|
switch t {
|
|
case accounting.EntryTypeLocalChannelOpen:
|
|
return frdrpc.EntryType_LOCAL_CHANNEL_OPEN, nil
|
|
|
|
case accounting.EntryTypeRemoteChannelOpen:
|
|
return frdrpc.EntryType_REMOTE_CHANNEL_OPEN, nil
|
|
|
|
case accounting.EntryTypeChannelOpenFee:
|
|
return frdrpc.EntryType_CHANNEL_OPEN_FEE, nil
|
|
|
|
case accounting.EntryTypeChannelClose:
|
|
return frdrpc.EntryType_CHANNEL_CLOSE, nil
|
|
|
|
case accounting.EntryTypeReceipt:
|
|
return frdrpc.EntryType_RECEIPT, nil
|
|
|
|
case accounting.EntryTypePayment:
|
|
return frdrpc.EntryType_PAYMENT, nil
|
|
|
|
case accounting.EntryTypeFee:
|
|
return frdrpc.EntryType_FEE, nil
|
|
|
|
case accounting.EntryTypeCircularReceipt:
|
|
return frdrpc.EntryType_CIRCULAR_RECEIPT, nil
|
|
|
|
case accounting.EntryTypeForward:
|
|
return frdrpc.EntryType_FORWARD, nil
|
|
|
|
case accounting.EntryTypeForwardFee:
|
|
return frdrpc.EntryType_FORWARD_FEE, nil
|
|
|
|
case accounting.EntryTypeCircularPayment:
|
|
return frdrpc.EntryType_CIRCULAR_PAYMENT, nil
|
|
|
|
case accounting.EntryTypeCircularPaymentFee:
|
|
return frdrpc.EntryType_CIRCULAR_FEE, nil
|
|
|
|
case accounting.EntryTypeSweep:
|
|
return frdrpc.EntryType_SWEEP, nil
|
|
|
|
case accounting.EntryTypeSweepFee:
|
|
return frdrpc.EntryType_SWEEP_FEE, nil
|
|
|
|
case accounting.EntryTypeChannelCloseFee:
|
|
return frdrpc.EntryType_CHANNEL_CLOSE_FEE, nil
|
|
|
|
default:
|
|
return 0, fmt.Errorf("unknown entrytype: %v", t)
|
|
}
|
|
}
|
|
|
|
// resolveBlockHeightRange determines the block height range that should be
|
|
// used for in queries based on the start and end time of the report.
|
|
// The function will apply a buffer to ensure the block height range is
|
|
// too large rather than too small, so that all relevant transactions are
|
|
// fetched from the backend.
|
|
func resolveBlockHeightRange(ctx context.Context,
|
|
lndClient lndclient.LndServices, latestHeight uint32,
|
|
startTime, endTime time.Time) (uint32, uint32, error) {
|
|
|
|
// Apply a buffer on the start time which we use to find the block height.
|
|
// This should ensure we use a low enough height to fetch all relevant
|
|
// transactions following the start time.
|
|
bufferedStartTime := startTime.Add(-blockTimeRangeBuffer)
|
|
|
|
if bufferedStartTime.Before(time.Unix(0, 0)) {
|
|
bufferedStartTime = time.Unix(0, 0)
|
|
}
|
|
|
|
startHeight, err := findFirstBlockBeforeTimestamp(
|
|
ctx, lndClient, latestHeight, bufferedStartTime,
|
|
)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
// Apply a buffer on the end time which we use to find the block height.
|
|
// This should ensure we use a high enough height to fetch all relevant
|
|
// transactions up to the end time.
|
|
bufferedEndTime := endTime.Add(blockTimeRangeBuffer)
|
|
|
|
endHeight, err := findFirstBlockBeforeTimestamp(
|
|
ctx, lndClient, latestHeight, bufferedEndTime,
|
|
)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
if startHeight > endHeight {
|
|
log.Errorf("Start height: %v is greater than end height: %v, "+
|
|
"setting both to 0", startHeight, endHeight)
|
|
|
|
// If startHeight somehow ended up being greater than endHeight,
|
|
// set both start and end height to 0, meaning we will query for
|
|
// all onchain history.
|
|
startHeight = 0
|
|
endHeight = 0
|
|
}
|
|
|
|
return startHeight, endHeight, nil
|
|
}
|
|
|
|
// findFirstBlockBeforeTimestamp finds the block height from just before the
|
|
// given timestamp.
|
|
func findFirstBlockBeforeTimestamp(ctx context.Context,
|
|
lndClient lndclient.LndServices, latestHeight uint32,
|
|
targetTime time.Time) (uint32, error) {
|
|
|
|
targetTimestamp := targetTime.Unix()
|
|
|
|
// Set the search range to the genesis block and the latest block.
|
|
low := uint32(0)
|
|
high := latestHeight
|
|
|
|
// Perform binary search to find the block height that is just before the
|
|
// target timestamp.
|
|
for low <= high {
|
|
mid := (low + high) / 2
|
|
|
|
// Lookup the block in the middle of the search range.
|
|
blockHash, err := getBlockHash(ctx, lndClient, mid)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
blockTime, err := getBlockTimestamp(ctx, lndClient, blockHash)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
blockTimestamp := blockTime.Unix()
|
|
if blockTimestamp < targetTimestamp {
|
|
// If the block we looked up is before the target timestamp,
|
|
// we set the new low height to the next block after that.
|
|
low = mid + 1
|
|
} else if blockTimestamp > targetTimestamp {
|
|
// If the block we looked up is after the target timestamp,
|
|
// we set the new high height to the block before that.
|
|
high = mid - 1
|
|
} else {
|
|
// If we find an exact match of block timestamp and target
|
|
// timestamp, ruturn the height of this block.
|
|
return mid, nil
|
|
}
|
|
}
|
|
|
|
log.Debugf("Binary search done for targetTimestamp: %v. "+
|
|
"Returning height: %v", targetTimestamp, high)
|
|
|
|
// Closest block before the timestamp.
|
|
return high, nil
|
|
}
|
|
|
|
// getBlockHash retrieves the block hash for a given height.
|
|
func getBlockHash(ctx context.Context, lndClient lndclient.LndServices,
|
|
height uint32) (chainhash.Hash, error) {
|
|
|
|
blockHash, err := lndClient.ChainKit.GetBlockHash(ctx, int64(height))
|
|
if err != nil {
|
|
return chainhash.Hash{}, err
|
|
}
|
|
|
|
return blockHash, nil
|
|
}
|
|
|
|
// getBlockTimestamp retrieves the block timestamp for a given block hash.
|
|
func getBlockTimestamp(ctx context.Context,
|
|
lndClient lndclient.LndServices, hash chainhash.Hash) (time.Time, error) {
|
|
|
|
blockHeader, err := lndClient.ChainKit.GetBlockHeader(ctx, hash)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
|
|
return blockHeader.Timestamp, nil
|
|
}
|