Compare commits

..

No commits in common. "master" and "v0.2.10-alpha" have entirely different histories.

102 changed files with 2423 additions and 12840 deletions

View file

@ -1,12 +0,0 @@
# Config for the Gemini Pull Request Review Bot.
# https://github.com/marketplace/gemini-code-assist
have_fun: false
code_review:
disable: false
comment_severity_threshold: MEDIUM
max_review_comments: -1
pull_request_opened:
help: false
summary: true
code_review: true
ignore_patterns: []

View file

@ -1,44 +0,0 @@
name: Docker image build
on:
push:
tags:
- 'v*'
defaults:
run:
shell: bash
jobs:
main:
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: lightninglabs/gh-actions/setup-qemu-action@2021.01.25.00
- name: Set up Docker buildx
uses: lightninglabs/gh-actions/setup-buildx-action@2021.01.25.00
- name: Login to DockerHub
uses: lightninglabs/gh-actions/login-action@2021.01.25.00
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_API_KEY }}
- name: Set env
run: |
echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
DOCKER_REPO_DEFAULT=${{secrets.DOCKER_REPO}}
echo "DOCKER_REPO=${DOCKER_REPO_DEFAULT:-lightninglabs/faraday}" >> $GITHUB_ENV
- name: Build and push image
id: docker_build
uses: lightninglabs/gh-actions/build-push-action@2021.01.25.00
with:
push: true
platforms: linux/amd64,linux/arm64
tags: "${{ env.DOCKER_REPO }}:${{ env.RELEASE_VERSION }}"
build-args: checkout=${{ env.RELEASE_VERSION }}
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

View file

@ -21,7 +21,7 @@ env:
# /Dockerfile
# /frdrpc/Dockerfile
# /itest/Dockerfile
GO_VERSION: 1.25.10
GO_VERSION: 1.19.4
jobs:
########################
@ -50,28 +50,10 @@ jobs:
- name: run imports check
run: make fmt
- name: run JS stubs check
run: make rpc-js-compile
#######################
# sql model generation
#######################
sqlc-check:
name: Sqlc check
runs-on: ubuntu-latest
steps:
- name: git checkout
uses: actions/checkout@v2
- name: setup go ${{ env.GO_VERSION }}
uses: actions/setup-go@v2
with:
go-version: '${{ env.GO_VERSION }}'
- name: Generate sql models
run: make sqlc-check
########################
# lint code
########################
@ -104,10 +86,8 @@ jobs:
fail-fast: false
matrix:
unit_type:
- unit-race
- unit
- unit dbbackend=postgres
- unit dbbackend=sqlite
- unit-race
- itest
steps:
- name: git checkout

View file

@ -1,29 +1,27 @@
run:
# timeout for analysis
timeout: 4m
deadline: 4m
linters-settings:
govet:
# Don't report about shadowed variables
check-shadowing: false
gofmt:
# simplify code: gofmt with `-s` option, true by default
simplify: true
tagliatelle:
case:
rules:
json: snake
whitespace:
multi-func: true
multi-if: true
gosec:
excludes:
- G402 # Look for bad TLS connection settings.
- G306 # Poor file permissions used when writing to a new file.
- G601 # Implicit memory aliasing in for loop.
- G115 # Integer overflow in conversion.
staticcheck:
go: "1.18"
checks: ["-SA1019"]
linters:

View file

@ -1,4 +1,4 @@
FROM golang:1.25.10-alpine as builder
FROM golang:1.18.8-alpine as builder
# Force Go to use the cgo based DNS resolver. This is required to ensure DNS
# queries required to connect to linked containers succeed.
@ -11,9 +11,9 @@ RUN apk add --no-cache --update alpine-sdk \
git \
make \
gcc \
&& cd /go/src/github.com/lightninglabs/faraday \
&& make \
&& make install
&& cd /go/src/github.com/lightninglabs/faraday \
&& make \
&& make install
# Start a new, final image.
FROM alpine as final

View file

@ -27,11 +27,8 @@ XARGS := xargs -L 1
include make/testing_flags.mk
DOCKER_TOOLS = docker run \
-v $(shell bash -c "go env GOCACHE || (mkdir -p /tmp/go-cache; echo /tmp/go-cache)"):/tmp/build/.cache \
-v $(shell bash -c "go env GOMODCACHE || (mkdir -p /tmp/go-modcache; echo /tmp/go-modcache)"):/tmp/build/.modcache \
-v $(shell bash -c "mkdir -p /tmp/go-lint-cache; echo /tmp/go-lint-cache"):/root/.cache/golangci-lint \
-v $$(pwd):/build faraday-tools
LINT = $(LINT_BIN) run -v
DOCKER_TOOLS = docker run -v $$(pwd):/build faraday-tools
default: scratch
@ -137,14 +134,6 @@ rpc-js-compile:
@$(call print, "Compiling JSON/WASM stubs.")
GOOS=js GOARCH=wasm $(GOBUILD) $(PKG)/frdrpc
sqlc:
@$(call print, "Generating sql models and queries in Go")
./scripts/gen_sqlc_docker.sh
sqlc-check: sqlc
@$(call print, "Verifying sql code generation.")
if test -n "$$(git status --porcelain '*.go')"; then echo "SQL models not properly generated!"; git status --porcelain '*.go'; exit 1; fi
list:
@$(call print, "Listing commands.")
@$(MAKE) -qp | \

View file

@ -6,7 +6,7 @@ Faraday is a suite of tools built to help node operators and businesses run [lnd
## LND
Note that Faraday requires lnd to be built with **all of its subservers** and requires running at least v0.11.1. Download the [official release binary](https://github.com/lightningnetwork/lnd/releases/tag/v0.11.1-beta) or see the [instructions](https://github.com/lightningnetwork/lnd/blob/master/docs/INSTALL.md) in the lnd repo for more detailed installation instructions. If you choose to build lnd from source, following command to enable all the relevant subservers:
```shell
```
make install tags="signrpc walletrpc chainrpc invoicesrpc"
```
@ -14,19 +14,15 @@ make install tags="signrpc walletrpc chainrpc invoicesrpc"
## Installation
A [Makefile](https://github.com/lightninglabs/faraday/blob/master/Makefile) is provided. To install faraday and all its dependencies, run:
```shell
```
git clone https://github.com/lightninglabs/faraday.git
cd faraday
make && make install
```
## Usage
Faraday connects to a single instance of lnd. It requires access to `lnd`'s
`admin.macaroon` (or a custom scoped macaroon, see below) and a valid TLS
certificate. It will attempt to use the default `lnd` values if no command line
flags are specified.
```shell
Faraday connects to a single instance of lnd. It requires access to `lnd`'s `admin.macaroon` and a valid TLS certificate. It will attempt to use the default `lnd` values if no command line flags are specified.
```
./faraday \
--lnd.macaroonpath={full path to lnd's admin.macaroon} \
--lnd.tlscertpath={path to lnd cert} \
@ -36,17 +32,6 @@ flags are specified.
By default, faraday runs on mainnet. The `--network` flag can be used to run in
test environments.
### Baking a custom macaroon for Faraday
Faraday needs to derive a shared key with `lnd` to create an encryption password
for its macaroon database. That's why on top of the permissions in the
`readonly.macaroon` the `uri:/signrpc.Signer/DeriveSharedKey` is also required.
A custom scoped macaroon just for Faraday can be baked with:
```shell
lncli bakemacaroon onchain:read offchain:read address:read peers:read info:read invoices:read uri:/signrpc.Signer/DeriveSharedKey
```
## Authentication and transport security
The gRPC and REST connections of `faraday` are encrypted with TLS and secured
@ -71,7 +56,7 @@ cannot be used for both `faraday` and `lnd`.
Faraday offers node accounting services which require access to a Bitcoin node with `--txindex` set so that it can perform transaction lookup. Currently the `CloseReport` endpoint requires this connection, and will fail if it is not present. It is *strongly recommended* to provide this connection when utilizing the `NodeAudit` endpoint, but it is not required. This connection is *optional*, and all other endpoints will function if it is not configured.
To connect Faraday to bitcoind:
```text
```
--connect_bitcoin \
--bitcoin.host={host:port of bitcoind} \
--bitcoin.user={bitcoind username} \
@ -79,7 +64,7 @@ To connect Faraday to bitcoind:
```
To connect Faraday to btcd:
```text
```
--connect_bitcoin \
--bitcoin.host={host:port of btcd} \
--bitcoin.user={btcd username} \
@ -90,50 +75,14 @@ To connect Faraday to btcd:
#### RPCServer
Faraday serves requests over grpc by default on `localhost:8465`. This default can be overwritten:
```text
```
--rpclisten={host:port to listen for requests}
```
#### Channel Event Storage
Faraday records channel events (online/offline transitions and balance updates)
in its database. On high-frequency channels this table can grow without bound,
so a size ceiling is enabled by default, with an optional age-based retention
window:
```text
--chanevents.max-events={maximum number of events to retain}
--chanevents.retention={minimum duration of events to keep, e.g. 1440h}
```
By default only the size ceiling is active: a maximum of 7 million events
(`--chanevents.max-events=7000000`, roughly 1 GB of storage). Age-based
retention is disabled by default (`--chanevents.retention=0`) so that history is
never aged out unless an operator opts in. The retention window is a Go duration
string (e.g. `1440h` for 60 days). The two limits are applied independently:
1. **Size Ceiling (Hard Limit):** If the database exceeds `max-events`, older
events are pruned unconditionally to ensure the database size is strictly
capped, preventing disk filling. Newer events inside the retention window can
still be pruned if needed to satisfy this size limit.
2. **Age Threshold (Freshness):** When `retention` is set to a non-zero
duration, any events older than that window are automatically pruned to keep
history fresh, even if the database size is below `max-events`.
Because the two limits are independent, disabling pruning entirely requires
turning off both: `--chanevents.max-events=0 --chanevents.retention=0`. Setting
only `max-events=0` disables the size ceiling, leaving the table bounded only by
any retention window that has been configured.
As a rough rule of thumb, each channel event consumes on the order of 140 bytes
of storage once table and index overhead is taken into account. The default
`--chanevents.max-events=7000000` therefore bounds the table at roughly 1 GB
(7 million events × ~140 bytes ≈ 1 GB). For roughly 100 MB use
`--chanevents.max-events=700000`. These are approximations measured on a
compacted SQLite database, and actual usage runs higher on a live database
(write-ahead log, page fragmentation) and varies by backend.
#### Cli Tool
The RPC server can be conveniently accessed using a command line tool.
1. Run faraday as detailed above
```shell
```
./frcli {command}
```
@ -159,15 +108,15 @@ If you would like to contribute to Faraday, please see our [issues page](https:/
### Tests
To run all the unit tests in the repo:
```shell
```
make check
```
To run Faraday's itests locally, you will need docker installed. To run all itests:
```shell
```
make itest
```
Individual itests can also be run using:
```shell
```
./run_itest.sh {test name}
```

View file

@ -1,9 +1,6 @@
package accounting
import (
"fmt"
"regexp"
)
import "regexp"
// CustomCategory describes a custom category which can be used to identify
// special case groups of transactions.
@ -27,8 +24,7 @@ func NewCustomCategory(name string, regexes []string) (*CustomCategory, error) {
for _, regex := range regexes {
exp, err := regexp.Compile(regex)
if err != nil {
return nil, fmt.Errorf("category %v: compiling regex "+
"%v failed: %w", name, regex, err)
return nil, err
}
category.Regexes = append(category.Regexes, exp)

View file

@ -97,10 +97,8 @@ type CommonConfig struct {
// The txLookup function may be nil if a connection to a bitcoin backend is not
// available. If this is the case, the fee report will log warnings indicating
// that fee lookups are not possible in certain cases.
func NewOnChainConfig(ctx context.Context,
lnd lndclient.LndServices, startTime, endTime time.Time,
blockRangeLookup func(start, end time.Time) (uint32, uint32, error),
disableFiat bool, txLookup fees.GetDetailsFunc,
func NewOnChainConfig(ctx context.Context, lnd lndclient.LndServices, startTime,
endTime time.Time, disableFiat bool, txLookup fees.GetDetailsFunc,
priceCfg *fiat.PriceSourceConfig,
categories []CustomCategory) *OnChainConfig {
@ -111,29 +109,6 @@ func NewOnChainConfig(ctx context.Context,
}
}
// Set both start and end height to 0, meaning we will query for all
// onchain history.
startHeight := uint32(0)
endHeight := uint32(0)
if blockRangeLookup != nil {
var err error
startHeight, endHeight, err = blockRangeLookup(startTime, endTime)
if err != nil {
log.Errorf("Error finding block height range for start time: %v "+
"end time: %v error: %v", startTime, endTime, err)
// If we cannot find the block height range, set both start and end
// height to 0, meaning we will query for all onchain history.
startHeight = 0
endHeight = 0
}
}
log.Debugf("Using startheight: %v endheight: %v while querying onchain "+
"activity", startHeight, endHeight)
return &OnChainConfig{
OpenChannels: lndwrap.ListChannels(
ctx, lnd.Client, false,
@ -145,12 +120,10 @@ func NewOnChainConfig(ctx context.Context,
return lnd.Client.PendingChannels(ctx)
},
OnChainTransactions: func() ([]lndclient.Transaction, error) {
return lnd.Client.ListTransactions(
ctx, int32(startHeight), int32(endHeight),
)
return lnd.Client.ListTransactions(ctx, 0, 0)
},
ListSweeps: func() ([]string, error) {
return lnd.WalletKit.ListSweeps(ctx, int32(startHeight))
return lnd.WalletKit.ListSweeps(ctx)
},
CommonConfig: CommonConfig{
StartTime: startTime,

View file

@ -2,7 +2,6 @@ package accounting
import (
"context"
"fmt"
"time"
"github.com/btcsuite/btcd/btcutil"
@ -45,18 +44,12 @@ func getConversion(ctx context.Context, startTime, endTime time.Time,
err := utils.ValidateTimeRange(startTime, endTime)
if err != nil {
return nil, fmt.Errorf("conversion: invalid time range [%v,%v): %w",
startTime, endTime, err)
return nil, err
}
fiatClient, err := fiat.NewPriceSource(priceCfg)
if err != nil {
backend := "<nil>"
if priceCfg != nil {
backend = priceCfg.Backend.String()
}
return nil, fmt.Errorf("conversion: initialising price "+
"source backend %v failed: %w", backend, err)
return nil, err
}
// Get price data for our relevant period. We get pricing for the whole
@ -64,19 +57,12 @@ func getConversion(ctx context.Context, startTime, endTime time.Time,
// calls we need to make to our external data source.
prices, err := fiatClient.GetPrices(ctx, startTime, endTime)
if err != nil {
return nil, fmt.Errorf("conversion: fetching prices for "+
"range [%v,%v) failed: %w", startTime, endTime, err)
return nil, err
}
// Create a wrapper function which can be used to get individual price
// points from our set of price data as we create our report.
return func(ts time.Time) (*fiat.Price, error) {
price, err := fiat.GetPrice(prices, ts)
if err != nil {
return nil, fmt.Errorf("conversion: fetching price "+
"at %v failed: %w", ts, err)
}
return price, nil
return fiat.GetPrice(prices, ts)
}, nil
}

View file

@ -80,8 +80,7 @@ func channelOpenEntries(channel channelInfo, tx lndclient.Transaction,
true, u.getFiat,
)
if err != nil {
return nil, fmt.Errorf("tx %v channel %v: creating open entry "+
"failed: %w", tx.TxHash, channel.channelID, err)
return nil, err
}
// If we did not initiate opening the channel, we can just return the
@ -102,9 +101,7 @@ func channelOpenEntries(channel channelInfo, tx lndclient.Transaction,
FeeReference(tx.TxHash), note, category, true, u.getFiat,
)
if err != nil {
return nil, fmt.Errorf("tx %v channel %v: creating channel "+
"open fee entry failed: %w", tx.TxHash,
channel.channelID, err)
return nil, err
}
return []*HarmonyEntry{openEntry, feeEntry}, nil
@ -138,9 +135,7 @@ func closedChannelEntries(channel closedChannelInfo, tx lndclient.Transaction,
tx.TxHash, note, category, true, u.getFiat,
)
if err != nil {
return nil, fmt.Errorf("tx %v channel %v: creating channel "+
"close entry failed: %w", tx.TxHash, channel.channelID,
err)
return nil, err
}
switch channel.initiator {
@ -177,9 +172,7 @@ func closedChannelEntries(channel closedChannelInfo, tx lndclient.Transaction,
fees, err := u.getFee(tx.Tx.TxHash())
if err != nil {
return nil, fmt.Errorf("tx %v channel %v: fetching on-chain "+
"close fees failed: %w", tx.TxHash, channel.channelID,
err)
return nil, err
}
// Our fees are provided as a positive amount in sats. Convert this to
@ -192,9 +185,7 @@ func closedChannelEntries(channel closedChannelInfo, tx lndclient.Transaction,
true, u.getFiat,
)
if err != nil {
return nil, fmt.Errorf("tx %v channel %v: creating channel "+
"close fee entry failed: %w", tx.TxHash,
channel.channelID, err)
return nil, err
}
return []*HarmonyEntry{closeEntry, feeEntry}, nil
@ -210,8 +201,7 @@ func sweepEntries(tx lndclient.Transaction, u entryUtils) ([]*HarmonyEntry, erro
tx.TxHash, tx.Label, category, true, u.getFiat,
)
if err != nil {
return nil, fmt.Errorf("tx %v: creating sweep entry failed: %w",
tx.TxHash, err)
return nil, err
}
// If we do not have a fee lookup function set, we log a warning that
@ -226,8 +216,7 @@ func sweepEntries(tx lndclient.Transaction, u entryUtils) ([]*HarmonyEntry, erro
fee, err := u.getFee(tx.Tx.TxHash())
if err != nil {
return nil, fmt.Errorf("tx %v: fetching sweep fee failed: %w",
tx.TxHash, err)
return nil, err
}
feeEntry, err := newHarmonyEntry(
@ -236,70 +225,21 @@ func sweepEntries(tx lndclient.Transaction, u entryUtils) ([]*HarmonyEntry, erro
u.getFiat,
)
if err != nil {
return nil, fmt.Errorf("tx %v: creating sweep fee entry "+
"failed: %w", tx.TxHash, err)
return nil, err
}
return []*HarmonyEntry{txEntry, feeEntry}, nil
}
// isUtxoManagementTx checks whether a transaction is restructuring our utxos.
func isUtxoManagementTx(txn lndclient.Transaction) bool {
// Check all inputs.
for _, input := range txn.PreviousOutpoints {
if !input.IsOurOutput {
return false
}
}
// Check all outputs.
for _, output := range txn.OutputDetails {
if !output.IsOurAddress {
return false
}
}
// If all inputs and outputs belong to our wallet, it's utxo management.
return true
}
// createOnchainFeeEntry creates a fee entry for an on chain transaction.
func createOnchainFeeEntry(tx lndclient.Transaction, category string,
note string, u entryUtils) (*HarmonyEntry, error) {
// Total fees are expressed as a positive value in sats, we convert to
// msat here and make the value negative so that it reflects as a
// debit.
feeAmt := invertedSatsToMsats(tx.Fee)
feeEntry, err := newHarmonyEntry(
tx.Timestamp, feeAmt, EntryTypeFee,
tx.TxHash, FeeReference(tx.TxHash), note, category, true,
u.getFiat,
)
if err != nil {
return nil, fmt.Errorf("tx %v: creating on-chain fee entry "+
"failed: %w", tx.TxHash, err)
}
return feeEntry, nil
}
// utxoManagementFeeNote creates a note for utxo management fee types.
func utxoManagementFeeNote(txid string) string {
return fmt.Sprintf("fees for utxo management transaction: %v", txid)
}
// onChainEntries produces relevant entries for an on chain transaction.
func onChainEntries(tx lndclient.Transaction,
u entryUtils) ([]*HarmonyEntry, error) {
var (
amtMsat = satsToMsat(tx.Amount)
entryType EntryType
category = getCategory(tx.Label, u.customCategories)
utxoManagement bool
amtMsat = satsToMsat(tx.Amount)
entryType EntryType
feeType = EntryTypeFee
category = getCategory(tx.Label, u.customCategories)
)
// Determine the type of entry we are creating. If this is a sweep, we
@ -312,9 +252,6 @@ func onChainEntries(tx lndclient.Transaction,
case amtMsat > 0:
entryType = EntryTypeReceipt
case isUtxoManagementTx(tx):
utxoManagement = true
// If we have a zero amount on chain transaction, we do not create an
// entry for it. This may happen when the remote party claims a htlc on
// our commitment. We do not want to report 0 value transactions that
@ -323,26 +260,12 @@ func onChainEntries(tx lndclient.Transaction,
return nil, nil
}
// If this is a utxo management transaction, we return a fee entry only.
if utxoManagement {
note := utxoManagementFeeNote(tx.TxHash)
feeEntry, err := createOnchainFeeEntry(tx, category, note, u)
if err != nil {
return nil, fmt.Errorf("tx %v: creating utxo "+
"management fee entry failed: %w", tx.TxHash,
err)
}
return []*HarmonyEntry{feeEntry}, nil
}
txEntry, err := newHarmonyEntry(
tx.Timestamp, amtMsat, entryType, tx.TxHash, tx.TxHash,
tx.Label, category, true, u.getFiat,
)
if err != nil {
return nil, fmt.Errorf("tx %v: creating on-chain transaction "+
"entry failed: %w", tx.TxHash, err)
return nil, err
}
// If we did not pay any fees, we can just return a single entry.
@ -350,10 +273,17 @@ func onChainEntries(tx lndclient.Transaction,
return []*HarmonyEntry{txEntry}, nil
}
feeEntry, err := createOnchainFeeEntry(tx, category, "", u)
// Total fees are expressed as a positive value in sats, we convert to
// msat here and make the value negative so that it reflects as a
// debit.
feeAmt := invertedSatsToMsats(tx.Fee)
feeEntry, err := newHarmonyEntry(
tx.Timestamp, feeAmt, feeType, tx.TxHash,
FeeReference(tx.TxHash), "", category, true, u.getFiat,
)
if err != nil {
return nil, fmt.Errorf("tx %v: creating on-chain fee entry "+
"failed: %w", tx.TxHash, err)
return nil, err
}
return []*HarmonyEntry{txEntry, feeEntry}, nil
@ -418,18 +348,11 @@ func paymentReference(sequenceNumber uint64, preimage lntypes.Preimage) string {
// paymentNote creates a note for payments from our node.
// nolint: interfacer
func paymentNote(dest *route.Vertex, memo *string) string {
var notes []string
if memo != nil && *memo != "" {
notes = append(notes, fmt.Sprintf("memo: %v", *memo))
func paymentNote(dest *route.Vertex) string {
if dest == nil {
return ""
}
if dest != nil {
notes = append(notes, fmt.Sprintf("destination: %v", dest))
}
return strings.Join(notes, "/")
return dest.String()
}
// paymentEntry creates an entry for an off chain payment, including fee entries
@ -456,7 +379,7 @@ func paymentEntry(payment paymentInfo, paidToSelf bool,
// Create a note for our payment. Since we have already checked that our
// payment is settled, we will not have a nil preimage.
note := paymentNote(payment.destination, payment.description)
note := paymentNote(payment.destination)
ref := paymentReference(payment.SequenceNumber, *payment.Preimage)
// Payment values are expressed as positive values over rpc, but they
@ -468,8 +391,7 @@ func paymentEntry(payment paymentInfo, paidToSelf bool,
ref, note, "", false, u.getFiat,
)
if err != nil {
return nil, fmt.Errorf("payment %v: creating payment entry "+
"failed: %w", payment.Hash, err)
return nil, err
}
// If we paid no fees (possible for payments to our direct peer), then
@ -486,8 +408,7 @@ func paymentEntry(payment paymentInfo, paidToSelf bool,
feeRef, note, "", false, u.getFiat,
)
if err != nil {
return nil, fmt.Errorf("payment %v: creating payment fee "+
"entry failed: %w", payment.Hash, err)
return nil, err
}
return []*HarmonyEntry{paymentEntry, feeEntry}, nil
}
@ -521,8 +442,7 @@ func forwardingEntry(forward lndclient.ForwardingEvent,
false, u.getFiat,
)
if err != nil {
return nil, fmt.Errorf("forward %v: creating forwarding "+
"entry failed: %w", txid, err)
return nil, err
}
// If we did not earn any fees, return the forwarding entry.
@ -535,8 +455,7 @@ func forwardingEntry(forward lndclient.ForwardingEvent,
EntryTypeForwardFee, txid, "", "", "", false, u.getFiat,
)
if err != nil {
return nil, fmt.Errorf("forward %v: creating forwarding fee "+
"entry failed: %w", txid, err)
return nil, err
}
return []*HarmonyEntry{fwdEntry, feeEntry}, nil

View file

@ -83,6 +83,10 @@ var (
Tx: &wire.MsgTx{},
}
paymentRequest = "lnbcrt10n1p0t6nmypp547evsfyrakg0nmyw59ud9cegkt99yccn5nnp4suq3ac4qyzzgevsdqqcqzpgsp54hvffpajcyddm20k3ptu53930425hpnv8m06nh5jrd6qhq53anrq9qy9qsqphhzyenspf7kfwvm3wyu04fa8cjkmvndyexlnrmh52huwa4tntppjmak703gfln76rvswmsx2cz3utsypzfx40dltesy8nj64ttgemgqtwfnj9"
invoiceMemo = "memo"
invoiceAmt = lnwire.MilliSatoshi(300)
invoiceOverpaidAmt = lnwire.MilliSatoshi(400)
@ -108,7 +112,7 @@ var (
paymentTime = time.Unix(1590399649, 0)
paymentHash = "0001020304050607080900010203040506070809000102030405060708090102"
paymentHash = "11f414479f0a0c2762492c71c58dded5dce99d56d65c3fa523f73513605bebb3"
pmtHash, _ = lntypes.MakeHashFromStr(paymentHash)
paymentPreimage = "adfef20b24152accd4ed9a05257fb77203d90a8bbbe6d4069a75c5320f0538d9"
@ -130,13 +134,11 @@ var (
Fee: lnwire.MilliSatoshi(paymentFeeMsat),
Htlcs: []*lnrpc.HTLCAttempt{{}},
SequenceNumber: uint64(paymentIndex),
PaymentRequest: paymentRequest,
}
payInfo = paymentInfo{
Payment: payment,
destination: &otherPubkey,
description: &invoiceMemo,
settleTime: paymentTime,
}
@ -499,13 +501,12 @@ func TestSweepEntry(t *testing.T) {
// TestOnChainEntry tests creation of entries for receipts and payments, and the
// generation of a fee entry where applicable.
func TestOnChainEntry(t *testing.T) {
getOnChainEntry := func(amount btcutil.Amount, hasFee bool,
isUtxoManagement bool, label string, note string) []*HarmonyEntry {
getOnChainEntry := func(amount btcutil.Amount,
hasFee bool, label string) []*HarmonyEntry {
var (
entryType EntryType
feeType = EntryTypeFee
utxoManagement bool
entryType EntryType
feeType = EntryTypeFee
)
switch {
@ -515,33 +516,10 @@ func TestOnChainEntry(t *testing.T) {
case amount > 0:
entryType = EntryTypeReceipt
case isUtxoManagement:
utxoManagement = true
default:
return nil
}
if utxoManagement {
feeAmt := satsToMsat(onChainFeeSat)
feeMsat := lnwire.MilliSatoshi(feeAmt)
feeEntry := &HarmonyEntry{
Timestamp: onChainTimestamp,
Amount: feeMsat,
FiatValue: fiat.MsatToFiat(mockBTCPrice.Price, feeMsat),
TxID: onChainTxID,
Reference: FeeReference(onChainTxID),
Note: note,
Type: feeType,
OnChain: true,
Credit: false,
BTCPrice: mockBTCPrice,
}
return []*HarmonyEntry{feeEntry}
}
amt := satsToMsat(onChainAmtSat)
amtMsat := lnwire.MilliSatoshi(amt)
entry := &HarmonyEntry{
@ -571,7 +549,7 @@ func TestOnChainEntry(t *testing.T) {
FiatValue: fiat.MsatToFiat(mockBTCPrice.Price, feeMsat),
TxID: onChainTxID,
Reference: FeeReference(onChainTxID),
Note: note,
Note: "",
Type: feeType,
OnChain: true,
Credit: false,
@ -591,14 +569,8 @@ func TestOnChainEntry(t *testing.T) {
// Whether the transaction has a fee attached.
hasFee bool
// Whether the transaction is a sweep.
isUtxoManagement bool
// txLabel is an optional label on the rpc transaction.
txLabel string
// Note is the expected note on the entry.
note string
}{
{
name: "receive with fee",
@ -625,13 +597,6 @@ func TestOnChainEntry(t *testing.T) {
amount: 0,
hasFee: false,
},
{
name: "zero amount utxo management tx",
amount: 0,
hasFee: true,
isUtxoManagement: true,
note: utxoManagementFeeNote(onChainTxID),
},
}
for _, test := range tests {
@ -650,13 +615,6 @@ func TestOnChainEntry(t *testing.T) {
chainTx.Fee = 0
}
chainTx.PreviousOutpoints = []*lnrpc.PreviousOutPoint{{
IsOurOutput: test.isUtxoManagement,
}}
chainTx.OutputDetails = []*lnrpc.OutputDetail{{
IsOurAddress: test.isUtxoManagement,
}}
// Set the label as per the test.
chainTx.Label = test.txLabel
@ -666,7 +624,7 @@ func TestOnChainEntry(t *testing.T) {
// Create the entries we expect based on the test
// params.
expected := getOnChainEntry(
test.amount, test.hasFee, test.isUtxoManagement, test.txLabel, test.note,
test.amount, test.hasFee, test.txLabel,
)
require.Equal(t, expected, entries)
@ -756,7 +714,7 @@ func TestPaymentEntry(t *testing.T) {
FiatValue: fiat.MsatToFiat(mockBTCPrice.Price, amtMsat),
TxID: paymentHash,
Reference: paymentRef,
Note: paymentNote(&otherPubkey, &invoiceMemo),
Note: paymentNote(&otherPubkey),
Type: EntryTypePayment,
OnChain: false,
Credit: false,
@ -771,7 +729,7 @@ func TestPaymentEntry(t *testing.T) {
FiatValue: fiat.MsatToFiat(mockBTCPrice.Price, feeMsat),
TxID: paymentHash,
Reference: FeeReference(paymentRef),
Note: paymentNote(&otherPubkey, &invoiceMemo),
Note: paymentNote(&otherPubkey),
Type: EntryTypeFee,
OnChain: false,
Credit: false,

View file

@ -109,16 +109,15 @@ func filterInvoices(startTime, endTime time.Time,
return filtered
}
// paymentInfo wraps a lndclient payment struct with a destination, and
// description if available from the information we have available, and its
// settle time. Since we now allow multi-path payments, a single payment may
// have multiple htlcs resolved over a period of time. We use the most recent
// settle time for payment because payments are not considered settled until
// all the htlcs are resolved.
// paymentInfo wraps a lndclient payment struct with a destination, if it is
// available from the information we have available, and its settle time.
// Since we now allow multi-path payments, a single payment may have multiple
// htlcs resolved over a period of time. We use the most recent settle time for
// payment because payments are not considered settled until all the htlcs are
// resolved.
type paymentInfo struct {
lndclient.Payment
destination *route.Vertex
description *string
settleTime time.Time
}
@ -136,33 +135,24 @@ func preProcessPayments(payments []lndclient.Payment,
paymentList := make([]paymentInfo, len(payments))
for i, payment := range payments {
// Attempt to obtain the payment destination and description
// from our payment request. If this is not possible (which
// can be the case for legacy payments that did not store
// payment requests, or payments that pay directly to a
// payment hash), then try to get it from our HTLCs. Note
// that HTLCs may also not be available for legacy payments
// that did not store HTLCs. In the event that we get a
// destination from both sources, we prefer the destination
// from the HTLCs.
payReqDestination, description, err := paymentRequestDetails(
payment.PaymentRequest, decode,
)
if err != nil && err != errNoPaymentRequest {
return nil, fmt.Errorf("payment %v: retrieving "+
"payment request details failed: %w",
payment.Hash, err)
}
// Try to get our payment destination from our set of htlcs.
// If we cannot get it from our htlcs (which is the case for
// legacy payments that did not store htlcs), we try to get it
// from our payment request. This value may not be present for
// all payments, so we do not error if it is not.
destination, err := paymentHtlcDestination(payment)
if err != nil {
destination = payReqDestination
destination, err = paymentRequestDestination(
payment.PaymentRequest, decode,
)
if err != nil && err != errNoPaymentRequest {
return nil, err
}
}
pmt := paymentInfo{
Payment: payment,
destination: destination,
description: description,
}
// If the payment did not succeed, we can add it to our list
@ -216,31 +206,27 @@ func paymentHtlcDestination(payment lndclient.Payment) (*route.Vertex, error) {
lastHop := hops[len(hops)-1]
lastHopPubkey, err := route.NewVertexFromStr(lastHop.PubKey)
if err != nil {
return nil, fmt.Errorf("payment %v: parsing last hop "+
"pubkey %v failed: %w", payment.Hash, lastHop.PubKey,
err)
return nil, err
}
return &lastHopPubkey, nil
}
// paymentRequestDetails attempts to decode a payment address, and returns
// the destination and the description.
func paymentRequestDetails(paymentRequest string,
decode decodePaymentRequest) (*route.Vertex, *string, error) {
// paymentRequestDestination attempts to decode a payment address, and returns
// the destination.
func paymentRequestDestination(paymentRequest string,
decode decodePaymentRequest) (*route.Vertex, error) {
if paymentRequest == "" {
return nil, nil, errNoPaymentRequest
return nil, errNoPaymentRequest
}
payReq, err := decode(paymentRequest)
if err != nil {
return nil, nil, fmt.Errorf(
"decode payment request failed: %w", err,
)
return nil, fmt.Errorf("decode payment request failed: %w", err)
}
return &payReq.Destination, &payReq.Description, nil
return &payReq.Destination, nil
}
// filterPayments filters out unsuccessful payments and those which did not

View file

@ -378,7 +378,6 @@ func decode(toSelf bool) func(_ string) (*lndclient.PaymentRequest,
return &lndclient.PaymentRequest{
Destination: pubkey,
Description: invoiceMemo,
}, nil
}
}
@ -438,39 +437,35 @@ func TestPaymentHtlcDestination(t *testing.T) {
}
}
// TestPaymentRequestDestination tests getting of payment details from our
// TestPaymentRequestDestination tests getting of payment destinations from our
// payment request.
func TestPaymentRequestDetails(t *testing.T) {
func TestPaymentRequestDestination(t *testing.T) {
tests := []struct {
name string
paymentRequest string
decode decodePaymentRequest
destination *route.Vertex
description *string
dest *route.Vertex
err error
}{
{
name: "no payment request",
decode: decode(true),
paymentRequest: "",
destination: nil,
description: nil,
dest: nil,
err: errNoPaymentRequest,
},
{
name: "to self",
decode: decode(true),
paymentRequest: paymentRequest,
destination: &ourPubKey,
description: &invoiceMemo,
dest: &ourPubKey,
err: nil,
},
{
name: "not to self",
decode: decode(false),
paymentRequest: paymentRequest,
destination: &otherPubkey,
description: &invoiceMemo,
dest: &otherPubkey,
err: nil,
},
}
@ -481,13 +476,11 @@ func TestPaymentRequestDetails(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
destination, description, err := paymentRequestDetails(
dest, err := paymentRequestDestination(
test.paymentRequest, test.decode,
)
require.Equal(t, test.err, err)
require.Equal(t, test.destination, destination)
require.Equal(t, test.description, description)
require.Equal(t, test.dest, dest)
})
}
}

View file

@ -1,7 +1,7 @@
package accounting
import (
"github.com/btcsuite/btclog/v2"
"github.com/btcsuite/btclog"
"github.com/lightningnetwork/lnd/build"
)

View file

@ -4,7 +4,6 @@ import (
"bytes"
"context"
"errors"
"fmt"
"github.com/lightninglabs/lndclient"
"github.com/lightningnetwork/lnd/lntypes"
@ -49,9 +48,7 @@ func OffChainReport(ctx context.Context, cfg *OffChainConfig) (Report, error) {
cfg.PriceSourceCfg,
)
if err != nil {
return nil, fmt.Errorf("off-chain report: init conversion "+
"lookup for range [%v,%v) failed: %w", cfg.StartTime,
cfg.EndTime, err)
return nil, err
}
return offChainReportWithPrices(cfg, getPrice)
@ -65,8 +62,7 @@ func offChainReportWithPrices(cfg *OffChainConfig, getPrice fiatPrice) (Report,
invoices, err := cfg.ListInvoices()
if err != nil {
return nil, fmt.Errorf("off-chain report: listing invoices "+
"failed: %w", err)
return nil, err
}
filteredInvoices := filterInvoices(cfg.StartTime, cfg.EndTime, invoices)
@ -75,31 +71,25 @@ func offChainReportWithPrices(cfg *OffChainConfig, getPrice fiatPrice) (Report,
payments, err := cfg.ListPayments()
if err != nil {
return nil, fmt.Errorf("off-chain report: listing payments "+
"failed: %w", err)
return nil, err
}
preProcessed, err := preProcessPayments(payments, cfg.DecodePayReq)
if err != nil {
return nil, fmt.Errorf("off-chain report: preprocessing %d "+
"payments failed: %w", len(payments), err)
return nil, err
}
// Get a list of all the payments we made to ourselves.
paymentsToSelf, err := getCircularPayments(cfg.OwnPubKey, preProcessed)
if err != nil {
return nil, fmt.Errorf("off-chain report: identifying "+
"circular payments for node %v failed: %w",
cfg.OwnPubKey, err)
return nil, err
}
filteredPayments := filterPayments(
cfg.StartTime, cfg.EndTime, preProcessed,
)
if err := sanityCheckDuplicates(filteredPayments); err != nil {
return nil, fmt.Errorf("off-chain report: duplicate payment "+
"hashes detected in range [%v,%v): %w", cfg.StartTime,
cfg.EndTime, err)
return nil, err
}
log.Infof("Retrieved: %v payments, %v filtered, %v circular",
@ -109,8 +99,7 @@ func offChainReportWithPrices(cfg *OffChainConfig, getPrice fiatPrice) (Report,
// are already supplied over the relevant range for our query.
forwards, err := cfg.ListForwards()
if err != nil {
return nil, fmt.Errorf("off-chain report: listing forwards "+
"failed: %w", err)
return nil, err
}
log.Infof("Retrieved: %v forwards", len(forwards))
@ -144,8 +133,7 @@ func offChainReport(invoices []lndclient.Invoice, payments []paymentInfo,
entry, err := invoiceEntry(invoice, toSelf, utils)
if err != nil {
return nil, fmt.Errorf("invoice %v: creating entry "+
"failed: %w", invoice.Hash, err)
return nil, err
}
reports = append(reports, entry)
@ -158,8 +146,7 @@ func offChainReport(invoices []lndclient.Invoice, payments []paymentInfo,
entries, err := paymentEntry(payment, toSelf, utils)
if err != nil {
return nil, fmt.Errorf("payment %v: creating entries "+
"failed: %w", payment.Hash, err)
return nil, err
}
reports = append(reports, entries...)
@ -168,8 +155,7 @@ func offChainReport(invoices []lndclient.Invoice, payments []paymentInfo,
for _, forward := range forwards {
entries, err := forwardingEntry(forward, utils)
if err != nil {
return nil, fmt.Errorf("forward at %v: creating "+
"entries failed: %w", forward.Timestamp, err)
return nil, err
}
reports = append(reports, entries...)

View file

@ -24,9 +24,6 @@ var (
paymentHash2 = "a5530c5930b9eb7ea4284bcff39da52c6bca3103fc790749eb632911edc7143b"
hash2, _ = lntypes.MakeHashFromStr(paymentHash2)
paymentRequest = "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9srp"
invoiceMemo = "1 cup coffee"
hopToUs = &lnrpc.Hop{
PubKey: ourPK,
}

View file

@ -2,7 +2,6 @@ package accounting
import (
"context"
"fmt"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/wire"
@ -24,15 +23,12 @@ func OnChainReport(ctx context.Context, cfg *OnChainConfig) (Report, error) {
cfg.PriceSourceCfg,
)
if err != nil {
return nil, fmt.Errorf("on-chain report: init conversion "+
"lookup for range [%v,%v) failed: %w", cfg.StartTime,
cfg.EndTime, err)
return nil, err
}
info, err := getOnChainInfo(cfg, getPrice)
if err != nil {
return nil, fmt.Errorf("on-chain report: gathering on-chain "+
"data failed: %w", err)
return nil, err
}
return onChainReport(info)
@ -97,8 +93,7 @@ func getOnChainInfo(cfg *OnChainConfig, getPrice fiatPrice) (*onChainInformation
onChainTxns, err := cfg.OnChainTransactions()
if err != nil {
return nil, fmt.Errorf("on-chain report: listing on-chain "+
"transactions failed: %w", err)
return nil, err
}
// Filter our on chain transactions by start and end time. If we have
@ -106,9 +101,7 @@ func getOnChainInfo(cfg *OnChainConfig, getPrice fiatPrice) (*onChainInformation
// early.
info.txns, err = filterOnChain(cfg.StartTime, cfg.EndTime, onChainTxns)
if err != nil {
return nil, fmt.Errorf("on-chain report: filtering "+
"transactions for range [%v,%v) failed: %w",
cfg.StartTime, cfg.EndTime, err)
return nil, err
}
if len(info.txns) == 0 {
@ -122,8 +115,7 @@ func getOnChainInfo(cfg *OnChainConfig, getPrice fiatPrice) (*onChainInformation
// closing channels that are awaiting resolution).
pending, err := cfg.PendingChannels()
if err != nil {
return nil, fmt.Errorf("on-chain report: listing pending "+
"channels failed: %w", err)
return nil, err
}
// We add our pending force close channels to opened and closed channels
@ -179,8 +171,7 @@ func getOnChainInfo(cfg *OnChainConfig, getPrice fiatPrice) (*onChainInformation
// other on chain transactions.
openRPCChannels, err := cfg.OpenChannels()
if err != nil {
return nil, fmt.Errorf("on-chain report: listing open "+
"channels failed: %w", err)
return nil, err
}
for _, channel := range openRPCChannels {
@ -188,9 +179,7 @@ func getOnChainInfo(cfg *OnChainConfig, getPrice fiatPrice) (*onChainInformation
channel.ChannelPoint,
)
if err != nil {
return nil, fmt.Errorf("on-chain report: parsing open "+
"channel point %v failed: %w",
channel.ChannelPoint, err)
return nil, err
}
init := lndclient.InitiatorLocal
@ -212,8 +201,7 @@ func getOnChainInfo(cfg *OnChainConfig, getPrice fiatPrice) (*onChainInformation
// on chain transactions.
closedRPCChannels, err := cfg.ClosedChannels()
if err != nil {
return nil, fmt.Errorf("on-chain report: listing closed "+
"channels failed: %w", err)
return nil, err
}
// Add our already closed channels open and closed transactions to our
@ -224,9 +212,7 @@ func getOnChainInfo(cfg *OnChainConfig, getPrice fiatPrice) (*onChainInformation
closed.ChannelPoint,
)
if err != nil {
return nil, fmt.Errorf("on-chain report: parsing "+
"closed channel point %v failed: %w",
closed.ChannelPoint, err)
return nil, err
}
inf := newChannelInfo(
@ -248,8 +234,7 @@ func getOnChainInfo(cfg *OnChainConfig, getPrice fiatPrice) (*onChainInformation
// identify them separately to other on chain transactions.
sweeps, err := cfg.ListSweeps()
if err != nil {
return nil, fmt.Errorf("on-chain report: listing sweep "+
"transactions failed: %w", err)
return nil, err
}
for _, sweep := range sweeps {
@ -275,9 +260,7 @@ func onChainReport(info *onChainInformation) (
openChannel, txn, info.entryUtils,
)
if err != nil {
return nil, fmt.Errorf("tx %v: creating "+
"channel open entries failed: %w",
txn.TxHash, err)
return nil, err
}
report = append(report, entries...)
@ -291,9 +274,7 @@ func onChainReport(info *onChainInformation) (
channelClose, txn, info.entryUtils,
)
if err != nil {
return nil, fmt.Errorf("tx %v: creating "+
"channel close entries failed: %w",
txn.TxHash, err)
return nil, err
}
report = append(report, entries...)
@ -308,8 +289,7 @@ func onChainReport(info *onChainInformation) (
txn, info.entryUtils,
)
if err != nil {
return nil, fmt.Errorf("tx %v: creating sweep "+
"entries failed: %w", txn.TxHash, err)
return nil, err
}
report = append(report, entries...)
@ -320,8 +300,7 @@ func onChainReport(info *onChainInformation) (
// closes, we create a generic on chain entry for it.
entries, err := onChainEntries(txn, info.entryUtils)
if err != nil {
return nil, fmt.Errorf("tx %v: creating generic "+
"on-chain entries failed: %w", txn.TxHash, err)
return nil, err
}
report = append(report, entries...)
}

View file

@ -78,8 +78,7 @@ func newHarmonyEntry(ts time.Time, amountMsat int64, e EntryType, txid,
btcPrice, err := convert(ts)
if err != nil {
return nil, fmt.Errorf("fiat conversion at %v failed: %w", ts,
err)
return nil, err
}
amtMsat := lnwire.MilliSatoshi(absAmt)

View file

@ -1,895 +0,0 @@
package chanevents
import (
"context"
"errors"
"fmt"
"iter"
"log/slog"
"sort"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btclog/v2"
"github.com/lightninglabs/lndclient"
)
var (
// errUnexpectedUpdateEvent fires when getInitialChannelState's
// residual-event walk surfaces an Update at a timestamp newer than the
// seed update.
errUnexpectedUpdateEvent = errors.New("unexpected update event in " +
"initial-state walk")
// errUnknownEventType fires when the event-replay switch sees an
// EventType outside {Offline, Online, Update}. Indicates schema drift
// between the store and the analyzer.
errUnknownEventType = errors.New("unknown channel event type")
)
// EventsSource abstracts the chanevents store so ForwardingAnalyzer can derive
// uptime metrics without coupling to a specific storage backend.
type EventsSource interface {
// GetLatestChannelUpdateBefore returns the latest channel event before
// the given time, or (nil, nil) if no event predates it.
GetLatestChannelUpdateBefore(ctx context.Context, channelID int64,
before time.Time) (*ChannelEvent, error)
// GetChannelEvents fetches up to limit events for a channel with id >
// afterID and timestamp in [startTime, endTime), ordered by id ASC.
// Callers page through a range by passing the last returned id as
// afterID until a short page comes back.
GetChannelEvents(ctx context.Context, channelID, afterID int64,
startTime, endTime time.Time,
limit int32) ([]*ChannelEvent, error)
// GetChannelByShortChanID resolves an scid to a Channel, returning
// ErrUnknownChannel when no row matches.
GetChannelByShortChanID(ctx context.Context,
shortChannelID uint64) (*Channel, error)
// ScidToPeerMap returns the historically recorded scid→peer index,
// including closed channels.
ScidToPeerMap(ctx context.Context) (map[uint64]string, error)
}
// ForwardingAnalyzer computes forwarding velocity and effective uptime for
// every (peerIn, peerOut) pair.
type ForwardingAnalyzer struct {
store EventsSource
lnd lndclient.LndServices
}
// channelEventSeq is a chronologically ordered stream of channel events
// paired with a propagated error value.
type channelEventSeq = iter.Seq2[*ChannelEvent, error]
// ForwardingAbility holds the raw forwarding facts for one direction of a peer
// pair over the analysis window. It carries no derived rates or categories. The
// consumer derives velocity and uptime fraction from these and the window, and
// reconstructs any categorization (such as forwards observed without qualifying
// uptime) from EffectiveUptime and ForwardedAmount.
type ForwardingAbility struct {
// EffectiveUptime is the time the pair held at least the liquidity floor
// of directional forwardable liquidity over the window.
EffectiveUptime time.Duration
// ForwardedAmount is the total successfully forwarded amount over the
// window.
ForwardedAmount btcutil.Amount
}
// PeerPair identifies a unidirectional routing edge from PeerIn to PeerOut.
// PeerIn names the source-side peer (the incoming channel's far end in lnd's
// forwarding vocabulary) and PeerOut names the sink-side peer.
type PeerPair struct {
PeerIn string
PeerOut string
}
// channelState is the per-channel snapshot the uptime walk carries forward as
// it consumes events: liveness plus the two balances that determine forwarding
// liquidity.
type channelState struct {
online bool
localBalance btcutil.Amount
remoteBalance btcutil.Amount
}
// NewForwardingAnalyzer returns a ready-to-use analyzer.
func NewForwardingAnalyzer(store EventsSource,
lnd lndclient.LndServices) *ForwardingAnalyzer {
return &ForwardingAnalyzer{
store: store,
lnd: lnd,
}
}
// EffectiveUptime returns a ForwardingAbility for every (peerIn, peerOut) pair
// over [startTime, endTime). Closed channels are folded into the considered set
// so survivorship bias does not skew the uptime denominator. A single
// liquidityFloor is applied uniformly to every pair, so effective uptime is the
// time each pair held at least that much directional forwardable liquidity.
func (a *ForwardingAnalyzer) EffectiveUptime(ctx context.Context, startTime,
endTime time.Time, liquidityFloor btcutil.Amount) (
map[PeerPair]ForwardingAbility, error) {
log.DebugS(
ctx, "Calculating effective uptime",
slog.Time("startTime", startTime),
slog.Time("endTime", endTime),
slog.Int64("liquidityFloor", int64(liquidityFloor)),
)
scidToPeer, err := a.store.ScidToPeerMap(ctx)
if err != nil {
return nil, err
}
log.DebugS(
ctx, "Found historical channels",
slog.Int("count", len(scidToPeer)),
)
successfulForwards, channelPeersConsidered, err := a.getForwardingData(
ctx, startTime, endTime, scidToPeer,
)
if err != nil {
return nil, err
}
log.DebugS(
ctx, "Found peer pairs with successful forwards",
slog.Int("count", len(successfulForwards)),
)
err = a.addActiveChannels(ctx, channelPeersConsidered)
if err != nil {
return nil, err
}
peerChannels, initialStates, err := a.getPeerChannelData(
ctx, startTime, channelPeersConsidered,
)
if err != nil {
return nil, err
}
log.DebugS(
ctx, "Identified channels for peers",
slog.Int("count", len(peerChannels)),
)
return calculateAllPairsUptime(
ctx, a.store, startTime, endTime, liquidityFloor,
successfulForwards, initialStates, peerChannels,
)
}
// getForwardingData queries lnd's forwarding history sequentially in paginated
// batches to retrieve successful forwarding events within the specified time
// range, indexing the results by peer pair.
func (a *ForwardingAnalyzer) getForwardingData(ctx context.Context, startTime,
endTime time.Time, scidToPeer map[uint64]string) (
map[PeerPair][]btcutil.Amount, map[uint64]string, error) {
var events []lndclient.ForwardingEvent
var offset uint32
const forwardingPageSize = 1000
for {
fwds, err := a.lnd.Client.ForwardingHistory(
ctx, lndclient.ForwardingHistoryRequest{
StartTime: startTime,
EndTime: endTime,
Offset: offset,
MaxEvents: forwardingPageSize,
},
)
if err != nil {
return nil, nil, err
}
if len(fwds.Events) == 0 {
break
}
events = append(events, fwds.Events...)
if len(fwds.Events) < forwardingPageSize {
break
}
// Guard against a non-advancing offset: if lnd does not move
// LastIndexOffset past the cursor we already queried, stop
// rather than re-fetch the same page forever.
if fwds.LastIndexOffset <= offset {
break
}
offset = fwds.LastIndexOffset
}
log.DebugS(
ctx, "Found forwarding events",
slog.Int(
"count", len(events),
),
)
channelPeersConsidered := make(map[uint64]string)
successfulForwards := make(map[PeerPair][]btcutil.Amount)
for _, fwd := range events {
inPeer, ok := scidToPeer[fwd.ChannelIn]
if !ok {
log.WarnS(
ctx, "Could not find peer for incoming channel",
nil, slog.Uint64("channelIn", fwd.ChannelIn),
)
continue
}
outPeer, ok := scidToPeer[fwd.ChannelOut]
if !ok {
log.WarnS(
ctx, "Could not find peer for outgoing channel",
nil, slog.Uint64("channelOut", fwd.ChannelOut),
)
continue
}
channelPeersConsidered[fwd.ChannelIn] = inPeer
channelPeersConsidered[fwd.ChannelOut] = outPeer
pair := PeerPair{
PeerIn: inPeer,
PeerOut: outPeer,
}
amt := fwd.AmountMsatOut.ToSatoshis()
successfulForwards[pair] = append(successfulForwards[pair], amt)
}
return successfulForwards, channelPeersConsidered, nil
}
// addActiveChannels ensures the channel set includes both open and closed
// channels so that channels that closed during the analysis period are not
// silently excluded.
func (a *ForwardingAnalyzer) addActiveChannels(ctx context.Context,
channelPeersConsidered map[uint64]string) error {
// Currently open channels surface their peer directly.
openChannels, err := a.lnd.Client.ListChannels(ctx, false, false)
if err != nil {
return err
}
for _, channel := range openChannels {
channelPeersConsidered[channel.ChannelID] =
channel.PubKeyBytes.String()
}
// Historically closed channels are added so survivorship bias does not
// skew the denominator.
closedChannels, err := a.lnd.Client.ClosedChannels(ctx)
if err != nil {
return err
}
for _, channel := range closedChannels {
// Channels that did not confirm onchain will not have a
// ChannelID.
if channel.ChannelID == 0 {
continue
}
channelPeersConsidered[channel.ChannelID] =
channel.PubKeyBytes.String()
}
return nil
}
// getPeerChannelData returns channels and their initial state at startTime,
// grouped by peer, including only those present in the store.
func (a *ForwardingAnalyzer) getPeerChannelData(ctx context.Context,
startTime time.Time, channelPeersConsidered map[uint64]string) (
map[string][]int64, map[string]map[int64]*channelState, error) {
peerChannels := make(map[string][]int64)
initialStates := make(map[string]map[int64]*channelState)
for scid, peerPubKey := range channelPeersConsidered {
channel, err := a.store.GetChannelByShortChanID(ctx, scid)
if errors.Is(err, ErrUnknownChannel) {
// Channels obtained from lnd but not present in the
// store. This can happen if the channel was very
// recently opened or closed and the store hasn't
// ingested the event yet.
log.DebugS(
ctx, "Skipping channel not in events store",
slog.Uint64("scid", scid),
)
continue
}
if err != nil {
return nil, nil, err
}
state, err := a.getInitialChannelState(
ctx, startTime, channel.ID,
)
if err != nil {
return nil, nil, err
}
if _, ok := initialStates[peerPubKey]; !ok {
initialStates[peerPubKey] = make(
map[int64]*channelState,
)
}
initialStates[peerPubKey][channel.ID] = state
peerChannels[peerPubKey] = append(
peerChannels[peerPubKey], channel.ID,
)
}
return peerChannels, initialStates, nil
}
// getInitialChannelState reconstructs a channel's state at startTime by seeding
// from the latest pre-window update and replaying any residual same-second
// siblings the SQL keyset may have surfaced. A channel with no prior update is
// treated as offline with zero balance.
func (a *ForwardingAnalyzer) getInitialChannelState(ctx context.Context,
startTime time.Time, channelID int64) (*channelState, error) {
lastUpdate, err := a.store.GetLatestChannelUpdateBefore(
ctx, channelID, startTime,
)
if err != nil {
return nil, err
}
if lastUpdate == nil {
log.TraceS(
ctx, "No update event for channel",
slog.Int64("channelID", channelID),
slog.Time("startTime", startTime),
)
return &channelState{online: false}, nil
}
// An update event always implies the channel is online.
state := &channelState{
online: true,
}
lastUpdate.LocalBalance.WhenSome(
func(amt btcutil.Amount) {
state.localBalance = amt
},
)
lastUpdate.RemoteBalance.WhenSome(
func(amt btcutil.Amount) {
state.remoteBalance = amt
},
)
// Fetch any residual events between the last update and the start time.
// The range is bounded (typically a handful of same-second siblings or
// status events) so materialising in one call is fine. Replay below
// assumes id-ASC matches chronological order, true while writers leave
// Timestamp zero so the store stamps clock.Now(). Overflow at the cap
// signals pathological volume the analyzer cannot safely seed from.
const residualEventLimit = 1024
residual, err := a.store.GetChannelEvents(
ctx, channelID, lastUpdate.ID, lastUpdate.Timestamp, startTime,
residualEventLimit,
)
if err != nil {
return nil, err
}
if len(residual) == residualEventLimit {
return nil, fmt.Errorf("residual events overflow (>=%d) for "+
"chanID=%d", residualEventLimit, channelID)
}
// Replay the residual events to arrive at the channel state on the
// window's open.
for _, event := range residual {
switch event.EventType {
case EventTypeOffline:
state.online = false
case EventTypeOnline:
state.online = true
case EventTypeUpdate:
// Defensively check that the seed update is indeed the
// latest before startTime.
if !event.Timestamp.Equal(lastUpdate.Timestamp) {
return nil, fmt.Errorf("%w: chanID=%d ts=%v",
errUnexpectedUpdateEvent, channelID,
event.Timestamp)
}
default:
return nil, fmt.Errorf("%w: chanID=%d type=%v",
errUnknownEventType, channelID, event.EventType)
}
}
return state, nil
}
// calculateAllPairsUptime returns forwarding abilities for every peer pair,
// computing both directions (A→B and B→A) in a single pass.
func calculateAllPairsUptime(ctx context.Context, store EventsSource, startTime,
endTime time.Time, liquidityFloor btcutil.Amount,
successfulForwards map[PeerPair][]btcutil.Amount,
initialStates map[string]map[int64]*channelState,
peerChannels map[string][]int64) (
map[PeerPair]ForwardingAbility, error) {
results := make(map[PeerPair]ForwardingAbility)
recordResult := func(peerIn, peerOut string, a ForwardingAbility) {
results[PeerPair{PeerIn: peerIn, PeerOut: peerOut}] = a
}
// Lazy per-peer event cache: each peer's events are fetched once and
// replayed across every pair walk that consumes them.
peerEvents := make(map[string][]*ChannelEvent, len(initialStates))
loadPeer := func(peer string) ([]*ChannelEvent, error) {
if cached, ok := peerEvents[peer]; ok {
return cached, nil
}
events, err := loadPeerEvents(
ctx, store, startTime, endTime, peerChannels[peer],
)
if err != nil {
return nil, err
}
peerEvents[peer] = events
return events, nil
}
peers := make([]string, 0, len(initialStates))
for peer := range initialStates {
peers = append(peers, peer)
}
type peerInitialSums struct {
remote btcutil.Amount
local btcutil.Amount
}
// We gather the initial balance sums for each peer upfront so the pair
// walk can be more efficient and doesn't have to recalculate.
initialSums := make(map[string]peerInitialSums, len(initialStates))
for peer, states := range initialStates {
var remoteSum, localSum btcutil.Amount
for _, s := range states {
if s.online {
remoteSum += s.remoteBalance
localSum += s.localBalance
}
}
initialSums[peer] = peerInitialSums{
remote: remoteSum,
local: localSum,
}
}
for i, peerA := range peers {
if ctx.Err() != nil {
return nil, ctx.Err()
}
statesA := initialStates[peerA]
sumsA := initialSums[peerA]
sliceA, err := loadPeer(peerA)
if err != nil {
return nil, err
}
for j := i; j < len(peers); j++ {
if ctx.Err() != nil {
return nil, ctx.Err()
}
peerB := peers[j]
statesB := initialStates[peerB]
sumsB := initialSums[peerB]
forwardedAB := pairForwardedTotal(
successfulForwards, peerA, peerB,
)
forwardedBA := pairForwardedTotal(
successfulForwards, peerB, peerA,
)
sliceB := sliceA
if i != j {
sliceB, err = loadPeer(peerB)
if err != nil {
return nil, err
}
}
abilityAB, abilityBA, err :=
calculateBothDirectionsUptime(
ctx, startTime, endTime,
liquidityFloor,
statesA, statesB,
sumsA.remote, sumsA.local,
sumsB.remote, sumsB.local,
mergeEventSlices(sliceA, sliceB),
forwardedAB, forwardedBA,
)
if err != nil {
return nil, err
}
recordResult(peerA, peerB, *abilityAB)
if i != j {
recordResult(peerB, peerA, *abilityBA)
}
}
}
return results, nil
}
// eventPageSize bounds a single channel-event page so the per-channel fetch
// never asks the store for an unbounded result set.
const eventPageSize = 1000
// loadPeerEvents fetches every event in [startTime, endTime) on the given
// channels and returns them merged into a single chronologically sorted slice.
// Each channel is paged through in id-ascending batches so no single store
// query is unbounded. Events sharing a timestamp are ordered by ascending id so
// the result is deterministic.
func loadPeerEvents(ctx context.Context, store EventsSource, startTime,
endTime time.Time, chanIDs []int64) ([]*ChannelEvent, error) {
var events []*ChannelEvent
for _, chanID := range chanIDs {
var afterID int64
for {
page, err := store.GetChannelEvents(
ctx, chanID, afterID, startTime, endTime,
eventPageSize,
)
if err != nil {
return nil, err
}
events = append(events, page...)
if len(page) < eventPageSize {
break
}
// Events come back id-ASC, so the last id is the
// largest; continue the next page after it.
afterID = page[len(page)-1].ID
}
}
sort.SliceStable(
events,
func(i, j int) bool {
if events[i].Timestamp.Equal(events[j].Timestamp) {
return events[i].ID < events[j].ID
}
return events[i].Timestamp.Before(events[j].Timestamp)
},
)
return events, nil
}
// pairForwardedTotal sums the successfully forwarded amounts for one direction
// of a peer pair over the analysis window.
func pairForwardedTotal(successfulForwards map[PeerPair][]btcutil.Amount,
peerIn, peerOut string) btcutil.Amount {
var total btcutil.Amount
for _, amt := range successfulForwards[PeerPair{
PeerIn: peerIn, PeerOut: peerOut,
}] {
total += amt
}
return total
}
// calculateBothDirectionsUptime computes the effective forwarding uptime for
// both directions of a peer pair in a single chronological walk of the merged
// event stream. Only the liquidity-direction roles differ between the two
// accumulators, as both share the same uniform liquidityFloor. forwardedAB and
// forwardedBA carry each direction's total forwarded volume through to the
// returned abilities. For self-pair calls both returned abilities are equal.
func calculateBothDirectionsUptime(ctx context.Context, startTime,
endTime time.Time, liquidityFloor btcutil.Amount, statesA,
statesB map[int64]*channelState, sumARemote, sumALocal, sumBRemote,
sumBLocal btcutil.Amount, mergedEvents channelEventSeq,
forwardedAB, forwardedBA btcutil.Amount) (
*ForwardingAbility, *ForwardingAbility, error) {
traceOn := log.Level() <= btclog.LevelTrace
if traceOn {
log.TraceS(ctx, "Calculating bidirectional effective uptime")
for chanID, state := range statesA {
log.TraceS(
ctx, "Initial state A",
slog.Int64("chanID", chanID),
slog.Bool("online", state.online),
slog.Int64(
"localBalance", int64(
state.localBalance,
),
),
slog.Int64(
"remoteBalance", int64(
state.remoteBalance,
),
),
)
}
for chanID, state := range statesB {
log.TraceS(
ctx, "Initial state B",
slog.Int64("chanID", chanID),
slog.Bool("online", state.online),
slog.Int64(
"localBalance", int64(
state.localBalance,
),
),
slog.Int64(
"remoteBalance", int64(
state.remoteBalance,
),
),
)
}
log.TraceS(
ctx, "Using uniform forwarding liquidity floor",
slog.Int64("liquidityFloor", int64(liquidityFloor)),
)
}
statesA = copyChannelStates(statesA)
statesB = copyChannelStates(statesB)
var uptimeAB, uptimeBA time.Duration
lastTimestamp := startTime
accumulate := func(intervalDuration time.Duration) {
if intervalDuration <= 0 {
return
}
// (A→B): A is incoming, B is outgoing. Liquidity bottleneck is
// min(A's online inbound, B's online outbound).
liqAB := min(sumARemote, sumBLocal)
// (B→A): roles flipped.
liqBA := min(sumBRemote, sumALocal)
if traceOn {
log.TraceS(
ctx, "Forwarding liquidity check",
slog.Duration("interval", intervalDuration),
slog.Int64(
"liqAB", int64(liqAB),
),
slog.Int64(
"liqBA", int64(liqBA),
),
)
}
// A direction qualifies when its bottleneck liquidity is at
// least the floor, matching the "at least" contract documented
// on the ForwardingAbility proto, struct, and CLI flag. The
// liquidity must also be strictly positive: zero forwardable
// liquidity can never route a payment, even when the floor is 0.
if liqAB >= liquidityFloor && liqAB > 0 {
uptimeAB += intervalDuration
}
if liqBA >= liquidityFloor && liqBA > 0 {
uptimeBA += intervalDuration
}
}
// Walk the merged event stream, applying each event to both peers'
// states and accumulating uptime for each direction when the respective
// liquidity conditions are met.
for event, err := range mergedEvents {
if err != nil {
return nil, nil, err
}
if traceOn {
log.TraceS(
ctx, "Processing event",
slog.Int64("chanID", event.ChannelID),
btclog.Fmt("type", "%v", event.EventType),
slog.Time("time", event.Timestamp),
)
}
// accumulate uptime for the elapsed interval since the last
// event, based on the state of the channels during that
// interval. The events are ordered chronologically so the state
// is consistent with the entire interval.
accumulate(event.Timestamp.Sub(lastTimestamp))
// Update the state for each peer if the event affects one of
// their channels. Before applying the event, we remove the
// channel's contribution to the sums if it's currently online,
// because the event may change the channel's online status or
// balances in a way that affects the sums.
if state, ok := statesA[event.ChannelID]; ok {
// We would have inlcuded the channel's balances in the
// sums if it was online, so we need to remove them
// before applying the event.
if state.online {
sumARemote -= state.remoteBalance
sumALocal -= state.localBalance
}
if err := applyEvent(state, event); err != nil {
return nil, nil, err
}
// If the channel is still online after applying the
// event, we add its (possibly updated) balances back to
// the sums.
if state.online {
sumARemote += state.remoteBalance
sumALocal += state.localBalance
}
}
if state, ok := statesB[event.ChannelID]; ok {
if state.online {
sumBRemote -= state.remoteBalance
sumBLocal -= state.localBalance
}
if err := applyEvent(state, event); err != nil {
return nil, nil, err
}
if state.online {
sumBRemote += state.remoteBalance
sumBLocal += state.localBalance
}
}
lastTimestamp = event.Timestamp
}
// Account for the final interval between the last event and the end
// time.
accumulate(endTime.Sub(lastTimestamp))
if traceOn {
log.TraceS(
ctx, "Total effective uptime",
slog.Duration("uptimeAB", uptimeAB),
slog.Duration("uptimeBA", uptimeBA),
slog.Duration(
"totalDuration", endTime.Sub(startTime),
),
)
}
abilityAB := makeAbility(uptimeAB, forwardedAB)
abilityBA := makeAbility(uptimeBA, forwardedBA)
return abilityAB, abilityBA, nil
}
// mergeEventSlices interleaves two sorted event streams into a single
// chronological iter.Seq2. Equal-timestamp events from sliceA are yielded
// first. Self-pair calls (sliceA == sliceB) yield each event twice. Callers
// must keep their state updates idempotent under same-timestamp duplicates.
func mergeEventSlices(sliceA, sliceB []*ChannelEvent) channelEventSeq {
return func(yield func(*ChannelEvent, error) bool) {
i, j := 0, 0
// Interleave both slices until one is exhausted, ensuring
// strict chronological order across the combined stream.
for i < len(sliceA) && j < len(sliceB) {
if sliceA[i].Timestamp.After(sliceB[j].Timestamp) {
if !yield(sliceB[j], nil) {
return
}
j++
} else {
if !yield(sliceA[i], nil) {
return
}
i++
}
}
// Drain any remaining events from sliceA. This loop only
// executes if sliceB was exhausted first.
for ; i < len(sliceA); i++ {
if !yield(sliceA[i], nil) {
return
}
}
// Drain any remaining events from sliceB. This loop only
// executes if sliceA was exhausted first.
for ; j < len(sliceB); j++ {
if !yield(sliceB[j], nil) {
return
}
}
}
}
// copyChannelStates returns a deep copy of the per-channel state map so the
// bidirectional walk cannot mutate the caller's snapshot.
func copyChannelStates(states map[int64]*channelState) map[int64]*channelState {
statesCopy := make(map[int64]*channelState, len(states))
for chanID, state := range states {
statesCopy[chanID] = &channelState{
online: state.online,
localBalance: state.localBalance,
remoteBalance: state.remoteBalance,
}
}
return statesCopy
}
// applyEvent advances a channel's snapshot by one event. Update events imply
// online and overwrite whichever balance the event carries. Unknown event
// types return errUnknownEventType to surface store↔analyzer schema drift.
func applyEvent(state *channelState, event *ChannelEvent) error {
switch event.EventType {
case EventTypeOffline:
state.online = false
case EventTypeOnline:
state.online = true
case EventTypeUpdate:
state.online = true
event.LocalBalance.WhenSome(
func(amt btcutil.Amount) {
state.localBalance = amt
},
)
event.RemoteBalance.WhenSome(
func(amt btcutil.Amount) {
state.remoteBalance = amt
},
)
default:
return fmt.Errorf("%w: chanID=%d type=%v", errUnknownEventType,
event.ChannelID, event.EventType)
}
return nil
}
// makeAbility folds an accumulated uptime and successful-amount total into a
// ForwardingAbility carrying the raw facts. Derived rates and categories are
// left to the consumer.
func makeAbility(totalUptime time.Duration,
totalAmt btcutil.Amount) *ForwardingAbility {
return &ForwardingAbility{
EffectiveUptime: totalUptime,
ForwardedAmount: totalAmt,
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,129 +0,0 @@
// Package chanevents contains functions for monitoring and storing channel
// events such as online/offline and balance updates.
package chanevents
import (
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightningnetwork/lnd/fn/v2"
)
// Config holds the configuration options for channel event pruning. See the
// README for storage sizing guidance.
type Config struct {
// MaxEvents is the maximum number of channel events to retain. Once the
// table exceeds this count, the oldest events are pruned. This operates
// as a hard ceiling on database size to prevent disk filling. A value
// of 0 disables this limit.
MaxEvents uint64 `long:"max-events" description:"The maximum number of channel events to retain before pruning the oldest events. This limit acts as a hard ceiling to prevent disk filling. A value of 0 disables pruning based on the number of events."`
// Retention is the minimum duration of channel events to keep. Events
// older than this window are pruned, even if the max-events limit is
// not exceeded. If max-events is exceeded, newer events can still be
// pruned to enforce the size ceiling. A value of 0 disables this limit.
Retention time.Duration `long:"retention" description:"The minimum duration of channel events to keep. Events older than this window are pruned, even if the max-events limit is not exceeded. A value of 0 disables pruning based on age."`
}
// EventType is an enum for the different types of channel events.
type EventType int16
const (
// EventTypeUnknown is the unknown event type.
EventTypeUnknown EventType = 0
// EventTypeOnline is the online event type.
EventTypeOnline EventType = 1
// EventTypeOffline is the offline event type.
EventTypeOffline EventType = 2
// EventTypeUpdate is the balance update event type.
EventTypeUpdate EventType = 3
)
// String returns the string representation of the event type.
func (e EventType) String() string {
switch e {
case EventTypeOnline:
return "online"
case EventTypeOffline:
return "offline"
case EventTypeUpdate:
return "update"
default:
return "unknown"
}
}
// EventTypeFromString returns the event type from a string.
func EventTypeFromString(s string) EventType {
switch s {
case "online":
return EventTypeOnline
case "offline":
return EventTypeOffline
case "update":
return EventTypeUpdate
default:
return EventTypeUnknown
}
}
// Peer is the application-level representation of a peer.
type Peer struct {
// ID is the database ID of the peer.
ID int64
// PubKey is the public key of the peer.
PubKey string
}
// Channel is the application-level representation of a channel.
type Channel struct {
// ID is the database ID of the channel.
ID int64
// ChannelPoint is the channel point of the channel.
ChannelPoint string
// ShortChannelID is the short channel ID of the channel.
ShortChannelID uint64
// PeerID is the database ID of the peer that this channel is with.
PeerID int64
}
// ChannelEvent is the application-level representation of a channel event.
type ChannelEvent struct {
// ID is the database ID of the event.
ID int64
// ChannelID is the database ID of the channel that this event is
// associated with.
ChannelID int64
// EventType is the type of the event.
EventType EventType
// Timestamp is the time that the event occurred.
Timestamp time.Time
// LocalBalance is the local balance of the channel at the time of the
// event. This is only populated for balance update events.
LocalBalance fn.Option[btcutil.Amount]
// RemoteBalance is the remote balance of the channel at the time of the
// event. This is only populated for balance update events.
RemoteBalance fn.Option[btcutil.Amount]
// IsSync indicates whether this event was recorded during an initial
// sync rather than from a live subscription.
IsSync bool
}

View file

@ -1,25 +0,0 @@
package chanevents
import (
"github.com/btcsuite/btclog/v2"
"github.com/lightningnetwork/lnd/build"
)
const Subsystem = "CHEV"
// log is a logger that is initialized with no output filters. This
// means the package will not perform any logging by default until the caller
// requests it.
var log btclog.Logger
// The default amount of logging is none.
func init() {
UseLogger(build.NewSubLogger(Subsystem, nil))
}
// UseLogger uses a specified Logger to output package logging info.
// This should be used in preference to SetLogWriter if the caller is also
// using btclog.
func UseLogger(logger btclog.Logger) {
log = logger
}

View file

@ -1,597 +0,0 @@
package chanevents
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/lndclient"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/routing/route"
)
const (
// retryInterval is the time to wait before retrying after a
// transient error or while waiting for lnd to become ready.
retryInterval = 5 * time.Second
// pruneInterval is how often the monitor enforces the channel event
// storage limits while consuming live events.
pruneInterval = time.Hour
// minPruneInterval is the floor for the background pruning ticker. A
// tiny retention window would otherwise drive the ticker interval down
// to milliseconds and starve the CPU, so we never tick faster than
// this.
minPruneInterval = time.Second
)
var (
// errMonitorAlreadyStarted is returned when the monitor is already
// started.
errMonitorAlreadyStarted = errors.New("monitor already started")
// errMonitorNotStarted is returned when the monitor is not started.
errMonitorNotStarted = errors.New("monitor not started")
)
// Monitor is an active component that listens to LND channel events and records
// them in the database.
type Monitor struct {
started atomic.Bool
// lnd is the lnd client that the monitor will use to subscribe to
// channel events.
lnd lndclient.LightningClient
// store is the channel events store that the monitor will use to record
// channel events.
store *Store
// cfg holds the channel event pruning configuration.
cfg Config
// warnedDestructivePrune ensures the operator is warned only once that
// pruning has permanently deleted events.
warnedDestructivePrune atomic.Bool
wg sync.WaitGroup
quit chan struct{}
}
// NewMonitor creates a new channel events monitor.
func NewMonitor(lnd lndclient.LightningClient, store *Store,
cfg Config) *Monitor {
return &Monitor{
lnd: lnd,
store: store,
cfg: cfg,
quit: make(chan struct{}),
}
}
// Start starts the channel events monitor.
func (m *Monitor) Start(ctx context.Context) error {
if !m.started.CompareAndSwap(false, true) {
return errMonitorAlreadyStarted
}
log.Info("Starting channel events monitor")
m.quit = make(chan struct{})
m.wg.Add(1)
go m.monitorLoop(ctx)
return nil
}
// Stop stops the channel events monitor.
func (m *Monitor) Stop() error {
if !m.started.CompareAndSwap(true, false) {
return errMonitorNotStarted
}
log.Info("Stopping channel events monitor")
close(m.quit)
m.wg.Wait()
return nil
}
// monitorLoop is the main loop of the channel events monitor. It waits for lnd
// to be fully synced, performs an initial state sync, and then subscribes to
// channel events. If the subscription fails or the stream breaks, it retries
// from the beginning.
func (m *Monitor) monitorLoop(ctx context.Context) {
defer m.wg.Done()
log.Info("Channel events monitor starting")
// Prune periodically while consuming live events, to bound the query
// overhead on high-frequency channels. Only arm the ticker when pruning
// is actually enabled; otherwise leave pruneChan nil so the select below
// never fires and we don't spin a ticker for nothing.
var pruneChan <-chan time.Time
if m.cfg.MaxEvents > 0 || m.cfg.Retention > 0 {
pruneIntervalToUse := pruneInterval
if m.cfg.Retention > 0 && m.cfg.Retention < pruneIntervalToUse {
pruneIntervalToUse = m.cfg.Retention
// Never tick faster than the floor: an extremely small
// retention would otherwise spin the ticker continuously.
if pruneIntervalToUse < minPruneInterval {
pruneIntervalToUse = minPruneInterval
}
}
pruneTicker := time.NewTicker(pruneIntervalToUse)
defer pruneTicker.Stop()
pruneChan = pruneTicker.C
}
var synced bool
for {
// Wait for lnd to be synced to chain, retrying on RPC errors.
if !m.waitForReady(ctx) {
return
}
// Initial state sync, only performed once.
if !synced {
if err := m.initialSync(ctx); err != nil {
log.Errorf("Error during initial sync: %v", err)
} else {
synced = true
// The initial sync can insert a sizeable number
// of events, so prune once it completes.
m.pruneEvents(ctx)
}
}
// Subscribe and consume events until the stream breaks or an
// error occurs.
if !m.subscribe(ctx, pruneChan) {
return
}
// Stream broke, wait before reconnecting.
log.Infof("Reconnecting channel event subscription...")
select {
case <-time.After(retryInterval):
case <-m.quit:
return
case <-ctx.Done():
return
}
}
}
// pruneEvents enforces the configured channel event storage limits, logging
// how many events were deleted.
func (m *Monitor) pruneEvents(ctx context.Context) {
pruned, err := m.store.PruneEvents(
ctx, m.cfg.MaxEvents, m.cfg.Retention,
)
if err != nil {
log.Errorf("Error pruning channel events: %v", err)
return
}
if pruned == 0 {
return
}
// Pruning permanently deletes events, so warn the first time it happens.
// This gives a clear signal to an operator who did not expect the default
// limits to remove pre-existing history. Subsequent prunes log at info
// level.
if m.warnedDestructivePrune.CompareAndSwap(false, true) {
log.Warnf("Pruned %d channel event(s) to enforce storage "+
"limits (max-events=%d, retention=%v); pruning is "+
"enabled by default and permanently deletes events",
pruned, m.cfg.MaxEvents, m.cfg.Retention)
} else {
log.Infof("Pruned %d channel event(s) to enforce storage "+
"limits", pruned)
}
}
// waitForReady polls lnd's GetInfo until it reports SyncedToChain. It retries
// on transient RPC errors. It returns true when lnd is ready, or false if the
// monitor is shutting down.
func (m *Monitor) waitForReady(ctx context.Context) bool {
for {
info, err := m.lnd.GetInfo(ctx)
if err != nil {
log.Warnf("Error getting lnd info, retrying: %v", err)
} else if info.SyncedToChain {
return true
} else {
log.Infof("Waiting for lnd to sync to chain...")
}
select {
case <-time.After(retryInterval):
case <-m.quit:
return false
case <-ctx.Done():
return false
}
}
}
// subscribe subscribes to lnd channel events and processes them until the
// stream breaks or an error occurs. It returns true on transient failures
// (caller should retry) or false if the monitor is shutting down.
func (m *Monitor) subscribe(ctx context.Context,
pruneChan <-chan time.Time) bool {
eventChan, errChan, err := m.lnd.SubscribeChannelEvents(ctx)
if err != nil {
log.Errorf("Error subscribing to channel events: %v", err)
// Return true to signal the caller to retry.
return true
}
for {
select {
case event, ok := <-eventChan:
if !ok {
log.Warn("Channel event stream closed")
return true
}
if err := m.handleChannelEvent(ctx, event); err != nil {
log.Errorf("Error handling channel event: %v",
err)
}
case <-pruneChan:
// Periodically enforce the storage limits to keep the
// channel_events table bounded.
m.pruneEvents(ctx)
case err, ok := <-errChan:
if !ok {
log.Warn("Channel event error stream " +
"closed")
return true
}
log.Errorf("Error from channel event "+
"subscription: %v", err)
return true
case <-m.quit:
log.Info("Channel events monitor stopping")
return false
case <-ctx.Done():
log.Info("Channel events monitor stopping")
return false
}
}
}
// initialSync performs an initial sync of the channel state. It queries lnd for
// all known channels (open and closed) and records their current state in the
// database. This ensures that any channel events that occurred while faraday
// was offline are accounted for: even though individual events are lost, the
// latest state is captured as a baseline. Events recorded during initial sync
// are marked with IsSync=true to distinguish them from real-time events
// received via the subscription.
func (m *Monitor) initialSync(ctx context.Context) error {
log.Info("Performing initial sync of channel state")
closedChannels, err := m.lnd.ClosedChannels(ctx)
if err != nil {
return fmt.Errorf("error listing closed channels: %w", err)
}
for _, channel := range closedChannels {
// Abort if the context has been cancelled.
if ctx.Err() != nil {
return ctx.Err()
}
// Channels that didn't confirm onchain will be present here,
// but don't have a channel ID. We skip those.
if channel.ChannelID == 0 {
log.Debugf("Skipping closed channel with no "+
"channel ID: %s", channel.ChannelPoint)
continue
}
err := m.addChannel(
ctx, channel.PubKeyBytes, channel.ChannelPoint,
channel.ChannelID,
)
if err != nil {
log.Errorf("error adding closed channel %s: %v",
channel.ChannelPoint, err)
continue
}
dbChan, err := m.store.GetChannel(ctx, channel.ChannelPoint)
if err != nil {
log.Errorf("error getting closed channel %s from db: %v",
channel.ChannelPoint, err)
continue
}
if err := m.store.AddChannelEvent(ctx, &ChannelEvent{
ChannelID: dbChan.ID,
EventType: EventTypeOffline,
IsSync: true,
}); err != nil {
log.Errorf("error adding offline event for closed "+
"channel %s: %v", channel.ChannelPoint, err)
}
}
channels, err := m.lnd.ListChannels(ctx, false, false)
if err != nil {
return fmt.Errorf("error listing channels: %w", err)
}
for _, channel := range channels {
// Abort if the context has been cancelled.
if ctx.Err() != nil {
return ctx.Err()
}
// We make sure the channel exists in the store.
err := m.addChannel(
ctx, channel.PubKeyBytes, channel.ChannelPoint,
channel.ChannelID,
)
if err != nil {
log.Errorf("error adding channel %s: %v",
channel.ChannelPoint, err)
continue
}
dbChan, err := m.store.GetChannel(ctx, channel.ChannelPoint)
if err != nil {
log.Errorf("error getting channel %s from db: %v",
channel.ChannelPoint, err)
continue
}
eventType := EventTypeOffline
if channel.Active {
eventType = EventTypeOnline
}
if err := m.store.AddChannelEvent(ctx, &ChannelEvent{
ChannelID: dbChan.ID,
EventType: eventType,
IsSync: true,
}); err != nil {
log.Errorf("error adding event for channel %s: %v",
channel.ChannelPoint, err)
}
// We add the update event separately from the online/offline
// event above, because each event type serves a different
// purpose: the online/offline event tracks channel
// availability, while the update event captures a balance
// snapshot. Keeping them as distinct records allows querying
// availability and balance history independently.
if err := m.store.AddChannelEvent(ctx, &ChannelEvent{
ChannelID: dbChan.ID,
EventType: EventTypeUpdate,
LocalBalance: fn.Some(channel.LocalBalance),
RemoteBalance: fn.Some(channel.RemoteBalance),
IsSync: true,
}); err != nil {
log.Errorf("error adding event for channel %s: %v",
channel.ChannelPoint, err)
}
}
return nil
}
// addChannel adds a channel and its peer to the store.
func (m *Monitor) addChannel(ctx context.Context, pubKeyBytes route.Vertex,
channelPoint string, channelID uint64) error {
// Check if the channel already exists.
channel, err := m.store.GetChannel(ctx, channelPoint)
if err != nil && !errors.Is(err, ErrUnknownChannel) {
return fmt.Errorf("error getting channel %s: %w",
channelPoint, err)
}
if channel != nil {
// Channel already exists, nothing to do.
return nil
}
// Check if peer already exists.
peer, err := m.store.GetPeer(ctx, pubKeyBytes.String())
if err != nil && !errors.Is(err, errUnknownPeer) {
return fmt.Errorf("error getting peer %s: %w",
pubKeyBytes, err)
}
var peerID int64
if peer != nil {
peerID = peer.ID
} else {
peerID, err = m.store.AddPeer(
ctx, pubKeyBytes.String(),
)
if err != nil {
return fmt.Errorf("error adding peer %s: %w",
pubKeyBytes, err)
}
}
_, err = m.store.AddChannel(ctx, channelPoint, channelID, peerID)
if err != nil {
return fmt.Errorf("error adding channel %s: %w",
channelPoint, err)
}
log.Infof("Added channel %s to db", channelPoint)
return nil
}
// handleChannelEvent handles a single channel event.
func (m *Monitor) handleChannelEvent(ctx context.Context,
event *lndclient.ChannelEventUpdate) error {
switch event.UpdateType {
case lndclient.OpenChannelUpdate:
openChannel := event.OpenedChannelInfo
if openChannel == nil {
return fmt.Errorf("open_channel event is nil")
}
log.Debugf("Handling open channel event: %+v", openChannel)
// We add the new channel to the store.
if err := m.addChannel(
ctx, openChannel.PubKeyBytes, openChannel.ChannelPoint,
openChannel.ChannelID,
); err != nil {
return err
}
// Now add the online and update events.
dbChan, err := m.store.GetChannel(ctx, openChannel.ChannelPoint)
if err != nil {
return err
}
if err := m.store.AddChannelEvent(ctx, &ChannelEvent{
ChannelID: dbChan.ID,
EventType: EventTypeOnline,
}); err != nil {
return err
}
return m.addUpdateEvent(ctx, openChannel)
case lndclient.ClosedChannelUpdate:
if event.ClosedChannelInfo == nil {
return fmt.Errorf("closed_channel event is nil")
}
log.Debugf("Handling offline channel event: %+v",
event.ClosedChannelInfo)
return m.addOfflineEvent(ctx,
event.ClosedChannelInfo.ChannelPoint)
case lndclient.ActiveChannelUpdate:
log.Debugf("Handling active channel event: %v",
event.ChannelPoint)
return m.addOnlineEvent(ctx, event.ChannelPoint.String())
case lndclient.InactiveChannelUpdate:
log.Debugf("Handling offline channel event: %v",
event.ChannelPoint)
return m.addOfflineEvent(ctx, event.ChannelPoint.String())
case lndclient.PendingOpenChannelUpdate:
log.Debugf("Ignoring pending channel event: %v",
event.ChannelPoint)
return nil
case lndclient.StateChannelUpdate:
if event.UpdatedChannelInfo == nil {
return fmt.Errorf("state_update event is nil")
}
log.Debugf("Handling channel update event: %+v",
event.UpdatedChannelInfo)
return m.addUpdateEvent(ctx, event.UpdatedChannelInfo)
}
return nil
}
// addOnlineEvent adds an online event for a channel.
func (m *Monitor) addOnlineEvent(ctx context.Context,
channelPoint string) error {
channel, err := m.store.GetChannel(ctx, channelPoint)
if err != nil {
return fmt.Errorf("error getting channel %s: %w", channelPoint,
err)
}
log.Infof("Adding online event for channel %s", channelPoint)
return m.store.AddChannelEvent(ctx, &ChannelEvent{
ChannelID: channel.ID,
EventType: EventTypeOnline,
})
}
// addOfflineEvent adds an offline event for a channel.
func (m *Monitor) addOfflineEvent(ctx context.Context,
channelPoint string) error {
channel, err := m.store.GetChannel(ctx, channelPoint)
if err != nil {
return fmt.Errorf("error getting channel %s: %w", channelPoint,
err)
}
log.Infof("Adding offline event for channel %s", channelPoint)
return m.store.AddChannelEvent(ctx, &ChannelEvent{
ChannelID: channel.ID,
EventType: EventTypeOffline,
})
}
// addUpdateEvent adds an update event for a channel.
func (m *Monitor) addUpdateEvent(ctx context.Context,
channelInfo *lndclient.ChannelInfo) error {
channel, err := m.store.GetChannel(ctx, channelInfo.ChannelPoint)
if err != nil {
return fmt.Errorf("error getting channel %s: %w",
channelInfo.ChannelPoint, err)
}
log.Tracef("Adding update event for channel %s",
channelInfo.ChannelPoint)
return m.store.AddChannelEvent(ctx, &ChannelEvent{
ChannelID: channel.ID,
EventType: EventTypeUpdate,
LocalBalance: fn.Some(
btcutil.Amount(channelInfo.LocalBalance),
),
RemoteBalance: fn.Some(
btcutil.Amount(channelInfo.RemoteBalance),
),
})
}

View file

@ -1,424 +0,0 @@
package chanevents
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/faraday/db/sqlc"
"github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/sqldb/v2"
)
var (
errUnknownPeer = errors.New("unknown peer")
// ErrUnknownChannel signals that the requested channel is not
// present in the store.
ErrUnknownChannel = errors.New("unknown channel")
)
// Queries is a subset of the sqlc.Queries interface that can be used to
// interact with the peers, channels and channel_events tables.
type Queries interface {
InsertPeer(ctx context.Context, pubkey string) (int64, error)
GetPeerByPubKey(ctx context.Context, pubkey string) (sqlc.Peer, error)
InsertChannel(ctx context.Context,
arg sqlc.InsertChannelParams) (int64, error)
GetChannelByChanPoint(ctx context.Context,
channelPoint string) (sqlc.Channel, error)
GetChannelByShortChanID(ctx context.Context,
shortChannelID int64) (sqlc.Channel, error)
InsertChannelEvent(ctx context.Context,
arg sqlc.InsertChannelEventParams) error
GetChannelEvents(ctx context.Context,
arg sqlc.GetChannelEventsParams) ([]sqlc.ChannelEvent, error)
GetLatestChannelEventBefore(ctx context.Context,
arg sqlc.GetLatestChannelEventBeforeParams) (
sqlc.ChannelEvent,
error,
)
GetChannels(ctx context.Context) ([]sqlc.GetChannelsRow, error)
PruneChannelEventsBySize(ctx context.Context, offset int32) (int64,
error)
PruneChannelEventsByAge(ctx context.Context, timestamp time.Time) (
int64, error)
}
// Store provides access to the db for channel events.
type Store struct {
// db is all the higher level queries that the SQLStore has access to in
// order to implement all its CRUD logic.
db BatchedSQLQueries
// BaseDB represents the underlying database connection.
*sqldb.BaseDB
clock clock.Clock
}
// BatchedSQLQueries combines the SQLQueries interface with the BatchedTx
// interface, allowing for multiple queries to be executed in single SQL
// transaction.
type BatchedSQLQueries interface {
SQLQueries
sqldb.BatchedTx[SQLQueries]
}
// SQLQueries is a subset of the sqlc.Queries interface that can be used to
// interact with various chanevents tables.
type SQLQueries interface {
sqldb.BaseQuerier
Queries
}
type SQLQueriesExecutor[T sqldb.BaseQuerier] struct {
*sqldb.TransactionExecutor[T]
SQLQueries
}
// NewStore creates a new SQLStore instance given an open SQLQueries storage
// backend.
func NewStore(sqlDB *sqldb.BaseDB, queries *sqlc.Queries,
clock clock.Clock) *Store {
txExecutor := sqldb.NewTransactionExecutor(
sqlDB,
func(tx *sql.Tx) SQLQueries {
return queries.WithTx(tx)
},
)
executor := &SQLQueriesExecutor[SQLQueries]{
TransactionExecutor: txExecutor,
SQLQueries: queries,
}
return &Store{
db: executor,
BaseDB: sqlDB,
clock: clock,
}
}
// AddPeer adds a new peer to the database.
func (s *Store) AddPeer(ctx context.Context, pubkey string) (int64, error) {
id, err := s.db.InsertPeer(ctx, pubkey)
if err != nil {
return 0, fmt.Errorf("failed to insert peer: %w", err)
}
return id, nil
}
// GetPeer retrieves a peer by their public key.
func (s *Store) GetPeer(ctx context.Context, pubkey string) (*Peer, error) {
dbPeer, err := s.db.GetPeerByPubKey(ctx, pubkey)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errUnknownPeer
}
return nil, fmt.Errorf("failed to get peer: %w", err)
}
return &Peer{
ID: dbPeer.ID,
PubKey: dbPeer.Pubkey,
}, nil
}
// int64ToSCID converts an int64 to a uint64 ShortChannelID. The BOLT spec
// encodes SCIDs as uint64, but SQL only supports signed int64. We preserve the
// bits, which means SCIDs with the high bit set will appear negative in the
// database. Direct SQL queries (e.g. ORDER BY short_channel_id) will not sort
// these correctly, but round-tripping through Go preserves the value.
func int64ToSCID(i int64) uint64 {
return uint64(i)
}
// scidToInt64 converts a uint64 ShortChannelID to an int64 for SQL storage.
func scidToInt64(u uint64) int64 {
return int64(u)
}
// AddChannel adds a new channel for a peer.
func (s *Store) AddChannel(ctx context.Context, channelPoint string,
shortChannelID uint64, peerID int64) (int64, error) {
id, err := s.db.InsertChannel(
ctx, sqlc.InsertChannelParams{
ChannelPoint: channelPoint,
ShortChannelID: scidToInt64(shortChannelID),
PeerID: peerID,
},
)
if err != nil {
return 0, fmt.Errorf("failed to insert channel: %w", err)
}
return id, nil
}
// GetChannel retrieves a channel by its channel point.
func (s *Store) GetChannel(ctx context.Context, channelPoint string) (*Channel,
error) {
dbChannel, err := s.db.GetChannelByChanPoint(ctx, channelPoint)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUnknownChannel
}
return nil, fmt.Errorf("failed to get channel: %w", err)
}
return &Channel{
ID: dbChannel.ID,
ChannelPoint: dbChannel.ChannelPoint,
ShortChannelID: int64ToSCID(dbChannel.ShortChannelID),
PeerID: dbChannel.PeerID,
}, nil
}
// GetChannelByShortChanID retrieves a channel by its short channel ID,
// returning ErrUnknownChannel if no row matches.
func (s *Store) GetChannelByShortChanID(ctx context.Context,
shortChannelID uint64) (*Channel, error) {
dbChannel, err := s.db.GetChannelByShortChanID(
ctx, scidToInt64(shortChannelID),
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUnknownChannel
}
return nil, err
}
return &Channel{
ID: dbChannel.ID,
ChannelPoint: dbChannel.ChannelPoint,
ShortChannelID: int64ToSCID(dbChannel.ShortChannelID),
PeerID: dbChannel.PeerID,
}, nil
}
// AddChannelEvent adds a new channel event.
func (s *Store) AddChannelEvent(ctx context.Context,
event *ChannelEvent) error {
log.Tracef("Adding channel event: %+v", event)
var localBalance sql.NullInt64
event.LocalBalance.WhenSome(
func(b btcutil.Amount) {
localBalance.Int64 = int64(b)
localBalance.Valid = true
},
)
var remoteBalance sql.NullInt64
event.RemoteBalance.WhenSome(
func(b btcutil.Amount) {
remoteBalance.Int64 = int64(b)
remoteBalance.Valid = true
},
)
timestamp := event.Timestamp.UTC()
if timestamp.IsZero() {
timestamp = s.clock.Now().UTC()
}
err := s.db.InsertChannelEvent(
ctx, sqlc.InsertChannelEventParams{
ChannelID: event.ChannelID,
EventType: int16(event.EventType),
Timestamp: timestamp,
LocalBalanceSat: localBalance,
RemoteBalanceSat: remoteBalance,
IsSync: event.IsSync,
},
)
if err != nil {
return fmt.Errorf("failed to insert channel event: %w", err)
}
return nil
}
// GetChannelEvents returns up to limit events for a channel where
// id > afterID AND startTime <= timestamp < endTime, ordered by id ASC.
// Pass afterID = 0 on the first call; for subsequent calls pass the
// previous page's last event id. The (startTime, endTime) bounds are
// independent filters and do not need to advance between pages.
func (s *Store) GetChannelEvents(ctx context.Context, channelID, afterID int64,
startTime, endTime time.Time, limit int32) ([]*ChannelEvent, error) {
dbEvents, err := s.db.GetChannelEvents(
ctx, sqlc.GetChannelEventsParams{
ChannelID: channelID,
ID: afterID,
Timestamp: startTime.UTC(),
Timestamp_2: endTime.UTC(),
Limit: limit,
},
)
if err != nil {
return nil, fmt.Errorf("failed to get channel events: %w", err)
}
events := make([]*ChannelEvent, len(dbEvents))
for i, dbEvent := range dbEvents {
events[i] = marshalChannelEvent(dbEvent)
}
return events, nil
}
// ScidToPeerMap returns the historic scid→peer index, including channels that
// have since closed. Unconfirmed channels (scid still zero) are not part of
// the contract.
func (s *Store) ScidToPeerMap(ctx context.Context) (map[uint64]string, error) {
dbChannels, err := s.db.GetChannels(ctx)
if err != nil {
return nil, err
}
scidToPeer := make(map[uint64]string, len(dbChannels))
for _, dbChannel := range dbChannels {
// The short channel ID can be zero if it's not known yet. We
// should just ignore those.
if dbChannel.ShortChannelID == 0 {
continue
}
scidToPeer[uint64(dbChannel.ShortChannelID)] = dbChannel.Pubkey
}
return scidToPeer, nil
}
// GetLatestChannelUpdateBefore returns the latest channel event before a given
// time (exclusive). If no event is found, it returns (nil, nil).
func (s *Store) GetLatestChannelUpdateBefore(ctx context.Context,
channelID int64, before time.Time) (*ChannelEvent, error) {
dbEvent, err := s.db.GetLatestChannelEventBefore(
ctx, sqlc.GetLatestChannelEventBeforeParams{
ChannelID: channelID,
Timestamp: before.UTC(),
EventType: int16(EventTypeUpdate),
},
)
if err != nil {
// If there are no events before the start time, we return (nil,
// nil).
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return marshalChannelEvent(dbEvent), nil
}
// PruneEvents enforces the size and age storage limits independently,
// returning the number of events deleted. A zero maxEvents or retention
// disables the corresponding limit, and zero for both disables pruning.
func (s *Store) PruneEvents(ctx context.Context, maxEvents uint64,
retention time.Duration) (int64, error) {
// If both options are 0, pruning is completely disabled.
if maxEvents == 0 && retention == 0 {
return 0, nil
}
var pruned int64
// Enforce the size ceiling by keeping only the newest maxEvents rows.
// An offset of (maxEvents - 1) lands on the oldest row we want to keep,
// so everything with a smaller id is deleted.
if maxEvents > 0 {
// The size limit becomes an int32 SQL OFFSET below. ValidateConfig
// already rejects an out-of-range max-events, but it is not run on
// every initialization path (e.g. when faraday runs as a
// subserver), so guard the cast here too: an overflowing value
// would wrap to a tiny offset and prune almost the entire table.
if maxEvents > math.MaxInt32 {
return pruned, fmt.Errorf("maxEvents %d exceeds maximum "+
"allowed value %d", maxEvents, math.MaxInt32)
}
bySize, err := s.db.PruneChannelEventsBySize(
ctx, int32(maxEvents-1),
)
if err != nil {
return pruned, fmt.Errorf("failed to prune channel "+
"events by size: %w", err)
}
pruned += bySize
}
// Enforce the retention window by deleting anything older than the
// cutoff.
if retention > 0 {
cutoff := s.clock.Now().UTC().Add(-retention)
byAge, err := s.db.PruneChannelEventsByAge(ctx, cutoff)
if err != nil {
return pruned, fmt.Errorf("failed to prune channel "+
"events by age: %w", err)
}
pruned += byAge
}
return pruned, nil
}
// marshalChannelEvent converts a db channel event into our internal type.
func marshalChannelEvent(dbEvent sqlc.ChannelEvent) *ChannelEvent {
var localBalance fn.Option[btcutil.Amount]
if dbEvent.LocalBalanceSat.Valid {
amt := btcutil.Amount(dbEvent.LocalBalanceSat.Int64)
localBalance = fn.Some(amt)
}
var remoteBalance fn.Option[btcutil.Amount]
if dbEvent.RemoteBalanceSat.Valid {
amt := btcutil.Amount(dbEvent.RemoteBalanceSat.Int64)
remoteBalance = fn.Some(amt)
}
return &ChannelEvent{
ID: dbEvent.ID,
ChannelID: dbEvent.ChannelID,
EventType: EventType(dbEvent.EventType),
Timestamp: dbEvent.Timestamp.UTC(),
LocalBalance: localBalance,
RemoteBalance: remoteBalance,
IsSync: dbEvent.IsSync,
}
}

View file

@ -1,475 +0,0 @@
package chanevents
import (
"context"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/stretchr/testify/require"
)
var (
testPubKey = "028d4c6347426f2e3f5e2b8e4a1c3b9f1" +
"c4e5d6f7a8b9c0d1e2f3a4b5c6d7e8f9"
testChanPoint1 = "test_txid:0"
testChanPoint2 = "test_txid:1"
testShortChanID1 uint64 = 123
testShortChanID2 uint64 = 456
testTime = time.Unix(1, 0)
)
// requireEqualEvent asserts that a retrieved event matches the expected values,
// comparing only the fields that are set before insertion (ignoring the
// auto-assigned ID).
func requireEqualEvent(t *testing.T, expected *ChannelEvent,
expectedTime time.Time, actual *ChannelEvent) {
t.Helper()
require.Equal(t, expected.ChannelID, actual.ChannelID)
require.Equal(t, expected.EventType, actual.EventType)
require.Equal(t, expectedTime.Unix(), actual.Timestamp.Unix())
require.Equal(t, expected.LocalBalance, actual.LocalBalance)
require.Equal(t, expected.RemoteBalance, actual.RemoteBalance)
require.Equal(t, expected.IsSync, actual.IsSync)
}
// TestStore tests the chanevents store.
func TestStore(t *testing.T) {
t.Parallel()
// First, create a new test database.
clock := clock.NewTestClock(testTime)
store := NewTestDB(t, clock)
ctx := context.Background()
// *** Peers *** Add a peer.
peer := &Peer{PubKey: testPubKey}
peerID, err := store.AddPeer(ctx, peer.PubKey)
require.NoError(t, err)
require.NotZero(t, peerID)
// Adding the same peer again violates the unique constraint.
_, err = store.AddPeer(ctx, peer.PubKey)
require.Error(t, err)
dbPeer, err := store.GetPeer(ctx, "non_existent_pubkey")
require.ErrorIs(t, err, errUnknownPeer)
require.Nil(t, dbPeer)
// Get the peer and assert it is the same.
dbPeer, err = store.GetPeer(ctx, peer.PubKey)
require.NoError(t, err)
require.Equal(t, peer.PubKey, dbPeer.PubKey)
// *** Channels *** Add a channel for an unknown peer and assert an
// error is returned.
channelID, err := store.AddChannel(
ctx, testChanPoint1, testShortChanID1, 9999,
)
require.Error(t, err)
require.Zero(t, channelID)
// Add a channel for the peer.
channelID, err = store.AddChannel(
ctx, testChanPoint1, testShortChanID1, peerID,
)
require.NoError(t, err)
require.NotZero(t, channelID)
// Get a non-existent channel and assert an error is returned.
dbChannel, err := store.GetChannel(ctx, "non-existent-chan-point")
require.ErrorIs(t, err, ErrUnknownChannel)
require.Nil(t, dbChannel)
// Get the channel and assert it is the same.
dbChannel, err = store.GetChannel(ctx, testChanPoint1)
require.NoError(t, err)
require.Equal(t, testChanPoint1, dbChannel.ChannelPoint)
require.Equal(t, testShortChanID1, dbChannel.ShortChannelID)
require.Equal(t, peerID, dbChannel.PeerID)
// Look up the same channel by its scid; the analyzer relies on this
// inverse of AddChannel.
dbChannel, err = store.GetChannelByShortChanID(ctx, testShortChanID1)
require.NoError(t, err)
require.Equal(t, channelID, dbChannel.ID)
require.Equal(t, testChanPoint1, dbChannel.ChannelPoint)
require.Equal(t, testShortChanID1, dbChannel.ShortChannelID)
require.Equal(t, peerID, dbChannel.PeerID)
// An unknown scid surfaces the typed sentinel, not raw sql.ErrNoRows.
dbChannel, err = store.GetChannelByShortChanID(ctx, 9999)
require.ErrorIs(t, err, ErrUnknownChannel)
require.Nil(t, dbChannel)
// Add a second channel for the same peer.
channel2ID, err := store.AddChannel(
ctx, testChanPoint2, testShortChanID2, peerID,
)
require.NoError(t, err)
require.NotZero(t, channel2ID)
// Get the historic channel to peer map.
chanToPeer, err := store.ScidToPeerMap(ctx)
require.NoError(t, err)
require.Len(t, chanToPeer, 2)
require.Equal(t, testPubKey, chanToPeer[testShortChanID1])
require.Equal(t, testPubKey, chanToPeer[testShortChanID2])
// Add an online event for the channel.
onlineEvent := &ChannelEvent{
ChannelID: channelID,
EventType: EventTypeOnline,
}
err = store.AddChannelEvent(ctx, onlineEvent)
require.NoError(t, err)
// Advance the clock for the next event.
clock.SetTime(testTime.Add(time.Second))
// Add an update event for the channel.
localBalance := btcutil.Amount(1000)
remoteBalance := btcutil.Amount(2000)
updateEvent := &ChannelEvent{
ChannelID: channelID,
EventType: EventTypeUpdate,
LocalBalance: fn.Some(localBalance),
RemoteBalance: fn.Some(remoteBalance),
}
err = store.AddChannelEvent(ctx, updateEvent)
require.NoError(t, err)
// Get the channel events and assert they are correct.
events, err := store.GetChannelEvents(
ctx, channelID, 0, time.Unix(0, 0), time.Unix(3, 0), 100,
)
require.NoError(t, err)
require.Len(t, events, 2)
requireEqualEvent(t, onlineEvent, testTime, events[0])
requireEqualEvent(
t, updateEvent, testTime.Add(time.Second), events[1],
)
updateEvent = events[1]
// If we query a time after the update event, we'll obtain the update
// event as the latest event.
initEvent, err := store.GetLatestChannelUpdateBefore(
ctx, channelID, updateEvent.Timestamp.Add(500*time.Millisecond),
)
require.NoError(t, err)
requireEqualEvent(t, updateEvent, testTime.Add(time.Second), initEvent)
// If we query at the update event's timestamp, the only event before
// that is left is the online event, which is not an update.
initEvent, err = store.GetLatestChannelUpdateBefore(
ctx, channelID, updateEvent.Timestamp,
)
require.NoError(t, err)
require.Nil(t, initEvent)
// Advance the clock and add a sync event to verify the IsSync flag
// round-trips correctly.
clock.SetTime(testTime.Add(2 * time.Second))
syncEvent := &ChannelEvent{
ChannelID: channelID,
EventType: EventTypeOnline,
IsSync: true,
}
err = store.AddChannelEvent(ctx, syncEvent)
require.NoError(t, err)
events, err = store.GetChannelEvents(
ctx, channelID, 0, time.Unix(0, 0), time.Unix(4, 0), 100,
)
require.NoError(t, err)
require.Len(t, events, 3)
requireEqualEvent(
t, syncEvent, testTime.Add(2*time.Second), events[2],
)
}
// pruneFixture is an isolated environment for a single TestPruneEvents case. It
// holds a fresh store with two channels and a fixed clock, and exposes helpers
// to seed events and inspect the table without leaking state between cases.
type pruneFixture struct {
t *testing.T
store *Store
ctx context.Context
chan1 int64
chan2 int64
now time.Time
old time.Time
recent time.Time
}
// newPruneFixture builds a fresh store with two channels on one peer and pins
// the clock to a reference point. It derives an "old" timestamp well outside
// and a "recent" timestamp well inside a 30-day retention window.
func newPruneFixture(t *testing.T) *pruneFixture {
t.Helper()
clk := clock.NewTestClock(testTime)
store := NewTestDB(t, clk)
ctx := context.Background()
peerID, err := store.AddPeer(ctx, testPubKey)
require.NoError(t, err)
chan1, err := store.AddChannel(
ctx, testChanPoint1, testShortChanID1, peerID,
)
require.NoError(t, err)
chan2, err := store.AddChannel(
ctx, testChanPoint2, testShortChanID2, peerID,
)
require.NoError(t, err)
now := testTime.Add(100 * 24 * time.Hour)
clk.SetTime(now)
return &pruneFixture{
t: t,
store: store,
ctx: ctx,
chan1: chan1,
chan2: chan2,
now: now,
old: now.Add(-90 * 24 * time.Hour),
recent: now.Add(-5 * 24 * time.Hour),
}
}
// addEvents inserts n update events on the given channel at timestamp ts.
func (f *pruneFixture) addEvents(channelID int64, ts time.Time, n int) {
f.t.Helper()
for i := 0; i < n; i++ {
err := f.store.AddChannelEvent(f.ctx, &ChannelEvent{
ChannelID: channelID,
EventType: EventTypeUpdate,
Timestamp: ts,
})
require.NoError(f.t, err)
}
}
// events returns all stored events for a single channel.
func (f *pruneFixture) events(channelID int64) []*ChannelEvent {
f.t.Helper()
events, err := f.store.GetChannelEvents(
f.ctx, channelID, 0, time.Unix(0, 0), f.now.Add(time.Hour),
1000,
)
require.NoError(f.t, err)
return events
}
// count returns the total number of events across both channels.
func (f *pruneFixture) count() int {
return len(f.events(f.chan1)) + len(f.events(f.chan2))
}
// requireAllRecent asserts that every surviving event lies within the
// retention window, confirming age-based pruning drops the old events rather
// than the recent ones.
func (f *pruneFixture) requireAllRecent() {
f.t.Helper()
all := append(f.events(f.chan1), f.events(f.chan2)...)
for _, e := range all {
require.Equal(f.t, f.recent.Unix(), e.Timestamp.Unix())
}
}
// TestPruneEvents verifies that PruneEvents enforces the max-events count and
// the retention window independently. Each case runs against its own fresh
// store so the size and age limits can be exercised in isolation.
func TestPruneEvents(t *testing.T) {
t.Parallel()
const retention = 30 * 24 * time.Hour
tests := []struct {
name string
seed func(f *pruneFixture)
maxEvents uint64
retention time.Duration
wantTotal int
verify func(f *pruneFixture)
}{{
// Pruning an empty table succeeds and deletes nothing.
name: "empty database",
maxEvents: 10,
retention: retention,
wantTotal: 0,
}, {
// A count equal to max-events is at the ceiling, not over it,
// so all events are kept.
name: "count equal to max-events keeps all",
seed: func(f *pruneFixture) {
f.addEvents(f.chan1, f.recent, 5)
},
maxEvents: 5,
retention: retention,
wantTotal: 5,
}, {
// Both limits zero disables pruning entirely, even for events
// outside the retention window.
name: "both limits zero disables pruning",
seed: func(f *pruneFixture) {
f.addEvents(f.chan1, f.recent, 5)
f.addEvents(f.chan1, f.old, 3)
},
maxEvents: 0,
retention: 0,
wantTotal: 8,
}, {
// The age limit alone drops events older than the window and
// keeps the recent ones.
name: "age limit prunes old events",
seed: func(f *pruneFixture) {
f.addEvents(f.chan1, f.recent, 5)
f.addEvents(f.chan1, f.old, 3)
},
maxEvents: 0,
retention: retention,
wantTotal: 5,
verify: func(f *pruneFixture) {
f.requireAllRecent()
},
}, {
// The size limit bounds the global table across channels and
// keeps the newest events, even when all are inside the
// retention window. Channel 2 is seeded last, so its events
// have the newest ids and must be the survivors.
name: "size limit prunes oldest across channels",
seed: func(f *pruneFixture) {
f.addEvents(f.chan1, f.recent, 5)
f.addEvents(f.chan2, f.recent, 5)
},
maxEvents: 4,
retention: retention,
wantTotal: 4,
verify: func(f *pruneFixture) {
require.Empty(f.t, f.events(f.chan1))
require.Len(f.t, f.events(f.chan2), 4)
},
}, {
// With the size ceiling not exceeded, the age limit still
// prunes old events independently.
name: "age limit prunes with size headroom",
seed: func(f *pruneFixture) {
f.addEvents(f.chan2, f.recent, 4)
f.addEvents(f.chan1, f.old, 3)
},
maxEvents: 10,
retention: retention,
wantTotal: 4,
verify: func(f *pruneFixture) {
f.requireAllRecent()
},
}, {
// Retention zero disables the age limit. With the count under
// max-events nothing is pruned.
name: "retention zero disables age limit",
seed: func(f *pruneFixture) {
f.addEvents(f.chan2, f.recent, 4)
},
maxEvents: 10,
retention: 0,
wantTotal: 4,
}, {
// Max-events zero disables the size limit. The age limit still
// prunes old events on its own.
name: "max-events zero leaves age limit active",
seed: func(f *pruneFixture) {
f.addEvents(f.chan2, f.recent, 4)
f.addEvents(f.chan1, f.old, 3)
},
maxEvents: 0,
retention: retention,
wantTotal: 4,
verify: func(f *pruneFixture) {
f.requireAllRecent()
},
}}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
f := newPruneFixture(t)
if tc.seed != nil {
tc.seed(f)
}
_, err := f.store.PruneEvents(
f.ctx, tc.maxEvents, tc.retention,
)
require.NoError(t, err)
require.Equal(t, tc.wantTotal, f.count())
if tc.verify != nil {
tc.verify(f)
}
})
}
}
// TestPagination verifies that the keyset cursor advances correctly across
// events sharing one second-resolution timestamp.
func TestPagination(t *testing.T) {
t.Parallel()
clock := clock.NewTestClock(testTime)
store := NewTestDB(t, clock)
ctx := context.Background()
peerID, err := store.AddPeer(ctx, testPubKey)
require.NoError(t, err)
channelID, err := store.AddChannel(
ctx, testChanPoint1, testShortChanID1, peerID,
)
require.NoError(t, err)
sameTime := testTime.Add(10 * time.Second)
for i := 0; i < 5; i++ {
err = store.AddChannelEvent(ctx, &ChannelEvent{
ChannelID: channelID,
EventType: EventTypeUpdate,
Timestamp: sameTime,
LocalBalance: fn.Some(btcutil.Amount(i)),
})
require.NoError(t, err)
}
endTime := sameTime.Add(time.Hour)
page1, err := store.GetChannelEvents(
ctx, channelID, 0, time.Unix(0, 0), endTime, 3,
)
require.NoError(t, err)
require.Len(t, page1, 3)
page2, err := store.GetChannelEvents(
ctx, channelID, page1[len(page1)-1].ID,
time.Unix(0, 0), endTime, 3,
)
require.NoError(t, err)
require.Len(t, page2, 2)
require.Equal(t, btcutil.Amount(3), page2[0].LocalBalance.UnwrapOr(0))
require.Equal(t, btcutil.Amount(4), page2[1].LocalBalance.UnwrapOr(0))
}

View file

@ -1,29 +0,0 @@
//go:build test_db_postgres
package chanevents
import (
"testing"
"github.com/lightninglabs/faraday/db"
"github.com/lightningnetwork/lnd/clock"
"github.com/stretchr/testify/require"
)
// NewTestDB creates a new test chanevents.Store backed by a postgres DB.
func NewTestDB(t testing.TB, clock clock.Clock) *Store {
// We'll create a new test database. The call to NewTestPostgresDB will
// automatically create the DB and apply the migrations.
testDB := db.NewTestPostgresDB(t)
// Now, we'll create the FaradayDB instance from the test database. The
// FaradayDB is the main database object that holds the connection and
// the generated querier.
faradayDB := createStore(t, testDB.BaseDB, clock)
t.Cleanup(func() {
require.NoError(t, faradayDB.Close())
})
return faradayDB
}

View file

@ -1,18 +0,0 @@
package chanevents
import (
"testing"
"github.com/lightninglabs/faraday/db/sqlc"
"github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/sqldb/v2"
)
// createStore is a helper function that creates a new Store.
func createStore(t testing.TB, sqlDB *sqldb.BaseDB, clock clock.Clock) *Store {
queries := sqlc.NewForType(sqlDB, sqlDB.BackendType)
store := NewStore(sqlDB, queries, clock)
return store
}

View file

@ -1,30 +0,0 @@
//go:build !test_db_postgres
package chanevents
import (
"testing"
"github.com/lightninglabs/faraday/db"
"github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/sqldb/v2"
"github.com/stretchr/testify/require"
)
// NewTestDB creates a new test chanevents.Store backed by a sqlite DB.
func NewTestDB(t testing.TB, clock clock.Clock) *Store {
// We'll create a new test database. The call to NewTestSqliteDB will
// automatically create the DB and apply the migrations.
testDB := sqldb.NewTestSqliteDB(t, db.FaradayMigrationSets)
// Now, we'll create the FaradayDB instance from the test database. The
// FaradayDB is the main database object that holds the connection and
// the generated querier.
faradayDB := createStore(t, testDB.BaseDB, clock)
t.Cleanup(func() {
require.NoError(t, faradayDB.Close())
})
return faradayDB
}

View file

@ -1,97 +0,0 @@
package main
import (
"context"
"fmt"
"github.com/lightninglabs/faraday/frdrpc"
"github.com/urfave/cli"
)
var chanEventsCommand = cli.Command{
Name: "chanevents",
Category: "reporting",
Usage: "Get a report of channel events.",
Description: `
Get a report for a channel which provides a detailed account of its
lifecycle events. The server caps each response; if has_more is true,
fetch the next page by re-running with --last_id set to the previous
response's last_id. Stop when has_more is false. --start_time and
--end_time are independent filters and do not need to advance between
paginated calls.`,
ArgsUsage: "funding_txid [output_index]",
Flags: []cli.Flag{
cli.StringFlag{
Name: "funding_txid",
Usage: "the txid of the channel's funding transaction",
},
cli.IntFlag{
Name: "output_index",
Usage: "the output index for the funding output of " +
"the funding transaction",
},
cli.Int64Flag{
Name: "start_time",
Usage: "start time of the query range as a unix timestamp",
},
cli.Int64Flag{
Name: "end_time",
Usage: "end time of the query range as a unix " +
"timestamp; zero defaults to the server's " +
"current time",
},
cli.UintFlag{
Name: "max_events",
Usage: "maximum number of events to return; zero " +
"uses the server default (capped server-side)",
},
cli.Int64Flag{
Name: "last_id",
Usage: "pagination cursor; pass the previous " +
"response's last_id to continue, or zero " +
"for the first page",
},
},
Action: queryChanEvents,
}
func queryChanEvents(ctx *cli.Context) error {
client, cleanup := getClient(ctx)
defer cleanup()
// Show command help if the channel point was not provided.
if ctx.NArg() == 0 && ctx.String("funding_txid") == "" {
return cli.ShowCommandHelp(ctx, "chanevents")
}
outpoint, err := parseChannelPoint(ctx)
if err != nil {
return err
}
startTime := ctx.Int64("start_time")
endTime := ctx.Int64("end_time")
if startTime < 0 || endTime < 0 {
return fmt.Errorf("start_time and end_time must be >= 0")
}
if endTime != 0 && startTime > endTime {
return fmt.Errorf("start_time must be <= end_time")
}
req := &frdrpc.ChannelEventsRequest{
ChanPoint: outpoint.String(),
StartTime: startTime,
EndTime: endTime,
MaxEvents: uint32(ctx.Uint("max_events")),
LastId: ctx.Int64("last_id"),
}
rpcCtx := context.Background()
report, err := client.GetChannelEvents(rpcCtx, req)
if err != nil {
return err
}
printRespJSON(report)
return nil
}

View file

@ -15,11 +15,11 @@ import (
var fiatBackendFlag = cli.StringFlag{
Name: "fiat_backend",
Usage: fmt.Sprintf("fiat backend to be used. Options include: '%v' "+
"(default), '%v', `%v`, `%v` or `%v`, which allows custom "+
"price data to be used. The `%v` option requires the "+
"(default), '%v', `%v` or `%v`, which allows custom price "+
"data to be used. The `%v` option requires the "+
"`prices_csv_path` and `custom_price_currency` options to be "+
"set", fiat.CoinDeskPriceBackend, fiat.CoinCapPriceBackend,
fiat.CoinGeckoPriceBackend, fiat.BitfinexPriceBackend,
fiat.CoinGeckoPriceBackend,
fiat.CustomPriceBackend, fiat.CustomPriceBackend),
}

View file

@ -1,122 +0,0 @@
package main
import (
"context"
"sort"
"github.com/lightninglabs/faraday/frdrpc"
"github.com/urfave/cli"
)
var forwardingAbilityCommand = cli.Command{
Name: "forwardingability",
Category: "insights",
Usage: "Get forwarding ability analysis of peer pairs.",
Flags: []cli.Flag{
cli.Uint64Flag{
Name: "start_time",
Usage: "start time of the query range as a unix " +
"timestamp",
},
cli.Uint64Flag{
Name: "end_time",
Usage: "end time of the query range as a unix " +
"timestamp; zero defaults to the server's " +
"current time",
},
cli.Uint64Flag{
Name: "liquidity_floor_sat",
Usage: "the minimum directional liquidity in " +
"satoshis for a pair to count as " +
"economically forwardable; zero uses the " +
"server default",
},
cli.Float64Flag{
Name: "uptime_threshold",
Usage: "the uptime fraction in [0,1] at or above " +
"which a non-forwarding pair is reported as " +
"up but idle; zero uses the server default",
},
},
Action: queryForwardingAbility,
}
type pairView struct {
PeerIn string `json:"peer_in"`
PeerOut string `json:"peer_out"`
EffectiveUptimeS int64 `json:"effective_uptime_s"`
ForwardedSat int64 `json:"forwarded_sat"`
UptimeFraction float64 `json:"uptime_fraction"`
Velocity float64 `json:"velocity"`
}
func queryForwardingAbility(ctx *cli.Context) error {
client, cleanup := getClient(ctx)
defer cleanup()
req := &frdrpc.ForwardingAbilityRequest{
StartTime: ctx.Uint64("start_time"),
EndTime: ctx.Uint64("end_time"),
LiquidityFloorSat: ctx.Uint64("liquidity_floor_sat"),
UptimeThreshold: ctx.Float64("uptime_threshold"),
}
rpcCtx := context.Background()
resp, err := client.ForwardingAbility(rpcCtx, req)
if err != nil {
return err
}
abilities, err := frdrpc.DecodeForwardingAbility(resp)
if err != nil {
return err
}
// The metrics are raw, so derive uptime fraction and velocity here from
// the window the server reported.
windowSeconds := resp.EndTime - resp.StartTime
var views []pairView
for inPeer, outMap := range abilities {
for outPeer, ability := range outMap {
var uptimeFraction, velocity float64
if windowSeconds > 0 {
uptimeFraction = float64(
ability.EffectiveUptimeS,
) / float64(windowSeconds)
}
if ability.EffectiveUptimeS > 0 {
velocity = float64(ability.ForwardedSat) /
float64(ability.EffectiveUptimeS)
}
views = append(
views, pairView{
PeerIn: inPeer,
PeerOut: outPeer,
EffectiveUptimeS: ability.EffectiveUptimeS,
ForwardedSat: ability.ForwardedSat,
UptimeFraction: uptimeFraction,
Velocity: velocity,
},
)
}
}
// Stable sort by PeerIn, then PeerOut.
sort.SliceStable(
views,
func(i, j int) bool {
if views[i].PeerIn != views[j].PeerIn {
return views[i].PeerIn < views[j].PeerIn
}
return views[i].PeerOut < views[j].PeerOut
},
)
printJSON(views)
return nil
}

View file

@ -57,8 +57,6 @@ func main() {
fiatEstimateCommand,
onChainReportCommand,
closeReportCommand,
chanEventsCommand,
forwardingAbilityCommand,
}
if err := app.Run(os.Args); err != nil {

View file

@ -22,13 +22,13 @@ import (
"github.com/lightninglabs/faraday/frdrpc"
"github.com/lightninglabs/faraday/utils"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/protobuf-hex-display/jsonpb"
"github.com/lightninglabs/protobuf-hex-display/proto"
"github.com/lightningnetwork/lnd/lncfg"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/macaroons"
"github.com/urfave/cli"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/protobuf/proto"
"gopkg.in/macaroon.v2"
)
@ -50,13 +50,19 @@ func fatal(err error) {
// printRespJSON prints a proto message as json.
func printRespJSON(resp proto.Message) {
jsonBytes, err := lnrpc.ProtoJSONMarshalOpts.Marshal(resp)
jsonMarshaler := &jsonpb.Marshaler{
OrigName: true,
EmitDefaults: true,
Indent: " ",
}
jsonStr, err := jsonMarshaler.MarshalToString(resp)
if err != nil {
fmt.Println("unable to decode response: ", err)
return
}
fmt.Println(string(jsonBytes))
fmt.Println(jsonStr)
}
func printJSON(resp interface{}) {
@ -159,23 +165,12 @@ func extractPathArgs(ctx *cli.Context) (string, string, error) {
if faradayDir != faraday.FaradayDirBase ||
networkStr != faraday.DefaultNetwork {
// Only overwrite the tls cert path if the user has not
// explicitly defined it.
if !ctx.GlobalIsSet(tlsCertFlag.Name) {
tlsCertPath = filepath.Join(
faradayDir, networkStr,
faraday.DefaultTLSCertFilename,
)
}
// Only overwrite the macaroon path if the user has not
// explicitly defined it.
if !ctx.GlobalIsSet(macaroonPathFlag.Name) {
macPath = filepath.Join(
faradayDir, networkStr,
faraday.DefaultMacaroonFilename,
)
}
tlsCertPath = filepath.Join(
faradayDir, networkStr, faraday.DefaultTLSCertFilename,
)
macPath = filepath.Join(
faradayDir, networkStr, faraday.DefaultMacaroonFilename,
)
}
return tlsCertPath, macPath, nil
@ -288,11 +283,6 @@ func parseChannelPoint(ctx *cli.Context) (*wire.OutPoint, error) {
return channelPoint, nil
}
// fiatBackendBitfinex is the rpc enum value for BITFINEX.
// TODO: Replace with frdrpc.FiatBackend_BITFINEX once the frdrpc module is
// tagged and the dependency is bumped.
const fiatBackendBitfinex = frdrpc.FiatBackend(5)
// parseFiatBackend parses the user chosen fiat backend into a FiatBackend type.
func parseFiatBackend(fiatBackend string) (frdrpc.FiatBackend, error) {
switch fiatBackend {
@ -311,9 +301,6 @@ func parseFiatBackend(fiatBackend string) (frdrpc.FiatBackend, error) {
case fiat.CoinGeckoPriceBackend.String():
return frdrpc.FiatBackend_COINGECKO, nil
case fiat.BitfinexPriceBackend.String():
return fiatBackendBitfinex, nil
default:
return frdrpc.FiatBackend_UNKNOWN_FIATBACKEND, fmt.Errorf(
"unknown fiat backend",

View file

@ -3,9 +3,7 @@ package main
import (
"testing"
"github.com/lightninglabs/faraday/fiat"
"github.com/lightninglabs/faraday/frdrpc"
"github.com/stretchr/testify/require"
)
// TestFilterPrices checks that the filterPrices function correctly filters
@ -120,64 +118,3 @@ func TestFilterPrices(t *testing.T) {
})
}
}
// TestParseFiatBackend checks that known backend strings map to expected
// rpc enum values.
func TestParseFiatBackend(t *testing.T) {
t.Parallel()
tests := []struct {
name string
backendStr string
expected frdrpc.FiatBackend
expectErr bool
}{
{
name: "empty uses unknown",
backendStr: "",
expected: frdrpc.FiatBackend_UNKNOWN_FIATBACKEND,
},
{
name: "coincap",
backendStr: fiat.CoinCapPriceBackend.String(),
expected: frdrpc.FiatBackend_COINCAP,
},
{
name: "coindesk",
backendStr: fiat.CoinDeskPriceBackend.String(),
expected: frdrpc.FiatBackend_COINDESK,
},
{
name: "custom",
backendStr: fiat.CustomPriceBackend.String(),
expected: frdrpc.FiatBackend_CUSTOM,
},
{
name: "coingecko",
backendStr: fiat.CoinGeckoPriceBackend.String(),
expected: frdrpc.FiatBackend_COINGECKO,
},
{
name: "bitfinex",
backendStr: fiat.BitfinexPriceBackend.String(),
expected: fiatBackendBitfinex,
},
{
name: "unknown backend",
backendStr: "not-a-backend",
expectErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
backend, err := parseFiatBackend(test.backendStr)
if test.expectErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, test.expected, backend)
}
})
}
}

201
config.go
View file

@ -4,7 +4,6 @@ import (
"crypto/tls"
"crypto/x509"
"fmt"
"math"
"os"
"path"
"path/filepath"
@ -12,16 +11,10 @@ import (
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/faraday/chain"
"github.com/lightninglabs/faraday/chanevents"
"github.com/lightninglabs/faraday/db"
"github.com/lightninglabs/faraday/db/sqlc"
"github.com/lightninglabs/lndclient"
"github.com/lightningnetwork/lnd/build"
"github.com/lightningnetwork/lnd/cert"
"github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/lncfg"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/sqldb/v2"
"google.golang.org/grpc/credentials"
)
@ -37,32 +30,10 @@ const (
// we can serve basic functionality by default.
defaultChainConn = false
// defaultTLSCertDuration is the default validity of a self-signed
// DefaultAutogenValidity is the default validity of a self-signed
// certificate. The value corresponds to 14 months
// (14 months * 30 days * 24 hours).
defaultTLSCertDuration = 14 * 30 * 24 * time.Hour
// DatabaseBackendSqlite is the name of the SQLite database backend.
DatabaseBackendSqlite = "sqlite"
// DatabaseBackendPostgres is the name of the Postgres database backend.
DatabaseBackendPostgres = "postgres"
// defaultSqliteDatabaseFileName is the default name of the SQLite
// database file.
defaultSqliteDatabaseFileName = "faraday.db"
// defaultChanEventsMaxEvents is the default maximum number of channel
// events to retain. At roughly 140 bytes per event this acts as a hard
// ceiling of approximately 1 GB. A value of 0 disables the size-based
// limit.
defaultChanEventsMaxEvents = 7000000
// defaultChanEventsRetention is the default retention window for channel
// events. Age-based pruning is disabled by default (0): out of the box
// only the max-events size ceiling bounds the table, and operators opt
// into a retention window explicitly.
defaultChanEventsRetention = 0
DefaultAutogenValidity = 14 * 30 * 24 * time.Hour
)
var (
@ -135,9 +106,6 @@ type LndConfig struct {
// TLSCertPath is the path to the tls cert that faraday should use.
TLSCertPath string `long:"tlscertpath" description:"Path to TLS cert"`
// RequestTimeout is the maximum time to wait for a response from lnd.
RequestTimeout time.Duration `long:"requesttimeout" description:"The maximum time to wait for a response from lnd, if not set the default of 30 seconds will be used."`
}
type Config struct { //nolint:maligned
@ -156,19 +124,18 @@ type Config struct { //nolint:maligned
MinimumMonitored time.Duration `long:"min_monitored" description:"The minimum amount of time that a channel must be monitored for before recommending termination. Valid time units are {s, m, h}."`
// Network is a string containing the network we're running on.
Network string `long:"network" description:"The network to run on." choice:"regtest" choice:"testnet" choice:"mainnet" choice:"simnet" choice:"signet" `
Network string `long:"network" description:"The network to run on." choice:"regtest" choice:"testnet" choice:"mainnet" choice:"simnet"`
// DebugLevel is a string defining the log level for the service either
// for all subsystems the same or individual level by subsystem.
DebugLevel string `long:"debuglevel" description:"Debug level for faraday and its subsystems."`
TLSCertPath string `long:"tlscertpath" description:"Path to write the TLS certificate for faraday's RPC and REST services."`
TLSKeyPath string `long:"tlskeypath" description:"Path to write the TLS private key for faraday's RPC and REST services."`
TLSExtraIPs []string `long:"tlsextraip" description:"Adds an extra IP to the generated certificate."`
TLSExtraDomains []string `long:"tlsextradomain" description:"Adds an extra domain to the generated certificate."`
TLSAutoRefresh bool `long:"tlsautorefresh" description:"Re-generate TLS certificate and key if the IPs or domains are changed."`
TLSDisableAutofill bool `long:"tlsdisableautofill" description:"Do not include the interface IPs or the system hostname in TLS certificate, use first --tlsextradomain as Common Name instead, if set."`
TLSCertDuration time.Duration `long:"tlscertduration" description:"The duration for which the auto-generated TLS certificate will be valid for."`
TLSCertPath string `long:"tlscertpath" description:"Path to write the TLS certificate for faraday's RPC and REST services."`
TLSKeyPath string `long:"tlskeypath" description:"Path to write the TLS private key for faraday's RPC and REST services."`
TLSExtraIPs []string `long:"tlsextraip" description:"Adds an extra IP to the generated certificate."`
TLSExtraDomains []string `long:"tlsextradomain" description:"Adds an extra domain to the generated certificate."`
TLSAutoRefresh bool `long:"tlsautorefresh" description:"Re-generate TLS certificate and key if the IPs or domains are changed."`
TLSDisableAutofill bool `long:"tlsdisableautofill" description:"Do not include the interface IPs or the system hostname in TLS certificate, use first --tlsextradomain as Common Name instead, if set."`
MacaroonPath string `long:"macaroonpath" description:"Path to write the macaroon for faraday's RPC and REST services if it doesn't exist."`
@ -183,24 +150,6 @@ type Config struct { //nolint:maligned
// Bitcoin is the configuration required to connect to a bitcoin node.
Bitcoin *chain.BitcoinConfig `group:"bitcoin" namespace:"bitcoin"`
// Logging controls various aspects of pool logging.
Logging *build.LogConfig `group:"logging" namespace:"logging"`
// DatabaseBackend is the database backend we will use for storing all
// liveness data.
DatabaseBackend string `long:"databasebackend" description:"The database backend to use for storing all liveness data." choice:"sqlite" choice:"postgres"`
// Sqlite holds the configuration options for a SQLite database
// backend.
Sqlite *db.SqliteConfig `group:"sqlite" namespace:"sqlite"`
// Postgres holds the configuration options for a Postgres database
Postgres *sqldb.PostgresConfig `group:"postgres" namespace:"postgres"`
// ChanEvents holds the configuration options for channel event safety
// pruning.
ChanEvents *chanevents.Config `group:"chanevents" namespace:"chanevents"`
}
// DefaultConfig returns all default values for the Config struct.
@ -216,20 +165,10 @@ func DefaultConfig() Config {
DebugLevel: defaultDebugLevel,
TLSCertPath: DefaultTLSCertPath,
TLSKeyPath: DefaultTLSKeyPath,
TLSCertDuration: defaultTLSCertDuration,
MacaroonPath: DefaultMacaroonPath,
RPCListen: defaultRPCListen,
ChainConn: defaultChainConn,
Bitcoin: chain.DefaultConfig,
Logging: build.DefaultLogConfig(),
DatabaseBackend: DatabaseBackendSqlite,
Sqlite: &db.SqliteConfig{
DatabaseFileName: defaultSqliteDatabaseFileName,
},
ChanEvents: &chanevents.Config{
MaxEvents: defaultChanEventsMaxEvents,
Retention: defaultChanEventsRetention,
},
}
}
@ -248,10 +187,6 @@ func ValidateConfig(config *Config) error {
config.TLSKeyPath = lncfg.CleanAndExpandPath(config.TLSKeyPath)
config.MacaroonPath = lncfg.CleanAndExpandPath(config.MacaroonPath)
// Before adding the network namespace below, check if the user has
// overwritten the default faraday directory.
faradayDirSet := config.FaradayDir != FaradayDirBase
// Append the network type to faraday directory so they are "namespaced"
// per network.
config.FaradayDir = filepath.Join(config.FaradayDir, config.Network)
@ -265,6 +200,7 @@ func ValidateConfig(config *Config) error {
// values, make sure that they are not set when faraday dir is set. We
// fail hard here rather than overwriting and potentially confusing the
// user.
faradayDirSet := config.FaradayDir != FaradayDirBase
if faradayDirSet {
tlsCertPathSet := config.TLSCertPath != DefaultTLSCertPath
tlsKeyPathSet := config.TLSKeyPath != DefaultTLSKeyPath
@ -364,25 +300,6 @@ func ValidateConfig(config *Config) error {
config.Lnd.TLSCertPath,
)
if config.ChanEvents != nil {
// The channel event size limit becomes an int32 SQL OFFSET
// during pruning, so reject values that would overflow it and
// silently corrupt the prune bound.
if config.ChanEvents.MaxEvents > math.MaxInt32 {
return fmt.Errorf("chanevents.max-events must not "+
"exceed %d", math.MaxInt32)
}
// A negative retention is silently ignored by the prune checks,
// which only treat a strictly positive duration as enabling
// age-based pruning. Reject it so a misconfigured window fails
// loudly instead of disabling pruning unexpectedly.
if config.ChanEvents.Retention < 0 {
return fmt.Errorf("chanevents.retention must not be " +
"negative")
}
}
return nil
}
@ -446,7 +363,7 @@ func loadCertWithCreate(cfg *Config) (tls.Certificate, *x509.Certificate,
certBytes, keyBytes, err := cert.GenCertPair(
defaultSelfSignedOrganization, cfg.TLSExtraIPs,
cfg.TLSExtraDomains, cfg.TLSDisableAutofill,
cfg.TLSCertDuration,
DefaultAutogenValidity,
)
if err != nil {
return tls.Certificate{}, nil, err
@ -466,99 +383,3 @@ func loadCertWithCreate(cfg *Config) (tls.Certificate, *x509.Certificate,
return cert.LoadCert(cfg.TLSCertPath, cfg.TLSKeyPath)
}
// stores holds a collection of the DB stores that are used by faraday.
type stores struct {
// ChanEventsStore is used to watch for channel events.
ChanEventsStore *chanevents.Store
// closeFns holds various callbacks that can be used to close any open
// stores in the stores struct.
closeFns map[string]func() error
}
// NewStores creates a new stores instance based on the chosen database backend.
func NewStores(cfg Config, clock clock.Clock) (*stores, error) {
var (
stores = &stores{
closeFns: make(map[string]func() error),
}
)
switch cfg.DatabaseBackend {
case DatabaseBackendSqlite:
dbPath := filepath.Join(
cfg.FaradayDir, cfg.Sqlite.DatabaseFileName,
)
sqlStore, err := sqldb.NewSqliteStore(&sqldb.SqliteConfig{
SkipMigrations: cfg.Sqlite.SkipMigrations,
SkipMigrationDbBackup: cfg.Sqlite.SkipMigrationDbBackup,
}, dbPath)
if err != nil {
return stores, err
}
if !cfg.Sqlite.SkipMigrations {
err = sqldb.ApplyAllMigrations(
sqlStore, db.FaradayMigrationSets,
)
if err != nil {
return stores, fmt.Errorf("error applying "+
"migrations to SQLite store: %w", err,
)
}
}
queries := sqlc.NewForType(sqlStore, sqlStore.BackendType)
stores.ChanEventsStore = chanevents.NewStore(
sqlStore.BaseDB, queries, clock,
)
stores.closeFns["sqlite"] = sqlStore.Close
case DatabaseBackendPostgres:
sqlStore, err := sqldb.NewPostgresStore(cfg.Postgres)
if err != nil {
return stores, err
}
if !cfg.Postgres.SkipMigrations {
err = sqldb.ApplyAllMigrations(
sqlStore, db.FaradayMigrationSets,
)
if err != nil {
return stores, fmt.Errorf("error applying "+
"migrations to Postgres store: %w", err,
)
}
}
queries := sqlc.NewForType(sqlStore, sqlStore.BackendType)
stores.ChanEventsStore = chanevents.NewStore(
sqlStore.BaseDB, queries, clock,
)
stores.closeFns["postgres"] = sqlStore.Close
default:
return nil, fmt.Errorf("unsupported database backend: "+
"%s", cfg.DatabaseBackend)
}
return stores, nil
}
// Close closes all the stores.
func (s *stores) Close() error {
for name, closeFn := range s.closeFns {
err := closeFn()
if err != nil {
return fmt.Errorf("error closing %s store: %v", name, err)
}
}
return nil
}

View file

@ -1,7 +1,7 @@
package dataset
import (
"github.com/btcsuite/btclog/v2"
"github.com/btcsuite/btclog"
"github.com/lightningnetwork/lnd/build"
)

View file

@ -1,22 +0,0 @@
package db
// QueriesTxOptions defines the set of db txn options the SQLQueries
// understands.
type QueriesTxOptions struct {
// readOnly governs if a read only transaction is needed or not.
readOnly bool
}
// ReadOnly returns true if the transaction should be read only.
//
// NOTE: This implements the TxOptions.
func (a *QueriesTxOptions) ReadOnly() bool {
return a.readOnly
}
// NewQueryReadTx creates a new read transaction option set.
func NewQueryReadTx() QueriesTxOptions {
return QueriesTxOptions{
readOnly: true,
}
}

View file

@ -1,10 +0,0 @@
package db
const (
// LatestMigrationVersion is the latest migration version of the
// database. This is used to implement downgrade protection for the
// daemon.
//
// NOTE: This MUST be updated when a new migration is added.
LatestMigrationVersion = 2
)

View file

@ -1,41 +0,0 @@
package db
import (
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// TestLatestMigrationVersion ensures that LatestMigrationVersion stays in sync
// with the highest-numbered .up.sql file in the migrations directory. Each
// migration — whether pure SQL or programmatic (with a dummy SQL file) — gets
// its own numbered file pair, so the max file number must equal the constant.
func TestLatestMigrationVersion(t *testing.T) {
entries, err := sqlSchemas.ReadDir("sqlc/migrations")
require.NoError(t, err)
var maxVersion uint
for _, entry := range entries {
if !strings.HasSuffix(entry.Name(), ".up.sql") {
continue
}
parts := strings.SplitN(entry.Name(), "_", 2)
require.NotEmpty(t, parts)
v, err := strconv.ParseUint(parts[0], 10, 64)
require.NoError(t, err)
if uint(v) > maxVersion {
maxVersion = uint(v)
}
}
require.EqualValues(
t, maxVersion, LatestMigrationVersion,
"LatestMigrationVersion is out of date, update "+
"db/migrations.go",
)
}

View file

@ -1,25 +0,0 @@
package db
import (
"testing"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/lightningnetwork/lnd/sqldb/v2"
)
// NewTestPostgresDB is a helper function that creates a Postgres database for
// testing.
func NewTestPostgresDB(t testing.TB) *sqldb.PostgresStore {
t.Helper()
t.Logf("Creating new Postgres DB for testing")
sqlFixture := sqldb.NewTestPgFixture(
t, sqldb.DefaultPostgresFixtureLifetime,
)
t.Cleanup(func() {
sqlFixture.TearDown(t)
})
return sqldb.NewTestPostgresDB(t, sqlFixture, FaradayMigrationSets)
}

View file

@ -1,9 +0,0 @@
package db
import (
"embed"
_ "embed"
)
//go:embed sqlc/migrations/*.*.sql
var sqlSchemas embed.FS

View file

@ -1,30 +0,0 @@
package db
import (
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/pgx/v5"
"github.com/lightningnetwork/lnd/sqldb/v2"
)
var (
FaradayMigrationSet = sqldb.MigrationSet{
TrackingTableName: pgx.DefaultMigrationsTable,
SQLFileDirectory: "sqlc/migrations",
SQLFiles: sqlSchemas,
// LatestMigrationVersion is the latest migration version of the
// database. This is used to implement downgrade protection for
// the daemon.
//
// NOTE: This MUST be updated when a new migration is added.
LatestMigrationVersion: LatestMigrationVersion,
MakeProgrammaticMigrations: func(
db *sqldb.BaseDB,
) (map[uint]migrate.ProgrammaticMigrEntry, error) {
return make(map[uint]migrate.ProgrammaticMigrEntry), nil
},
}
FaradayMigrationSets = []sqldb.MigrationSet{FaradayMigrationSet}
)

View file

@ -1,267 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.25.0
// source: chanevents.sql
package sqlc
import (
"context"
"database/sql"
"time"
)
const getChannelByChanPoint = `-- name: GetChannelByChanPoint :one
SELECT id, channel_point, short_channel_id, peer_id FROM channels WHERE channel_point = $1
`
func (q *Queries) GetChannelByChanPoint(ctx context.Context, channelPoint string) (Channel, error) {
row := q.db.QueryRowContext(ctx, getChannelByChanPoint, channelPoint)
var i Channel
err := row.Scan(
&i.ID,
&i.ChannelPoint,
&i.ShortChannelID,
&i.PeerID,
)
return i, err
}
const getChannelByShortChanID = `-- name: GetChannelByShortChanID :one
SELECT id, channel_point, short_channel_id, peer_id FROM channels WHERE short_channel_id = $1
`
func (q *Queries) GetChannelByShortChanID(ctx context.Context, shortChannelID int64) (Channel, error) {
row := q.db.QueryRowContext(ctx, getChannelByShortChanID, shortChannelID)
var i Channel
err := row.Scan(
&i.ID,
&i.ChannelPoint,
&i.ShortChannelID,
&i.PeerID,
)
return i, err
}
const getChannelEvents = `-- name: GetChannelEvents :many
SELECT id, channel_id, event_type, timestamp, local_balance_sat, remote_balance_sat, is_sync FROM channel_events
WHERE channel_id = $1
AND id > $2
AND timestamp >= $3
AND timestamp < $4
ORDER BY id ASC
LIMIT $5
`
type GetChannelEventsParams struct {
ChannelID int64
ID int64
Timestamp time.Time
Timestamp_2 time.Time
Limit int32
}
func (q *Queries) GetChannelEvents(ctx context.Context, arg GetChannelEventsParams) ([]ChannelEvent, error) {
rows, err := q.db.QueryContext(ctx, getChannelEvents,
arg.ChannelID,
arg.ID,
arg.Timestamp,
arg.Timestamp_2,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ChannelEvent
for rows.Next() {
var i ChannelEvent
if err := rows.Scan(
&i.ID,
&i.ChannelID,
&i.EventType,
&i.Timestamp,
&i.LocalBalanceSat,
&i.RemoteBalanceSat,
&i.IsSync,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getChannels = `-- name: GetChannels :many
SELECT c.id, c.short_channel_id, p.pubkey
FROM channels c
JOIN peers p ON c.peer_id = p.id
`
type GetChannelsRow struct {
ID int64
ShortChannelID int64
Pubkey string
}
func (q *Queries) GetChannels(ctx context.Context) ([]GetChannelsRow, error) {
rows, err := q.db.QueryContext(ctx, getChannels)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetChannelsRow
for rows.Next() {
var i GetChannelsRow
if err := rows.Scan(&i.ID, &i.ShortChannelID, &i.Pubkey); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getLatestChannelEventBefore = `-- name: GetLatestChannelEventBefore :one
SELECT id, channel_id, event_type, timestamp, local_balance_sat, remote_balance_sat, is_sync FROM channel_events
WHERE channel_id = $1 AND event_type = $2 AND timestamp < $3
ORDER BY timestamp DESC, id DESC
LIMIT 1
`
type GetLatestChannelEventBeforeParams struct {
ChannelID int64
EventType int16
Timestamp time.Time
}
func (q *Queries) GetLatestChannelEventBefore(ctx context.Context, arg GetLatestChannelEventBeforeParams) (ChannelEvent, error) {
row := q.db.QueryRowContext(ctx, getLatestChannelEventBefore, arg.ChannelID, arg.EventType, arg.Timestamp)
var i ChannelEvent
err := row.Scan(
&i.ID,
&i.ChannelID,
&i.EventType,
&i.Timestamp,
&i.LocalBalanceSat,
&i.RemoteBalanceSat,
&i.IsSync,
)
return i, err
}
const getPeerByPubKey = `-- name: GetPeerByPubKey :one
SELECT id, pubkey FROM peers WHERE pubkey = $1
`
func (q *Queries) GetPeerByPubKey(ctx context.Context, pubkey string) (Peer, error) {
row := q.db.QueryRowContext(ctx, getPeerByPubKey, pubkey)
var i Peer
err := row.Scan(&i.ID, &i.Pubkey)
return i, err
}
const insertChannel = `-- name: InsertChannel :one
INSERT INTO channels (channel_point, short_channel_id, peer_id) VALUES ($1, $2, $3) RETURNING id
`
type InsertChannelParams struct {
ChannelPoint string
ShortChannelID int64
PeerID int64
}
func (q *Queries) InsertChannel(ctx context.Context, arg InsertChannelParams) (int64, error) {
row := q.db.QueryRowContext(ctx, insertChannel, arg.ChannelPoint, arg.ShortChannelID, arg.PeerID)
var id int64
err := row.Scan(&id)
return id, err
}
const insertChannelEvent = `-- name: InsertChannelEvent :exec
INSERT INTO channel_events (
channel_id, event_type, timestamp, local_balance_sat, remote_balance_sat,
is_sync
) VALUES ($1, $2, $3, $4, $5, $6)
`
type InsertChannelEventParams struct {
ChannelID int64
EventType int16
Timestamp time.Time
LocalBalanceSat sql.NullInt64
RemoteBalanceSat sql.NullInt64
IsSync bool
}
func (q *Queries) InsertChannelEvent(ctx context.Context, arg InsertChannelEventParams) error {
_, err := q.db.ExecContext(ctx, insertChannelEvent,
arg.ChannelID,
arg.EventType,
arg.Timestamp,
arg.LocalBalanceSat,
arg.RemoteBalanceSat,
arg.IsSync,
)
return err
}
const insertPeer = `-- name: InsertPeer :one
INSERT INTO peers (pubkey) VALUES ($1) RETURNING id
`
func (q *Queries) InsertPeer(ctx context.Context, pubkey string) (int64, error) {
row := q.db.QueryRowContext(ctx, insertPeer, pubkey)
var id int64
err := row.Scan(&id)
return id, err
}
const pruneChannelEventsByAge = `-- name: PruneChannelEventsByAge :execrows
DELETE FROM channel_events
WHERE channel_events.timestamp < $1
`
// PruneChannelEventsByAge enforces the retention window on the channel_events
// table, returning the number of rows deleted. It deletes any row whose
// timestamp predates the given cutoff.
func (q *Queries) PruneChannelEventsByAge(ctx context.Context, timestamp time.Time) (int64, error) {
result, err := q.db.ExecContext(ctx, pruneChannelEventsByAge, timestamp)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const pruneChannelEventsBySize = `-- name: PruneChannelEventsBySize :execrows
DELETE FROM channel_events
WHERE channel_events.id < COALESCE((
SELECT id FROM channel_events
ORDER BY id DESC
LIMIT 1 OFFSET $1
), 0)
`
// PruneChannelEventsBySize enforces the size ceiling on the channel_events
// table, returning the number of rows deleted. It keeps the newest rows by
// deleting everything with a smaller (earlier-inserted) id than the id found at
// the given offset from the newest row, so an offset of (max-events - 1) keeps
// exactly max-events rows.
func (q *Queries) PruneChannelEventsBySize(ctx context.Context, offset int32) (int64, error) {
result, err := q.db.ExecContext(ctx, pruneChannelEventsBySize, offset)
if err != nil {
return 0, err
}
return result.RowsAffected()
}

View file

@ -1,31 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.25.0
package sqlc
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}

View file

@ -1,34 +0,0 @@
// Package sqlc provides a set of custom database queries and utilities
// for interacting with the SQL database used in the application. It includes
// generated code from sqlc as well as custom wrappers to handle different
// database backends.
package sqlc
import (
"github.com/lightningnetwork/lnd/sqldb/v2"
)
// wrappedTX is a wrapper around a DBTX that also stores the database backend
// type.
type wrappedTX struct {
DBTX
backendType sqldb.BackendType
}
// Backend returns the type of database backend we're using.
func (q *Queries) Backend() sqldb.BackendType {
wtx, ok := q.db.(*wrappedTX)
if !ok {
// Shouldn't happen unless a new database backend type is added
// but not initialized correctly.
return sqldb.BackendTypeUnknown
}
return wtx.backendType
}
// NewForType creates a new Queries instance for the given database type.
func NewForType(db DBTX, typ sqldb.BackendType) *Queries {
return &Queries{db: &wrappedTX{db, typ}}
}

View file

@ -1,6 +0,0 @@
DROP INDEX IF EXISTS channel_events_chan_id_id_idx;
DROP INDEX IF EXISTS channel_events_chan_id_ts_idx;
DROP TABLE IF EXISTS channel_events;
DROP INDEX IF EXISTS channel_peer_idx;
DROP TABLE IF EXISTS channels;
DROP TABLE IF EXISTS peers;

View file

@ -1,53 +0,0 @@
-- The peers table stores all the peers that we have channels with.
CREATE TABLE IF NOT EXISTS peers (
-- The auto incrementing primary key.
id INTEGER PRIMARY KEY,
-- The public key of the peer.
pubkey TEXT NOT NULL UNIQUE
);
-- The channels table stores all the channels that we have with our peers.
CREATE TABLE IF NOT EXISTS channels (
-- The auto incrementing primary key.
id INTEGER PRIMARY KEY,
-- The channel point, as a 'txid:output_index' string.
channel_point TEXT NOT NULL UNIQUE,
-- The short channel ID.
short_channel_id BIGINT NOT NULL UNIQUE,
-- The peer that this channel is with.
peer_id BIGINT NOT NULL REFERENCES peers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS channel_peer_idx ON channels (peer_id);
-- The channel_events table stores all the events that are associated with a
-- particular channel.
CREATE TABLE IF NOT EXISTS channel_events (
-- The auto incrementing primary key.
id INTEGER PRIMARY KEY,
-- The channel that this event is associated with.
channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
-- The type of event.
event_type SMALLINT NOT NULL,
-- The time the event occurred.
timestamp TIMESTAMP NOT NULL,
-- The local balance of the channel at the time of the event.
-- This is only populated for balance update events.
local_balance_sat BIGINT CHECK (local_balance_sat >= 0),
-- The remote balance of the channel at the time of the event.
-- This is only populated for balance update events.
remote_balance_sat BIGINT CHECK (remote_balance_sat >= 0),
-- Whether this event was recorded during an initial sync rather than
-- from a live subscription.
is_sync BOOLEAN NOT NULL DEFAULT FALSE
);
-- This composite index supports the chronological access patterns
-- (GetChannelEventsIter, GetLatestChannelEventBefore): events for a given
-- channel sorted by time, with a per-channel time-range scan.
CREATE INDEX IF NOT EXISTS channel_events_chan_id_ts_idx ON channel_events (channel_id, timestamp);
-- This composite index supports the public GetChannelEvents query, which
-- walks events for a given channel by id-keyset cursor (ORDER BY id ASC,
-- WHERE id > $cursor). Without it, the planner would scan every event with
-- id > $cursor across all channels and filter by channel_id afterwards.
CREATE INDEX IF NOT EXISTS channel_events_chan_id_id_idx ON channel_events (channel_id, id);

View file

@ -1 +0,0 @@
DROP INDEX IF EXISTS channel_events_ts_idx;

View file

@ -1,6 +0,0 @@
-- This standalone timestamp index supports the global age-based prune in
-- PruneChannelEvents, which deletes across all channels by timestamp with no
-- channel_id predicate. The composite (channel_id, timestamp) index cannot
-- serve that query because its leading column is channel_id, so without this
-- index every retention prune would scan the full channel_events table.
CREATE INDEX IF NOT EXISTS channel_events_ts_idx ON channel_events (timestamp);

View file

@ -1,32 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.25.0
package sqlc
import (
"database/sql"
"time"
)
type Channel struct {
ID int64
ChannelPoint string
ShortChannelID int64
PeerID int64
}
type ChannelEvent struct {
ID int64
ChannelID int64
EventType int16
Timestamp time.Time
LocalBalanceSat sql.NullInt64
RemoteBalanceSat sql.NullInt64
IsSync bool
}
type Peer struct {
ID int64
Pubkey string
}

View file

@ -1,34 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.25.0
package sqlc
import (
"context"
"time"
)
type Querier interface {
GetChannelByChanPoint(ctx context.Context, channelPoint string) (Channel, error)
GetChannelByShortChanID(ctx context.Context, shortChannelID int64) (Channel, error)
GetChannelEvents(ctx context.Context, arg GetChannelEventsParams) ([]ChannelEvent, error)
GetChannels(ctx context.Context) ([]GetChannelsRow, error)
GetLatestChannelEventBefore(ctx context.Context, arg GetLatestChannelEventBeforeParams) (ChannelEvent, error)
GetPeerByPubKey(ctx context.Context, pubkey string) (Peer, error)
InsertChannel(ctx context.Context, arg InsertChannelParams) (int64, error)
InsertChannelEvent(ctx context.Context, arg InsertChannelEventParams) error
InsertPeer(ctx context.Context, pubkey string) (int64, error)
// PruneChannelEventsByAge enforces the retention window on the channel_events
// table, returning the number of rows deleted. It deletes any row whose
// timestamp predates the given cutoff.
PruneChannelEventsByAge(ctx context.Context, timestamp time.Time) (int64, error)
// PruneChannelEventsBySize enforces the size ceiling on the channel_events
// table, returning the number of rows deleted. It keeps the newest rows by
// deleting everything with a smaller (earlier-inserted) id than the id found at
// the given offset from the newest row, so an offset of (max-events - 1) keeps
// exactly max-events rows.
PruneChannelEventsBySize(ctx context.Context, offset int32) (int64, error)
}
var _ Querier = (*Queries)(nil)

View file

@ -1,60 +0,0 @@
-- name: InsertPeer :one
INSERT INTO peers (pubkey) VALUES ($1) RETURNING id;
-- name: GetPeerByPubKey :one
SELECT * FROM peers WHERE pubkey = $1;
-- name: InsertChannel :one
INSERT INTO channels (channel_point, short_channel_id, peer_id) VALUES ($1, $2, $3) RETURNING id;
-- name: GetChannelByChanPoint :one
SELECT * FROM channels WHERE channel_point = $1;
-- name: GetChannelByShortChanID :one
SELECT * FROM channels WHERE short_channel_id = $1;
-- name: InsertChannelEvent :exec
INSERT INTO channel_events (
channel_id, event_type, timestamp, local_balance_sat, remote_balance_sat,
is_sync
) VALUES ($1, $2, $3, $4, $5, $6);
-- name: GetChannelEvents :many
SELECT * FROM channel_events
WHERE channel_id = $1
AND id > $2
AND timestamp >= $3
AND timestamp < $4
ORDER BY id ASC
LIMIT $5;
-- name: GetLatestChannelEventBefore :one
SELECT * FROM channel_events
WHERE channel_id = $1 AND event_type = $2 AND timestamp < $3
ORDER BY timestamp DESC, id DESC
LIMIT 1;
-- name: GetChannels :many
SELECT c.id, c.short_channel_id, p.pubkey
FROM channels c
JOIN peers p ON c.peer_id = p.id;
-- name: PruneChannelEventsBySize :execrows
-- PruneChannelEventsBySize enforces the size ceiling on the channel_events
-- table, returning the number of rows deleted. It keeps the newest rows by
-- deleting everything with a smaller (earlier-inserted) id than the id found at
-- the given offset from the newest row, so an offset of (max-events - 1) keeps
-- exactly max-events rows.
DELETE FROM channel_events
WHERE channel_events.id < COALESCE((
SELECT id FROM channel_events
ORDER BY id DESC
LIMIT 1 OFFSET $1
), 0);
-- name: PruneChannelEventsByAge :execrows
-- PruneChannelEventsByAge enforces the retention window on the channel_events
-- table, returning the number of rows deleted. It deletes any row whose
-- timestamp predates the given cutoff.
DELETE FROM channel_events
WHERE channel_events.timestamp < $1;

View file

@ -1,23 +0,0 @@
package db
import (
_ "modernc.org/sqlite" // Register relevant drivers.
)
// SqliteConfig holds all the config arguments needed to interact with our
// sqlite DB.
//
// nolint: lll
type SqliteConfig struct {
// SkipMigrations if true, then all the tables will be created on start
// up if they don't already exist.
SkipMigrations bool `long:"skipmigrations" description:"Skip applying migrations on startup."`
// SkipMigrationDbBackup if true, then a backup of the database will not
// be created before applying migrations.
SkipMigrationDbBackup bool `long:"skipmigrationdbbackup" description:"Skip creating a backup of the database before applying migrations."`
// DatabaseFileName is the full file path where the database file can be
// found.
DatabaseFileName string `long:"dbfile" description:"The full path to the database."`
}

View file

@ -1,30 +1,19 @@
# Accounting Reports
Faraday produces accounting reports on a node's on chain and off chain activity.
These reports are formatted using the [Harmony Reporting
Standard](https://github.com/harmony-csv/harmony).
These reports are formatted using the [Harmony Reporting Standard](https://github.com/harmony-csv/harmony).
This document provides a description of the entries in these reports.
## Bitcoin Backend
It is strongly recommended that Faraday is run with a connection to a Bitcoin
node when these reports are generated. This is required to lookup fee entries
for channel close transactions and sweep fees. If a connection to a bitcoin node
is not provided, warnings will be logged for the transactions that do not have
fee entries.
It is strongly recommended that Faraday is run with a connection to a Bitcon node when these reports are generated. This is required to lookup fee entries for channel close transactions and sweep fees. If a connection to a bitcoin node is not provided, warnings will be logged for the transactions that do not have fee entries.
## Common Fields
For brevity, the following fields which have the same meaning for each entry
will be omitted:
- Timestamp: The timestamp of the block that the channel open transaction
appeared in.
- Fiat: The value of the amount field in specified currency. Note that values
less than one satoshi will be rounded down to zero.
For brevity, the following fields which have the same meaning for each entry will be omitted:
- Timestamp: The timestamp of the block that the channel open transaction appeared in.
- Fiat: The value of the amount field in specified currency. Note that values less than one satoshi will be rounded down to zero.
- OnChain: Whether the transaction occurred off chain, or on chain.
- Credit: True when an entry increased our balances, false when an entry
decreased our balances.
- Credit: True when an entry increased our balances, false when an entry decreased our balances.
Note that fee entries reference the entry they are associated with by appending
a fee marker (:-1) to the original reference. The fee entry will have a
reference formatted as follows: `original reference:-1`.
Note that fee entries reference the entry they are associated with by appending a fee marker (:-1) to the original reference. The fee entry will have a reference formatted as follows: `original reference:-1`.
## On Chain Reports
@ -33,9 +22,8 @@ Local channel open entry types represent channel opens that were initiated by
our node. These entries are accompanied by a separate Channel Open Fees entry,
because the opening party pays on chain fees.
- Amount: The amount in millisatoshis that we added to the channel, excluding on
chain fees.
- TXID: The on chain transaction ID for the channel open.
- Amount: The amount in millisatoshis that we added to the channel, excluding on chain fees.
- TxID: The on chain transaction ID for the channel open.
- Reference: The unique channel ID assigned to the channel.
- Note: A note with details of who opened the channel.
@ -54,113 +42,82 @@ The fees paid to open a channel that we initiated.
Remote channel open entry types represent channels that were opened by remote
peers.
- Amount: Zero, our balance is unaffected by remote channel creation, except for
a push amount listed below.
- TXID: The on chain transaction ID for the channel open.
- Amount: Zero, our balance is unaffected by remote channel creation, with the exception of a push amount listed below.
- TxID: The on chain transaction ID for the channel open.
- Reference: The unique channel ID assigned to the channel.
- Note: A note containing the pubkey of the peer that opened a channel to us.
Known Omissions:
- Remote peers may push balance to our node as part of the funding flow. This
amount is not currently included in these reports.
- Remote peers may push balance to our node as part of the funding flow. This amount is not currently included in these reports.
### Channel Close
Channel close entries represent the on chain close of a channel.
- Amount: The amount in millisatoshis that was paid out to us immediately on
channel close.
- TXID: The on chain transaction ID for the channel close.
- Amount: The amount in millisatoshis that was paid out to us immediately on channel close.
- TxID: The on chain transaction ID for the channel close.
- Reference: The channel close transaction ID.
- Note: A note indicating the type of channel close, and who initiated it.
Known Omissions:
- If our balance is encumbered behind a timelock, or in an unresolved HTLC, it
will not be paid out as part of this transaction and must be resolved by
follow up on chain transactions.
- If our balance is encumbered behind a timelock, or in an unresolved htlc, it will not be paid out as part of this transaction and must be resolved by follow up on chain transactions.
### Channel Close Fee
Channel close fee entries represent the fees we paid on chain to close channels
that we initiated. Note that this includes the case where we opened the channel
but the remote party closed the channel.
Channel close fee entries represent the fees we paid on chain to close channels that we initiated. Note that this includes the case where we opened the channel but the remote party closed the channel.
- Amount: The amount in millisatoshis that we paid in on chain fees to close the
channel.
- TXID: The on chain transaction ID for the channel close.
- Amount: The amount in millisatoshis that we paid in on chain fees to close the channel.
- TxID: The on chain transaction ID for the channel close.
- Reference: The channel close transaction ID:-1.
- Note: Not set for close fees.
Known Omissions:
- If a channel was closed before we started saving our channel information for
use after close (<lnd 0.9), we will not know which party opened the channel,
so fees may be omitted.
- If a channel was closed before we started saving our channel information for use after close (<lnd 0.9), we will not know which party opened the channel, so fees may be omitted.
### Receipt
A receipt is an on chain transaction which paid to our wallet which was not
related to the opening/closing of channels.
A receipt is an on chain transaction which paid to our wallet which was not related to the opening/closing of channels.
- Amount: The amount in millisatoshis that was paid to an address controlled by
our wallet.
- TXID: The on chain transaction ID.
- Amount: The amount in millisatoshis that was paid to an address controlled by our wallet.
- TxID: The on chain transaction ID.
- Reference: The on chain transaction ID.
- Note: An optional label set on transaction publish (see
[lnd transaction labels][1]).
- Note: An optional label set on transaction publish (see [lnd transaction labels](https://github.com/lightningnetwork/lnd/blob/master/lnrpc/walletrpc/walletkit.proto#L136)).
Known Omissions:
- This entry type will include on chain resolutions for channel closes that
sweep balances back to our node.
- This entry type will include on chain resolutions for channel closes that sweep balances back to our node.
### Payment
A payment is an on chain transaction which was paid from our wallet and was not
related to the opening/closing of channels.
- Amount: The amount in millisatoshis that was paid from an address controlled
by our wallet.
- TXID: The on chain transaction ID.
A payment is an on chain transaction which was paid from our wallet and was not related to the opening/closing of channels.
- Amount: The amount in millisatoshis that was paid from an address controlled by our wallet.
- TxID: The on chain transaction ID.
- Reference: The on chain transaction ID.
- Note: An optional label set on transaction publish (see
[lnd transaction labels][1]).
- Note: An optional label set on transaction publish (see [lnd transaction labels](https://github.com/lightningnetwork/lnd/blob/master/lnrpc/walletrpc/walletkit.proto#L136)).
Known Omissions:
- This entry type will include the on chain resolution of HTLCs when we force
close on our peers and have to settle or fail them on chain.
- The current accounting package does not support accounting for payments with
duplicate payment hashes, which were allowed in previous versions of lnd.
Duplicate payments should be deleted or a time range that does not include
them should be specified.
- Legacy payments that were made in older versions of lnd that were created
without a payment request will not have any information stored about their
destination. We therefore cannot identify whether these are circular payments
(they will be identified as regular payments). A warning will be logged when
we encounter this type of payment.
- This entry type will include the on chain resolution of htlcs when we force close on our peers and have to settle or fail them on chain.
- The current accounting package does not support accounting for payments with duplicate payment hashes, which were allowed in previous versions of lnd. Duplicate payments should be deleted or a time range that does not include them should be specified.
- Legacy payments that were made in older versions of lnd that were created without a payment request will not have any information stored about their destination. We therefore cannot identify whether these are circular payments (they will be identified as regular payments). A warning will be logged when we encounter this type of payment.
### Fee
A fee entry represents the on chain fees we paid for a transaction.
- Amount: The amount in millisatoshis that was paid in fees from our wallet.
- TXID: The on chain transaction ID.
- TxID: The on chain transaction ID.
- Reference: TransactionID:-1.
- Note: Note set for fees.
### Sweep
A sweep is an on chain transaction which is used to sweep our own funds back to
our wallet. This is required when a channel is force closed and need to sweep
time locked commitment outputs, HTLCs or both.
A sweep is an on chain transaction which is used to sweep our own funds back to our wallet. This is required when a channel is force closed and need to sweep time locked commitment outputs, htlcs or both.
- TXID: The on chain transaction ID.
- TxID: The on chain transaction ID.
- Reference: The on chain transaction ID.
- Note: An optional label set on transaction publish (see
[lnd transaction labels][1]).
- Note: An optional label set on transaction publish (see [lnd transaction labels](https://github.com/lightningnetwork/lnd/blob/master/lnrpc/walletrpc/walletkit.proto#L136)).
Known Omissions:
- Note that this entry type does not include the first stage success/timeout
transactions that are required to resolve HTLCs when we force close on our
peer. We currently do not have the information required to identify these
transactions available, so they are included in payments, see known omissions.
- Note that this entry type does not include the first stage success/timeout transactions that are required to resolve htlcs when we force close on our peer. We currently do not have the information required to identify these transactions available, so they are included in payments, see known omissions.
### Sweep Fee
A fee entry represents the on chain fees we paid for a sweep.
- Amount: The amount in millisatoshis that was paid in fees from our wallet.
- TXID: The on chain transaction ID.
- TxID: The on chain transaction ID.
- Reference: TransactionID:-1.
- Note: Not set for fees.
@ -169,85 +126,68 @@ A fee entry represents the on chain fees we paid for a sweep.
### Receipt
Receipts off chain represent invoices that are paid via the Lightning Network.
- Amount: The amount in millisatoshis that we were paid, note that this may be
greater than the original invoice value.
- TXID: The payment hash of the invoice.
- Amount: The amount in millisatoshis that we were paid, note that this may be greater than the original invoice value.
- TxID: The payment hash of the invoice.
- Reference: The preimage of the invoice.
- Note: Optionally set if the invoice had a memo attached, was overpaid, or was
a keysend.
- Note: Optionally set if the invoice had a memo attached, was overpaid, or was a keysend.
### Circular Receipt
Circular receipts record instances where we have paid one of our own invoices.
- Amount: The amount in millisatoshis that we were paid, note that this may be
greater than the original invoice value.
- TXID: The payment hash of the invoice.
- Amount: The amount in millisatoshis that we were paid, note that this may be greater than the original invoice value.
- TxID: The payment hash of the invoice.
- Reference: The preimage of the invoice.
- Note: Optionally set if the invoice had a memo attached, was overpaid, or was
a keysend.
- Note: Optionally set if the invoice had a memo attached, was overpaid, or was a keysend.
### Payment
Payments off chain represent payments made via the Lightning Network.
- Amount: The amount in millisatoshis that we paid, excluding the off chain fees
paid.
- TXID: The payment hash.
- Amount: The amount in millisatoshis that we paid, excluding the off chain fees paid.
- TxID: The payment hash.
- Reference: Unique payment ID: Payment Preimage.
- Note: The node pubkey that the payment was made to.
### Fee
- Amount: The amount in millisatoshis that was paid in off chain fees.
- TXID: The payment hash.
- TxID: The payment hash.
- Reference: Unique payment ID: Payment Preimage: -1.
- Note: The node pubkey that the payment was made to.
### Circular Payment
Circular payments represent payments made to our own node to re-balance
channels. These payments are paid from our node to one of our own invoices.
Circular payments represent payments made to our own node to rebalance channels. These payments are paid from our node to one of our own invoices.
- Amount: The amount that was rebalanced.
- TXID: The payment hash.
- TxID: The payment hash.
- Reference: Unique payment ID: Payment Preimage.
- Note: The node pubkey that the payment was made to.
### Circular Payment Fee
Circular payment fees represent the fees we paid to loop a circular payment to
ourselves.
Circular payment fees represent the fees we paid to loop a circular payment to ourselves.
- Amount: The amount that was paid in off chain fees.
- TXID: The payment hash.
- TxID: The payment hash.
- Reference: Unique payment ID: Payment Preimage: -1.
- Note: The node pubkey that the payment was made to.
### Forwards
A forward represents a payment that arrives at our node on an incoming channel
and is forwarded out on an outgoing channel in exchange for fees. The forward
itself does not changes our balance, since it just shifts funds over our
channels. We include forwarding entries with zero balances for completeness.
Forwarding fee entries reflect the increase in our holdings from the fee we are
paid.
A forward represents a payment that arrives at our node on an incoming channel and is forwarded out on an outgoing channel in exchange for fees. The forward itself does not changes our balance, since it just shifts funds over our channels. We include forwarding entries with zero balances for completeness. Forwarding fee entries reflect the increase in our holdings from the fee we are paid.
- Amount: Zero, forwards do not change our balance except for fees, which are
separated out.
- TXID: Timestamp: Incoming Channel ID: Outgoing Channel ID.
- Amount: Zero, forwards do not change our balance except for fees, which are separated out.
- TxID: Timestamp: Incoming Channel ID: Outgoing Channel ID.
- Reference: Not set for forwards.
- Note: The amounts that were forwarded in and out of our node.
Known Omissions:
- We use incoming and outgoing channel ID paired with timestamp as a best-effort
version of a TXID. Note that this is not strictly unique for a single HTLC, it
is theoretically possible for two HTLCs to pass through the same channel with
the same timestamp.
- We use incoming and outgoing channel ID paired with timestamp as a best-effort version of a txid. Note that this is not strictly unique for a single htlc, it is theoretically possible for two htlcs to pass through the same channel with the same timestamp.
### Forward Fee
Forward fee entries represent the fees we earned from forwarding payments.
- Amount: The amount in millisatoshis of fees we earned from the forward.
- TXID: Timestamp: Incoming Channel ID: Outgoing Channel ID.
- TxID: Timestamp: Incoming Channel ID: Outgoing Channel ID.
- Reference: Not set for forwards.
- Note: Not set for forwards.
Known Omissions:
- See the note on TXIDs in the Forwards section.
- See the note on txids in the Forwards section.
[1]: https://github.com/lightningnetwork/lnd/blob/master/lnrpc/walletrpc/walletkit.proto#L136

View file

@ -2,69 +2,18 @@
package faraday
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
proxy "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/jessevdk/go-flags"
"github.com/lightninglabs/faraday/chain"
"github.com/lightninglabs/faraday/chanevents"
"github.com/lightninglabs/faraday/frdrpc"
"github.com/lightninglabs/faraday/frdrpcserver"
"github.com/lightninglabs/faraday/frdrpcserver/perms"
"github.com/lightninglabs/lndclient"
"github.com/lightningnetwork/lnd/build"
"github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/kvdb"
"github.com/lightningnetwork/lnd/lncfg"
"github.com/lightningnetwork/lnd/lnrpc/verrpc"
"github.com/lightningnetwork/lnd/macaroons"
"github.com/lightningnetwork/lnd/signal"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/protobuf/encoding/protojson"
"gopkg.in/macaroon-bakery.v2/bakery"
)
var (
// customMarshalerOption is the configuration we use for the JSON
// marshaler of the REST proxy. The default JSON marshaler only sets
// OrigName to true, which instructs it to use the same field names as
// specified in the proto file and not switch to camel case. What we
// also want is that the marshaler prints all values, even if they are
// falsey.
customMarshalerOption = proxy.WithMarshalerOption(
proxy.MIMEWildcard, &proxy.JSONPb{
MarshalOptions: protojson.MarshalOptions{
UseProtoNames: true,
EmitUnpopulated: true,
},
},
)
// maxMsgRecvSize is the largest message our REST proxy will receive. We
// set this to 600MiB atm.
maxMsgRecvSize = grpc.MaxCallRecvMsgSize(600 * 1024 * 1024)
// errServerAlreadyStarted is the error that is returned if the server
// is requested to start while it's already been started.
errServerAlreadyStarted = fmt.Errorf("server can only be started once")
// errServerStopped is the error that is returned if the server is
// requested to start after it has been stopped. The Faraday struct is
// not reusable after Stop.
errServerStopped = fmt.Errorf("server has been stopped and cannot " +
"be restarted")
)
// MinLndVersion is the minimum lnd version required. Note that apis that are
@ -77,544 +26,6 @@ var MinLndVersion = &verrpc.Version{
AppPatch: 4,
}
// Faraday is a struct that houses the faraday daemon and its dependencies.
type Faraday struct {
*frdrpcserver.RPCServer
// cfg is the faraday config.
cfg *Config
// started is used to ensure we only start/stop the faraday once.
started atomic.Bool
// stopped is set once Stop completes or Start fails. It prevents
// reuse of the struct, since internal fields are not reset.
stopped atomic.Bool
// monitor is the channel events monitor.
monitor *chanevents.Monitor
// stores contains all the stores used by faraday.
stores *stores
// ctxCancel is a function that can be used to cancel the main context.
ctxCancel context.CancelFunc
lnd *lndclient.GrpcLndServices
// lndOwned indicates whether Faraday created the lnd connection
// itself (standalone mode via Start). When true, Stop will close
// the connection. When false (subserver mode via StartAsSubserver),
// the parent process manages the lnd lifecycle.
lndOwned bool
// bitcoinClient is set if the client opted to connect to a bitcoin
// backend, if not, it will be nil.
bitcoinClient chain.BitcoinClient
macaroonService *lndclient.MacaroonService
macaroonDB kvdb.Backend
// grpcServer is the main gRPC server that this service will register
// itself with and accept client requests from.
grpcServer *grpc.Server
// rpcListener is the listener to use when starting the gRPC server.
rpcListener net.Listener
// restServer is the REST proxy server.
restServer *http.Server
restCancel func()
wg sync.WaitGroup
}
// New creates a new Faraday instance with the given configuration.
func New(cfg *Config) *Faraday {
return &Faraday{cfg: cfg}
}
// Start starts faraday and its dependencies with an RPC server included.
func (f *Faraday) Start() error {
if f.stopped.Load() {
return errServerStopped
}
if !f.started.CompareAndSwap(false, true) {
return errServerAlreadyStarted
}
log.Infof("Starting Faraday version %s", Version())
// Connect to the full suite of lightning services offered by lnd's
// subservers.
var err error
f.lnd, err = lndclient.NewLndServices(&lndclient.LndServicesConfig{
LndAddress: f.cfg.Lnd.RPCServer,
Network: lndclient.Network(f.cfg.Network),
CustomMacaroonPath: f.cfg.Lnd.MacaroonPath,
TLSPath: f.cfg.Lnd.TLSCertPath,
CheckVersion: MinLndVersion,
RPCTimeout: f.cfg.Lnd.RequestTimeout,
})
if err != nil {
f.stopped.Store(true)
f.started.Store(false)
return fmt.Errorf("cannot connect to lightning services: %v",
err)
}
f.lndOwned = true
// Initialize faraday with its dependencies. If anything from here
// on fails, we need to clean up the lnd connection.
err = f.initialize(true)
if err != nil {
f.lnd.Close()
f.stopped.Store(true)
f.started.Store(false)
return fmt.Errorf("error initializing faraday: %v", err)
}
fwdAnalyzer := chanevents.NewForwardingAnalyzer(
f.stores.ChanEventsStore, f.lnd.LndServices,
)
cfg := &frdrpcserver.Config{
Lnd: f.lnd.LndServices,
ChanEvents: f.stores.ChanEventsStore,
ForwardingAnalyzer: fwdAnalyzer,
BitcoinClient: f.bitcoinClient,
}
// Create the RPC server.
f.RPCServer = frdrpcserver.NewRPCServer(cfg)
err = f.startRPCServer()
if err != nil {
if f.macaroonService != nil {
if e := f.macaroonService.Stop(); e != nil {
log.Errorf("Error stopping macaroon "+
"service: %v", e)
}
if e := f.macaroonDB.Close(); e != nil {
log.Errorf("Error closing macaroon "+
"DB: %v", e)
}
}
f.lnd.Close()
f.stopped.Store(true)
f.started.Store(false)
return fmt.Errorf("error starting RPC server: %v", err)
}
return nil
}
// startRPCServer starts the gRPC and REST RPC servers.
func (f *Faraday) startRPCServer() error {
// Prepare the RPC server.
serverTLSCfg, restClientCreds, err := getTLSConfig(f.cfg)
if err != nil {
return fmt.Errorf("error loading TLS config: %v", err)
}
// Depending on how far we got in initializing the server, we might need
// to clean up certain services that were already started. Keep track of
// them with this map of service name to shutdown function.
shutdownFuncs := make(map[string]func() error)
defer func() {
for serviceName, shutdownFn := range shutdownFuncs {
if err := shutdownFn(); err != nil {
log.Errorf("Error shutting down %s service: %v",
serviceName, err)
}
}
}()
// First we add the security interceptor to our gRPC server options that
// checks the macaroons for validity.
if f.macaroonService == nil {
return fmt.Errorf("macaroon service must be initialized " +
"before starting the RPC server")
}
unaryInterceptor, streamInterceptor, err :=
f.macaroonService.Interceptors()
if err != nil {
return fmt.Errorf("error with macaroon interceptor: %v", err)
}
// Add our TLS configuration and then create our server instance. It's
// important that we let gRPC create the TLS listener and we don't just
// use tls.NewListener(). Otherwise we run into the ALPN error with non-
// golang clients.
tlsCredentials := credentials.NewTLS(serverTLSCfg)
f.grpcServer = grpc.NewServer(
grpc.UnaryInterceptor(unaryInterceptor),
grpc.StreamInterceptor(streamInterceptor),
grpc.Creds(tlsCredentials),
)
// Start the gRPC RPCServer listening for HTTP/2 connections.
log.Info("Starting gRPC listener")
f.rpcListener, err = net.Listen("tcp", f.cfg.RPCListen)
if err != nil {
return fmt.Errorf("gRPC server unable to listen on %v",
f.cfg.RPCListen)
}
shutdownFuncs["gRPC listener"] = f.rpcListener.Close
log.Infof("gRPC server listening on %s", f.rpcListener.Addr())
frdrpc.RegisterFaradayServerServer(f.grpcServer, f)
// We'll also create and start an accompanying proxy to serve clients
// through REST. An empty address indicates REST is disabled.
if f.cfg.RESTListen != "" {
log.Infof("Starting REST proxy listener ")
restListener, err := net.Listen("tcp", f.cfg.RESTListen)
if err != nil {
return fmt.Errorf("REST server unable to listen on "+
"%v: %v", f.cfg.RESTListen, err)
}
restListener = tls.NewListener(
restListener, serverTLSCfg,
)
shutdownFuncs["REST listener"] = restListener.Close
log.Infof("REST server listening on %s", restListener.Addr())
// We'll dial into the local gRPC server so we need to set some
// gRPC dial options and CORS settings.
var restCtx context.Context
restCtx, f.restCancel = context.WithCancel(context.Background())
mux := proxy.NewServeMux(customMarshalerOption)
var restHandler http.Handler = mux
if f.cfg.CORSOrigin != "" {
restHandler = allowCORS(restHandler, f.cfg.CORSOrigin)
}
proxyOpts := []grpc.DialOption{
grpc.WithTransportCredentials(*restClientCreds),
grpc.WithDefaultCallOptions(maxMsgRecvSize),
}
// With TLS enabled by default, we cannot call 0.0.0.0
// internally from the REST proxy as that IP address isn't in
// the cert. We need to rewrite it to the loopback address.
restProxyDest := f.cfg.RPCListen
switch {
case strings.Contains(restProxyDest, "0.0.0.0"):
restProxyDest = strings.Replace(
restProxyDest, "0.0.0.0", "127.0.0.1", 1,
)
case strings.Contains(restProxyDest, "[::]"):
restProxyDest = strings.Replace(
restProxyDest, "[::]", "[::1]", 1,
)
}
err = frdrpc.RegisterFaradayServerHandlerFromEndpoint(
restCtx, mux, restProxyDest, proxyOpts,
)
if err != nil {
return err
}
f.restServer = &http.Server{
Handler: restHandler,
ReadHeaderTimeout: 3 * time.Second,
}
f.wg.Add(1)
go func() {
defer f.wg.Done()
err := f.restServer.Serve(restListener)
// ErrServerClosed is always returned when the proxy is
// shut down, so don't log it.
if err != nil && err != http.ErrServerClosed {
log.Error(err)
}
}()
} else {
log.Infof("REST proxy disabled")
}
f.wg.Add(1)
go func() {
defer f.wg.Done()
if err := f.grpcServer.Serve(f.rpcListener); err != nil {
log.Errorf("could not serve grpc server: %v", err)
}
}()
// If we got here successfully, there's no need to shutdown anything
// anymore.
shutdownFuncs = nil
return nil
}
// stopRPCServer stops the gRPC and REST RPC servers.
func (f *Faraday) stopRPCServer() {
if f.restServer != nil {
f.restCancel()
err := f.restServer.Close()
if err != nil {
log.Errorf("unable to close REST listener: %v", err)
}
}
if f.grpcServer != nil {
f.grpcServer.Stop()
}
}
// StartAsSubserver is an alternative to Start where the RPC server does not
// create its own gRPC server but registers to an existing one. The same goes
// for REST (if enabled), instead of creating an own mux and HTTP server, we
// register to an existing one.
func (f *Faraday) StartAsSubserver(lndGrpc *lndclient.GrpcLndServices,
withMacaroonService bool) error {
log.Infof("Starting Faraday subserver version %s", Version())
// There should be no reason to start the daemon twice. Therefore,
// return an error if that's tried. This is mostly to guard against
// Start and StartAsSubserver both being called.
if f.stopped.Load() {
return errServerStopped
}
if !f.started.CompareAndSwap(false, true) {
return errServerAlreadyStarted
}
// When starting as a subserver, we get passed in an already established
// connection to lnd that might be shared among other subservers.
f.lnd = lndGrpc
// With lnd already pre-connected, initialize everything else, such as
// the RPC server instance. If this fails, then nothing has been
// started yet, and we can just return the error.
err := f.initialize(withMacaroonService)
if err != nil {
f.stopped.Store(true)
f.started.Store(false)
return fmt.Errorf("error initializing faraday: %v", err)
}
fwdAnalyzer := chanevents.NewForwardingAnalyzer(
f.stores.ChanEventsStore, lndGrpc.LndServices,
)
cfg := &frdrpcserver.Config{
Lnd: lndGrpc.LndServices,
ChanEvents: f.stores.ChanEventsStore,
ForwardingAnalyzer: fwdAnalyzer,
BitcoinClient: f.bitcoinClient,
}
// Create the RPC server, but don't start it.
f.RPCServer = frdrpcserver.NewRPCServer(cfg)
return nil
}
// ValidateMacaroon extracts the macaroon from the context's gRPC metadata,
// checks its signature, makes sure all specified permissions for the called
// method are contained within and finally ensures all caveat conditions are
// met. A non-nil error is returned if any of the checks fail. This method is
// needed to enable faraday running as an external subserver in the same process
// as lnd but still validate its own macaroons.
func (f *Faraday) ValidateMacaroon(ctx context.Context,
requiredPermissions []bakery.Op, fullMethod string) error {
if f.macaroonService == nil {
return fmt.Errorf("macaroon service not yet initialised")
}
// Delegate the call to faraday's own macaroon validator service.
return f.macaroonService.ValidateMacaroon(
ctx, requiredPermissions, fullMethod,
)
}
// Stop shuts down Faraday: the RPC servers, macaroon service, and, if Faraday
// owns the lnd connection (standalone mode via Start), the lnd connection as
// well. In subserver mode (started via StartAsSubserver) the lnd connection is
// left open for the parent process to manage.
//
// Calling Stop on an already stopped or never-started instance is a no-op
// and returns nil.
func (f *Faraday) Stop() error {
if !f.started.CompareAndSwap(true, false) {
return nil
}
// Mark as permanently stopped so the struct cannot be reused.
f.stopped.Store(true)
log.Infof("Stopping Faraday")
f.stopRPCServer()
// Wait for the gRPC and REST serve goroutines to exit before
// tearing down the macaroon service, so that in-flight RPCs
// can complete cleanly.
f.wg.Wait()
if f.ctxCancel != nil {
f.ctxCancel()
}
if f.monitor != nil {
if err := f.monitor.Stop(); err != nil {
log.Errorf("Error stopping channel event monitor: %v",
err)
}
}
if f.stores != nil {
if err := f.stores.Close(); err != nil {
log.Errorf("Error closing stores: %v", err)
}
}
var stopErr error
if f.macaroonService != nil {
err := f.macaroonService.Stop()
if err != nil {
log.Errorf("Error stopping macaroon service: %v", err)
stopErr = errors.Join(stopErr, err)
}
if err := f.macaroonDB.Close(); err != nil {
log.Errorf("Error closing macaroon DB: %v", err)
stopErr = errors.Join(stopErr, err)
}
}
// Only close the lnd connection if we created it ourselves
// (standalone mode). In subserver mode, the parent process
// manages the shared lnd connection.
if f.lndOwned && f.lnd != nil {
f.lnd.Close()
}
return stopErr
}
// initialize sets up faraday with its dependencies.
func (f *Faraday) initialize(withMacaroonService bool) error {
var err error
if withMacaroonService {
// Set up the macaroon service.
var rks bakery.RootKeyStore
rks, f.macaroonDB, err = lndclient.NewBoltMacaroonStore(
f.cfg.FaradayDir, lncfg.MacaroonDBName,
macDatabaseOpenTimeout,
)
if err != nil {
return err
}
f.macaroonService, err = lndclient.NewMacaroonService(
&lndclient.MacaroonServiceConfig{
RootKeyStore: rks,
MacaroonLocation: faradayMacaroonLocation,
MacaroonPath: f.cfg.MacaroonPath,
Checkers: []macaroons.Checker{
macaroons.IPLockChecker,
},
RequiredPerms: perms.RequiredPermissions,
DBPassword: macDbDefaultPw,
LndClient: &f.lnd.LndServices,
EphemeralKey: lndclient.SharedKeyNUMS,
KeyLocator: lndclient.SharedKeyLocator,
},
)
if err != nil {
if e := f.macaroonDB.Close(); e != nil {
log.Errorf("Error closing macaroon DB: %v", e)
}
return fmt.Errorf("error creating macaroon "+
"service: %v", err)
}
// Start the macaroon service and let it create its default
// macaroon in case it doesn't exist yet.
if err := f.macaroonService.Start(); err != nil {
if e := f.macaroonDB.Close(); e != nil {
log.Errorf("Error closing macaroon DB: %v", e)
}
return fmt.Errorf("error starting macaroon "+
"service: %v", err)
}
}
// If the client chose to connect to a bitcoin client, get one now.
if f.cfg.ChainConn {
f.bitcoinClient, err = chain.NewBitcoinClient(f.cfg.Bitcoin)
if err != nil {
if f.macaroonService != nil {
if e := f.macaroonService.Stop(); e != nil {
log.Errorf("Error stopping macaroon "+
"service: %v", e)
}
if e := f.macaroonDB.Close(); e != nil {
log.Errorf("Error closing macaroon "+
"DB: %v", e)
}
}
return err
}
}
// Create any relevant stores.
f.stores, err = NewStores(*f.cfg, clock.NewDefaultClock())
if err != nil {
return fmt.Errorf("could not create stores: %v", err)
}
// Create the channel event monitor. ChanEvents may be nil on
// initialization paths that don't go through DefaultConfig (e.g. when
// faraday runs as a subserver), so fall back to a zero-value config
// instead of dereferencing a nil pointer.
var chanEventsCfg chanevents.Config
if f.cfg.ChanEvents != nil {
chanEventsCfg = *f.cfg.ChanEvents
}
f.monitor = chanevents.NewMonitor(
f.lnd.Client, f.stores.ChanEventsStore, chanEventsCfg,
)
ctx, cancel := context.WithCancel(context.Background())
f.ctxCancel = cancel
if err := f.monitor.Start(ctx); err != nil {
cancel()
return fmt.Errorf("could not start channel event "+
"monitor: %v", err)
}
return nil
}
// allowCORS wraps the given http.Handler with a function that adds the
// Access-Control-Allow-Origin header to the response.
func allowCORS(handler http.Handler, origin string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", origin)
handler.ServeHTTP(w, r)
})
}
// Main is the real entry point for faraday. It is required to ensure that
// defers are properly executed when os.Exit() is called.
func Main() error {
@ -642,11 +53,8 @@ func Main() error {
// Setup logging before parsing the config.
logWriter := build.NewRotatingLogWriter()
subLogMgr := build.NewSubLoggerManager(
build.NewDefaultLogHandlers(config.Logging, logWriter)...,
)
SetupLoggers(subLogMgr, shutdownInterceptor)
err = build.ParseAndSetDebugLevels(config.DebugLevel, subLogMgr)
SetupLoggers(logWriter, shutdownInterceptor)
err = build.ParseAndSetDebugLevels(config.DebugLevel, logWriter)
if err != nil {
return err
}
@ -655,10 +63,51 @@ func Main() error {
return fmt.Errorf("error validating config: %v", err)
}
server := New(&config)
err = server.Start()
serverTLSCfg, restClientCreds, err := getTLSConfig(&config)
if err != nil {
return fmt.Errorf("error starting faraday: %w", err)
return fmt.Errorf("error loading TLS config: %v", err)
}
// Connect to the full suite of lightning services offered by lnd's
// subservers.
client, err := lndclient.NewLndServices(&lndclient.LndServicesConfig{
LndAddress: config.Lnd.RPCServer,
Network: lndclient.Network(config.Network),
CustomMacaroonPath: config.Lnd.MacaroonPath,
TLSPath: config.Lnd.TLSCertPath,
CheckVersion: MinLndVersion,
})
if err != nil {
return fmt.Errorf("cannot connect to lightning services: %v",
err)
}
defer client.Close()
// Instantiate the faraday gRPC server.
cfg := &frdrpcserver.Config{
Lnd: client.LndServices,
RPCListen: config.RPCListen,
RESTListen: config.RESTListen,
CORSOrigin: config.CORSOrigin,
TLSServerConfig: serverTLSCfg,
RestClientConfig: restClientCreds,
FaradayDir: config.FaradayDir,
MacaroonPath: config.MacaroonPath,
}
// If the client chose to connect to a bitcoin client, get one now.
if config.ChainConn {
cfg.BitcoinClient, err = chain.NewBitcoinClient(config.Bitcoin)
if err != nil {
return err
}
}
server := frdrpcserver.NewRPCServer(cfg)
// Start the server.
if err := server.Start(); err != nil {
return err
}
// Run until the user terminates.
@ -666,7 +115,7 @@ func Main() error {
log.Infof("Received shutdown signal.")
if err := server.Stop(); err != nil {
return fmt.Errorf("error stopping faraday: %w", err)
return err
}
return nil

View file

@ -1,193 +0,0 @@
package fiat
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/shopspring/decimal"
)
const (
// bitfinexHistoryAPI is the endpoint for historical candle data.
// The URL path encodes the time-frame and trading pair:
// /v2/candles/trade:<timeframe>:<symbol>/hist
bitfinexHistoryAPI = "https://api-pub.bitfinex.com" +
"/v2/candles/trade:%s:%s/hist"
// bitfinexDefaultPair is the trading pair used to obtain BTC/USD
// prices. Trading pair symbols are formed prepending a "t".
bitfinexDefaultPair = "tBTCUSD"
// bitfinexDefaultCurrency is the fiat currency returned.
bitfinexDefaultCurrency = "USD"
// bitfinexCandleCap is the maximum number of candles the API returns
// per request.
bitfinexCandleCap = 10000
)
// bitfinexTimeframe maps a Granularity to the Bitfinex candle key string.
var bitfinexTimeframe = map[Granularity]string{
GranularityHour: "1h",
GranularityDay: "1D",
}
// bitfinexAPI implements the fiatBackend interface using the Bitfinex v2
// public candles endpoint.
type bitfinexAPI struct {
// granularity controls the candle bucket size (hour or day).
granularity Granularity
// pair is the Bitfinex symbol, e.g. "tBTCUSD".
pair string
// client is the HTTP client used to make requests.
client *http.Client
}
// newBitfinexAPI returns a bitfinexAPI that satisfies fiatBackend.
func newBitfinexAPI(g Granularity) *bitfinexAPI {
return &bitfinexAPI{
granularity: g,
pair: bitfinexDefaultPair,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// queryBitfinex performs one HTTP request for a single window of up to
// bitfinexCandleCap candles. Timestamps are in milliseconds. The sort=1
// parameter requests ascending order.
func queryBitfinex(start, end time.Time, pair, timeframe string,
cl *http.Client) ([]byte, error) {
base := fmt.Sprintf(bitfinexHistoryAPI, timeframe, pair)
params := url.Values{}
params.Set("limit", strconv.Itoa(bitfinexCandleCap))
params.Set("start", strconv.FormatInt(start.UnixMilli(), 10))
params.Set("end", strconv.FormatInt(end.UnixMilli(), 10))
params.Set("sort", "1")
// #nosec G107 public data
resp, err := cl.Get(base + "?" + params.Encode())
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
// parseBitfinexData parses the JSON response from the Bitfinex candles
// endpoint.
//
// Bitfinex v2 public candles endpoint
//
// GET https://api-pub.bitfinex.com
// /v2/candles/trade:<timeframe>:<symbol>/hist
//
// Response body -- array of fixed-width arrays (when sort=1, ascending):
//
// [
// [ MTS, OPEN, CLOSE, HIGH, LOW, VOLUME ],
// ...
// ]
//
// Field meanings:
// - MTS -- millisecond timestamp (bucket open).
// - OPEN -- first execution price during the bucket interval.
// - CLOSE -- last execution price during the bucket interval.
// - HIGH -- highest execution price during the bucket interval.
// - LOW -- lowest execution price during the bucket interval.
// - VOLUME -- quantity of base asset traded during the bucket interval.
//
// We use the CLOSE price (index 2) to be consistent with the other
// backends.
func parseBitfinexData(data []byte) ([]*Price, error) {
var raw [][]float64
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
prices := make([]*Price, 0, len(raw))
for _, c := range raw {
if len(c) < 3 {
continue
}
ts := time.UnixMilli(int64(c[0])).UTC()
closePx := decimal.NewFromFloat(c[2])
prices = append(prices, &Price{
Timestamp: ts,
Price: closePx,
Currency: bitfinexDefaultCurrency,
})
}
return prices, nil
}
// rawPriceData satisfies the fiatBackend interface.
func (b *bitfinexAPI) rawPriceData(ctx context.Context,
startTime, endTime time.Time) ([]*Price, error) {
tf, ok := bitfinexTimeframe[b.granularity]
if !ok {
return nil, fmt.Errorf("bitfinex: unsupported granularity %v",
b.granularity.label)
}
// Each request returns at most bitfinexCandleCap candles. We page
// forward by advancing start past the last received timestamp.
chunk := b.granularity.aggregation * bitfinexCandleCap
start := startTime.Truncate(b.granularity.aggregation)
end := start.Add(chunk)
if end.After(endTime) {
end = endTime
}
var all []*Price
seen := make(map[int64]struct{})
for start.Before(endTime) {
queryStart, queryEnd := start, end
query := func() ([]byte, error) {
return queryBitfinex(
queryStart, queryEnd, b.pair, tf, b.client,
)
}
records, err := retryQuery(ctx, query, parseBitfinexData)
if err != nil {
return nil, err
}
// Bitfinex candles can include boundary timestamps for both
// start and end. Filter duplicates across page boundaries by
// timestamp.
for _, record := range records {
ts := record.Timestamp.UnixMilli()
if _, ok := seen[ts]; ok {
continue
}
seen[ts] = struct{}{}
all = append(all, record)
}
start = end
end = start.Add(chunk)
if end.After(endTime) {
end = endTime
}
}
return all, nil
}

View file

@ -1,172 +0,0 @@
package fiat
import (
"context"
"fmt"
"net/http"
"strconv"
"testing"
"time"
"github.com/jarcoal/httpmock"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/require"
)
// TestParseBitfinexData tests parsing of the candle array format returned
// by the Bitfinex v2 public candles endpoint.
func TestParseBitfinexData(t *testing.T) {
t.Parallel()
// Bitfinex returns: [MTS, OPEN, CLOSE, HIGH, LOW, VOLUME].
// We use the CLOSE field (index 2).
ts1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
ts2 := time.Date(2024, 1, 1, 1, 0, 0, 0, time.UTC)
input := []byte(`[
[` + fmt.Sprintf("%d", ts1.UnixMilli()) +
`, 42000.0, 42100.5, 42200.0, 41900.0, 12.5],
[` + fmt.Sprintf("%d", ts2.UnixMilli()) +
`, 42100.0, 42300.0, 42400.0, 42050.0, 8.3]
]`)
prices, err := parseBitfinexData(input)
require.NoError(t, err)
expected := []*Price{
{
Timestamp: ts1,
Price: decimal.NewFromFloat(42100.5),
Currency: "USD",
},
{
Timestamp: ts2,
Price: decimal.NewFromFloat(42300.0),
Currency: "USD",
},
}
require.Equal(t, expected, prices)
}
// TestParseBitfinexDataShortRow verifies that rows with fewer than 3
// elements are silently skipped.
func TestParseBitfinexDataShortRow(t *testing.T) {
t.Parallel()
input := []byte(`[[1700000000000, 42000.0], [1700003600000, ` +
`42100.0, 42200.0, 42300.0, 42050.0, 5.0]]`)
prices, err := parseBitfinexData(input)
require.NoError(t, err)
require.Len(t, prices, 1)
require.Equal(t, decimal.NewFromFloat(42200.0), prices[0].Price)
}
// TestBitfinexRawPriceData tests the paging logic of rawPriceData using a
// mocked HTTP transport.
func TestBitfinexRawPriceData(t *testing.T) {
now := time.Now().UTC().Truncate(time.Hour)
start := now.Add(-time.Hour * 4)
mock := httpmock.NewMockTransport()
client := &http.Client{Transport: mock}
const numCandles = 4
candles := make([][]float64, numCandles)
for i := range candles {
ts := start.Add(time.Duration(i) * time.Hour)
candles[i] = []float64{
float64(ts.UnixMilli()),
float64(45000 + i), // open
float64(50000 + i), // close
float64(55000 + i), // high
float64(44000 + i), // low
1.0, // volume
}
}
expected := make([]*Price, numCandles)
for i := range expected {
expected[i] = &Price{
Timestamp: start.Add(time.Hour * time.Duration(i)),
Price: decimal.NewFromFloat(float64(50000 + i)),
Currency: "USD",
}
}
mock.RegisterResponder(
"GET", `=~https://api-pub.bitfinex.com/.*`,
httpmock.NewJsonResponderOrPanic(200, candles),
)
api := newBitfinexAPI(GranularityHour)
api.client = client
ctx := context.Background()
out, err := api.rawPriceData(ctx, start, now)
require.NoError(t, err)
require.EqualValues(t, expected, out)
}
// TestBitfinexRawPriceDataNoDuplicateBoundaries verifies that page boundaries
// do not produce duplicate timestamps when multiple requests are made.
func TestBitfinexRawPriceDataNoDuplicateBoundaries(t *testing.T) {
t.Parallel()
end := time.Now().UTC().Truncate(time.Hour)
start := end.Add(-time.Duration(bitfinexCandleCap+1) * time.Hour)
mock := httpmock.NewMockTransport()
client := &http.Client{Transport: mock}
var calls int
mock.RegisterResponder(
"GET", `=~https://api-pub.bitfinex.com/.*`,
func(req *http.Request) (*http.Response, error) {
calls++
query := req.URL.Query()
startMS, err := strconv.ParseInt(
query.Get("start"), 10, 64,
)
if err != nil {
return nil, err
}
endMS, err := strconv.ParseInt(query.Get("end"), 10, 64)
if err != nil {
return nil, err
}
// Simulate an inclusive API that returns both
// boundaries.
candles := [][]float64{
{
float64(startMS), 0, float64(startMS),
0, 0, 1,
},
{
float64(endMS), 0, float64(endMS),
0, 0, 1,
},
}
return httpmock.NewJsonResponse(200, candles)
},
)
api := newBitfinexAPI(GranularityHour)
api.client = client
out, err := api.rawPriceData(context.Background(), start, end)
require.NoError(t, err)
require.GreaterOrEqual(t, calls, 2, "expected paging to occur")
seen := make(map[int64]struct{}, len(out))
for _, price := range out {
ts := price.Timestamp.UnixMilli()
_, ok := seen[ts]
require.False(t, ok, "duplicate timestamp: %v", price.Timestamp)
seen[ts] = struct{}{}
}
}

View file

@ -1,161 +0,0 @@
package fiat
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/shopspring/decimal"
)
const (
coinbaseHistoryAPI = "https://api.exchange.coinbase.com/products/%s/candles"
coinbaseDefaultPair = "BTC-USD"
coinbaseCandleCap = 300 // max buckets.
coinbaseGranHourSec = 3600 // 1hour buckets.
coinbaseGranDaySec = 86400 // 1day buckets.
coinbaseDefaultCurr = "USD"
)
type coinbaseAPI struct {
// granularity is the price granularity (must be GranularityHour or
// GranularityDay for coinbase).
granularity Granularity
// product is the Coinbase product pair (e.g. BTC-USD).
product string
// client is the HTTP client used to make requests.
client *http.Client
}
// newCoinbaseAPI returns an implementation that satisfies fiatBackend.
func newCoinbaseAPI(g Granularity) *coinbaseAPI {
return &coinbaseAPI{
granularity: g,
product: coinbaseDefaultPair,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// queryCoinbase performs one HTTP request for a single <300bucket window.
func queryCoinbase(start, end time.Time, product string,
g Granularity, cl *http.Client) ([]byte, error) {
url := fmt.Sprintf(coinbaseHistoryAPI, product) +
fmt.Sprintf("?start=%s&end=%s&granularity=%d",
start.Format(time.RFC3339),
end.Format(time.RFC3339),
int(g.aggregation.Seconds()))
// #nosec G107 public data
resp, err := cl.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
// parseCoinbaseData parses the JSON response from Coinbase's candles endpoint.
//
// Coinbase “product candles” endpoint
//
// GET https://api.exchange.coinbase.com/products/<productid>/candles
//
// Response body ─ array of fixedwidth arrays:
//
// [
// [ time, low, high, open, close, volume ],
// ...
// ]
//
// Field meanings (per Coinbase docs [1]):
// - time UNIX epoch **seconds** marking the *start* of the bucket (UTC).
// - low lowest trade price during the bucket interval.
// - high highest trade price during the bucket interval.
// - open price of the first trade in the interval.
// - close price of the last trade in the interval.
// - volume amount of the baseasset traded during the interval.
//
// Additional quirks
// - Candles are returned in *reversechronological* order (newestfirst).
// - `granularity` must be one of 60,300,900,3600,21600,86400 seconds.
// - A single request can return at most 300 buckets; larger spans must be
// paged by adjusting `start`/`end` query parameters.
//
// Example (1hour granularity, newestfirst):
//
// [
// [1714632000, 64950.12, 65080.00, 65010.55, 65075.00, 84.213],
// [1714628400, 64890.00, 65020.23, 64900.00, 64950.12, 92.441],
// ...
// ]
//
// [1] https://docs.cdp.coinbase.com/exchange/reference/exchangerestapi_getproductcandles
func parseCoinbaseData(data []byte) ([]*Price, error) {
var raw [][]float64
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
prices := make([]*Price, 0, len(raw))
for _, c := range raw {
// Historical rate data may be incomplete. No data is published
// for intervals where there are no ticks.
if len(c) < 5 {
continue
}
ts := time.Unix(int64(c[0]), 0).UTC()
closePx := decimal.NewFromFloat(c[4])
prices = append(prices, &Price{
Timestamp: ts,
Price: closePx,
Currency: coinbaseDefaultCurr,
})
}
return prices, nil
}
// rawPriceData satisfies the fiatBackend interface.
func (c *coinbaseAPI) rawPriceData(ctx context.Context,
startTime, endTime time.Time) ([]*Price, error) {
// Coinbase cap = 300 * granularity.
chunk := c.granularity.aggregation * coinbaseCandleCap
start := startTime.Truncate(c.granularity.aggregation)
end := start.Add(chunk)
if end.After(endTime) {
end = endTime
}
var all []*Price
for start.Before(endTime) {
query := func() ([]byte, error) {
return queryCoinbase(
start, end, c.product, c.granularity, c.client,
)
}
records, err := retryQuery(ctx, query, parseCoinbaseData)
if err != nil {
return nil, err
}
all = append(all, records...)
start = end
end = start.Add(chunk)
if end.After(endTime) {
end = endTime
}
}
return all, nil
}

View file

@ -1,7 +1,7 @@
package fiat
import (
"github.com/btcsuite/btclog/v2"
"github.com/btcsuite/btclog"
"github.com/lightningnetwork/lnd/build"
)

View file

@ -105,26 +105,6 @@ func (cfg *PriceSourceConfig) validatePriceSourceConfig() error {
if len(cfg.PricePoints) == 0 {
return errPricePointsRequired
}
case CoinbasePriceBackend:
if cfg.Granularity == nil ||
(*cfg.Granularity != GranularityHour &&
*cfg.Granularity != GranularityDay) {
return fmt.Errorf("%w: coinbase supports hourly or "+
"daily granularity only",
errGranularityUnsupported)
}
case BitfinexPriceBackend:
if cfg.Granularity == nil ||
(*cfg.Granularity != GranularityHour &&
*cfg.Granularity != GranularityDay) {
return fmt.Errorf("%w: bitfinex supports hourly or "+
"daily granularity only",
errGranularityUnsupported)
}
}
return nil
@ -182,12 +162,6 @@ const (
// CoinGeckoPriceBackend uses CoinGecko's API for fiat price data.
CoinGeckoPriceBackend
// CoinbasePriceBackend uses Coinbase's API for fiat price data.
CoinbasePriceBackend
// BitfinexPriceBackend uses Bitfinex's API for fiat price data.
BitfinexPriceBackend
)
var priceBackendNames = map[PriceBackend]string{
@ -196,8 +170,6 @@ var priceBackendNames = map[PriceBackend]string{
CoinDeskPriceBackend: "coindesk",
CustomPriceBackend: "custom",
CoinGeckoPriceBackend: "coingecko",
CoinbasePriceBackend: "coinbase",
BitfinexPriceBackend: "bitfinex",
}
// String returns the string representation of a price backend.
@ -240,16 +212,6 @@ func NewPriceSource(cfg *PriceSourceConfig) (*PriceSource, error) {
return &PriceSource{
impl: &coinGeckoAPI{},
}, nil
case CoinbasePriceBackend:
return &PriceSource{
impl: newCoinbaseAPI(*cfg.Granularity),
}, nil
case BitfinexPriceBackend:
return &PriceSource{
impl: newBitfinexAPI(*cfg.Granularity),
}, nil
}
return nil, errUnknownPriceBackend

View file

@ -1,13 +1,10 @@
package fiat
import (
"context"
"errors"
"net/http"
"testing"
"time"
"github.com/jarcoal/httpmock"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/require"
@ -232,35 +229,6 @@ func TestValidatePriceSourceConfig(t *testing.T) {
},
expectedErr: errGranularityUnsupported,
},
{
name: "bitfinex hourly granularity",
cfg: &PriceSourceConfig{
Backend: BitfinexPriceBackend,
Granularity: &GranularityHour,
},
},
{
name: "bitfinex daily granularity",
cfg: &PriceSourceConfig{
Backend: BitfinexPriceBackend,
Granularity: &GranularityDay,
},
},
{
name: "bitfinex no granularity disallowed",
cfg: &PriceSourceConfig{
Backend: BitfinexPriceBackend,
},
expectedErr: errGranularityUnsupported,
},
{
name: "bitfinex minute granularity disallowed",
cfg: &PriceSourceConfig{
Backend: BitfinexPriceBackend,
Granularity: &GranularityMinute,
},
expectedErr: errGranularityUnsupported,
},
}
for _, test := range tests {
@ -277,57 +245,3 @@ func TestValidatePriceSourceConfig(t *testing.T) {
})
}
}
// TestCoinbaseRawPriceData tests the rawPriceData method of the Coinbase API
// implementation.
func TestCoinbaseRawPriceData(t *testing.T) {
now := time.Now().UTC().Truncate(time.Hour)
start := now.Add(-time.Hour * 4)
// Stub HTTP client with httpmock (same pattern as CoinCap tests).
mock := httpmock.NewMockTransport()
client := &http.Client{Transport: mock}
// JSON response for the Coinbase API.
const numCandles = 4
candles := make([][]float64, numCandles)
for i := range candles {
timestamp := start.Add(time.Duration(i) * time.Hour).Unix()
// Example values; tweak as needed.
low := 45_000 + float64(i)
high := 55_000 + float64(i)
open := 0.0
close := 50_000 + float64(i)
vol := 0.0
candles[i] = []float64{
float64(timestamp), low, high, open, close, vol,
}
}
expected := make([]*Price, numCandles)
for i := range expected {
expected[i] = &Price{
Timestamp: start.Add(time.Hour * time.Duration(i)),
Price: decimal.NewFromFloat(float64(50_000 + i)),
Currency: "USD",
}
}
// Four hourly candles (close = 50000) returned.
mock.RegisterResponder(
"GET", `=~https://api.exchange.coinbase.com/.*`,
httpmock.NewJsonResponderOrPanic(200, candles),
)
api := newCoinbaseAPI(GranularityHour)
api.client = client
ctx := context.Background()
out, err := api.rawPriceData(ctx, start, now)
require.NoError(t, err)
require.EqualValues(t, expected, out)
}

View file

@ -1,9 +1,9 @@
FROM golang:1.25.10-bookworm
FROM golang:1.19.4-buster
RUN apt-get update && apt-get install -y \
git \
protobuf-compiler='3.21.12*' \
clang-format='1:14.0*'
protobuf-compiler='3.6.1*' \
clang-format='1:7.0*'
# We don't want any default values for these variables to make sure they're
# explicitly provided by parsing the go.mod file. Otherwise we might forget to

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -65,17 +65,6 @@ service FaradayServer {
http://localhost:8466/v1/faraday/closereport
*/
rpc CloseReport (CloseReportRequest) returns (CloseReportResponse);
/**
Get a list of channel events that occurred for a given channel.
*/
rpc GetChannelEvents (ChannelEventsRequest) returns (ChannelEventsResponse);
/**
Get forwarding ability analysis of peer pairs.
*/
rpc ForwardingAbility (ForwardingAbilityRequest)
returns (ForwardingAbilityResponse);
}
message CloseRecommendationRequest {
@ -354,11 +343,6 @@ enum FiatBackend {
// This API is reached through the following URL:
// https://api.coingecko.com/api/v3/coins/bitcoin/market_chart
COINGECKO = 4;
// Use the Bitfinex API for fiat price information.
// This API is reached through the following URL:
// https://api-pub.bitfinex.com/v2/candles/trade:1h:tBTCUSD/hist
BITFINEX = 5;
}
message ExchangeRateRequest {
@ -607,145 +591,3 @@ message CloseReportResponse {
*/
string close_fee = 6;
}
message ChannelEventsRequest {
/*
The channel point of the channel to get events for, formatted txid:outpoint.
*/
string chan_point = 1;
/*
Lower time bound, inclusive, as Unix seconds. Independent filter does
not need to advance between paginated calls. Zero means no lower bound.
*/
int64 start_time = 2;
/*
Upper time bound, exclusive, as Unix seconds. Must be greater than or
equal to start_time. Zero means "use the server's current time".
*/
int64 end_time = 3;
/*
The maximum number of events to return. If zero, the server default is
used. The server enforces a hard cap; values above the cap are clamped.
*/
uint32 max_events = 4;
/*
Pagination cursor: id of the last event from the previous response.
Pass zero on the first call; for subsequent calls pass the response's
last_id to resume past already-returned events.
*/
int64 last_id = 5;
}
enum ChannelEventType {
// An unknown event type.
CHAN_EVENT_UNKNOWN = 0;
// An online event.
CHAN_EVENT_ONLINE = 1;
// An offline event.
CHAN_EVENT_OFFLINE = 2;
// A channel balance update event.
CHAN_EVENT_UPDATE = 3;
}
message ChannelEventsResponse {
// The list of channel events.
repeated ChannelEvent events = 1;
// Id of the last event returned, suitable as the next request's last_id.
// Zero when events is empty.
int64 last_id = 2;
// True when the page filled to the requested limit and more events may be
// available; callers should keep paginating until this is false.
bool has_more = 3;
}
message ChannelEvent {
// The timestamp of the event, as Unix seconds.
int64 timestamp = 1;
// The type of the event.
ChannelEventType event_type = 2;
// The channel's local balance at the time of the event in sat.
uint64 local_balance = 3;
// The channel's remote balance at the time of the event in sat.
uint64 remote_balance = 4;
// Server-assigned monotonic identity. Echo this back as the next
// request's last_id when paginating.
int64 id = 5;
}
message ForwardingAbilityRequest {
// The start time of the query range as unix seconds. A value of 0 means
// the earliest available data.
uint64 start_time = 1;
// The end time of the query range as unix seconds. A value of 0 means the
// server's current time.
uint64 end_time = 2;
// The minimum directional liquidity in satoshis for a peer pair to count as
// economically forwardable. A value of 0 selects the server default. Hold
// this constant across calls for comparable series.
uint64 liquidity_floor_sat = 3;
// The uptime fraction in [0, 1] at or above which a peer pair that did not
// forward is reported compactly as a single bit in up_but_idle_bitmask
// rather than as a full entry. A value of 0 selects the server default.
// Pairs below this threshold that also did not forward are omitted
// entirely. If no pair meets the threshold the server returns a
// FailedPrecondition error, since that indicates the node itself was down
// for the window and the data carries no signal.
double uptime_threshold = 4;
}
message ForwardingAbilityResponse {
// Sorted list of unique compressed 33-byte public keys of the peers.
repeated bytes peers = 1;
// Sparse list of forwarding ability entries. An entry is present only for a
// pair that forwarded volume, carrying its exact effective_uptime_s and
// forwarded_sat. Pairs that did not forward are never listed here; they are
// either flagged in quiet_uptime_bitmask or absent. Entries take precedence
// over the bitmask for the same pair.
repeated ForwardingAbilityEntry entries = 2;
// The start of the window the metrics cover, as unix seconds.
int64 start_time = 3;
// The end of the window the metrics cover, as unix seconds.
int64 end_time = 4;
// A packed bitmask over the n*n ordered peer pairs, where n is the length
// of peers. The bit at index in*n+out is set when that pair held at least
// the uptime_threshold fraction of effective uptime over the window but did
// not forward: the dense "up but idle" population. A pair with a forwarded
// entry is never flagged here. A pair that is neither listed in entries nor
// flagged here had sub-threshold uptime and no forwards: treat it as zero.
bytes up_but_idle_bitmask = 5;
// The uptime fraction in [0, 1] used to populate up_but_idle_bitmask,
// echoed back so consumers know the threshold the server applied.
double uptime_threshold = 6;
}
message ForwardingAbilityEntry {
// The indices of the incoming and outgoing peers packed into a single
// 32-bit integer: packed_idx = (in << 16) | out. This caps the peer set at
// 65535 peers per direction.
uint32 packed_idx = 1;
// Seconds the peer pair held at least the requested liquidity floor of
// directional forwardable liquidity over the window.
int64 effective_uptime_s = 2;
// Total successfully forwarded amount over the window, in satoshis.
int64 forwarded_sat = 3;
}

View file

@ -102,7 +102,7 @@
},
{
"name": "fiat_backend",
"description": "The api to be used for fiat related queries.\n\n - COINCAP: Use the CoinCap API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coincap.io/v2/assets/bitcoin/history\n - COINDESK: Use the CoinDesk API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coindesk.com/v1/bpi/historical/close.json\n - CUSTOM: Use custom price data provided in a CSV file for fiat price information.\n - COINGECKO: Use the CoinGecko API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coingecko.com/api/v3/coins/bitcoin/market_chart\n - BITFINEX: Use the Bitfinex API for fiat price information.\nThis API is reached through the following URL:\nhttps://api-pub.bitfinex.com/v2/candles/trade:1h:tBTCUSD/hist",
"description": "The api to be used for fiat related queries.\n\n - COINCAP: Use the CoinCap API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coincap.io/v2/assets/bitcoin/history\n - COINDESK: Use the CoinDesk API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coindesk.com/v1/bpi/historical/close.json\n - CUSTOM: Use custom price data provided in a CSV file for fiat price information.\n - COINGECKO: Use the CoinGecko API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coingecko.com/api/v3/coins/bitcoin/market_chart",
"in": "query",
"required": false,
"type": "string",
@ -111,8 +111,7 @@
"COINCAP",
"COINDESK",
"CUSTOM",
"COINGECKO",
"BITFINEX"
"COINGECKO"
],
"default": "UNKNOWN_FIATBACKEND"
}
@ -120,71 +119,6 @@
"tags": [
"FaradayServer"
]
},
"post": {
"summary": "* frcli:\nGet fiat prices for btc.",
"description": "Example request:\nhttp://localhost:8466/v1/faraday/exchangerate",
"operationId": "FaradayServer_ExchangeRate2",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/frdrpcExchangeRateResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/frdrpcExchangeRateRequest"
}
}
],
"tags": [
"FaradayServer"
]
}
},
"/v1/faraday/getchannelevents": {
"post": {
"summary": "*\nGet a list of channel events that occurred for a given channel.",
"operationId": "FaradayServer_GetChannelEvents",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/frdrpcChannelEventsResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/frdrpcChannelEventsRequest"
}
}
],
"tags": [
"FaradayServer"
]
}
},
"/v1/faraday/insights": {
@ -275,7 +209,7 @@
},
{
"name": "fiat_backend",
"description": "The api to be used for fiat related queries.\n\n - COINCAP: Use the CoinCap API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coincap.io/v2/assets/bitcoin/history\n - COINDESK: Use the CoinDesk API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coindesk.com/v1/bpi/historical/close.json\n - CUSTOM: Use custom price data provided in a CSV file for fiat price information.\n - COINGECKO: Use the CoinGecko API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coingecko.com/api/v3/coins/bitcoin/market_chart\n - BITFINEX: Use the Bitfinex API for fiat price information.\nThis API is reached through the following URL:\nhttps://api-pub.bitfinex.com/v2/candles/trade:1h:tBTCUSD/hist",
"description": "The api to be used for fiat related queries.\n\n - COINCAP: Use the CoinCap API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coincap.io/v2/assets/bitcoin/history\n - COINDESK: Use the CoinDesk API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coindesk.com/v1/bpi/historical/close.json\n - CUSTOM: Use custom price data provided in a CSV file for fiat price information.\n - COINGECKO: Use the CoinGecko API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coingecko.com/api/v3/coins/bitcoin/market_chart",
"in": "query",
"required": false,
"type": "string",
@ -284,8 +218,7 @@
"COINCAP",
"COINDESK",
"CUSTOM",
"COINGECKO",
"BITFINEX"
"COINGECKO"
],
"default": "UNKNOWN_FIATBACKEND"
}
@ -293,38 +226,6 @@
"tags": [
"FaradayServer"
]
},
"post": {
"summary": "*\nGet a report of your node's activity over a period.",
"description": "Example request:\nhttp://localhost:8466/v1/faraday/nodeaudit",
"operationId": "FaradayServer_NodeAudit2",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/frdrpcNodeAuditResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/frdrpcNodeAuditRequest"
}
}
],
"tags": [
"FaradayServer"
]
}
},
"/v1/faraday/outliers/{rec_request.metric}": {
@ -382,53 +283,6 @@
"tags": [
"FaradayServer"
]
},
"post": {
"summary": "* frcli: `outliers`\nGet close recommendations for currently open channels based on whether it is\nan outlier.",
"description": "Example request:\nhttp://localhost:8466/v1/faraday/outliers/REVENUE?rec_request.minimum_monitored=123",
"operationId": "FaradayServer_OutlierRecommendations2",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/frdrpcCloseRecommendationsResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "rec_request.metric",
"description": "The data point base close recommendations on. Available options are:\nUptime: ratio of channel peer's uptime to the period they have been\nmonitored to.\nRevenue: the revenue that the channel has produced per block that its\nfunding transaction has been confirmed for.",
"in": "path",
"required": true,
"type": "string",
"enum": [
"UNKNOWN",
"UPTIME",
"REVENUE",
"INCOMING_VOLUME",
"OUTGOING_VOLUME",
"TOTAL_VOLUME"
]
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/FaradayServerOutlierRecommendationsBody"
}
}
],
"tags": [
"FaradayServer"
]
}
},
"/v1/faraday/revenue": {
@ -482,38 +336,6 @@
"tags": [
"FaradayServer"
]
},
"post": {
"summary": "* frcli: `revenue`\nGet a pairwise revenue report for a channel.",
"description": "Example request:\nhttp://localhost:8466/v1/faraday/revenue",
"operationId": "FaradayServer_RevenueReport2",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/frdrpcRevenueReportResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/frdrpcRevenueReportRequest"
}
}
],
"tags": [
"FaradayServer"
]
}
},
"/v1/faraday/threshold/{rec_request.metric}": {
@ -571,53 +393,6 @@
"tags": [
"FaradayServer"
]
},
"post": {
"summary": "* frcli: `threshold`\nGet close recommendations for currently open channels based whether they are\nbelow a set threshold.",
"description": "Example request:\nhttp://localhost:8466/v1/faraday/threshold/UPTIME?rec_request.minimum_monitored=123",
"operationId": "FaradayServer_ThresholdRecommendations2",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/frdrpcCloseRecommendationsResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "rec_request.metric",
"description": "The data point base close recommendations on. Available options are:\nUptime: ratio of channel peer's uptime to the period they have been\nmonitored to.\nRevenue: the revenue that the channel has produced per block that its\nfunding transaction has been confirmed for.",
"in": "path",
"required": true,
"type": "string",
"enum": [
"UNKNOWN",
"UPTIME",
"REVENUE",
"INCOMING_VOLUME",
"OUTGOING_VOLUME",
"TOTAL_VOLUME"
]
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/FaradayServerThresholdRecommendationsBody"
}
}
],
"tags": [
"FaradayServer"
]
}
}
},
@ -634,50 +409,6 @@
],
"default": "UNKNOWN"
},
"FaradayServerOutlierRecommendationsBody": {
"type": "object",
"properties": {
"rec_request": {
"type": "object",
"properties": {
"minimum_monitored": {
"type": "string",
"format": "int64",
"description": "The minimum amount of time in seconds that a channel should have been\nmonitored by lnd to be eligible for close. This value is in place to\nprotect against closing of newer channels."
}
},
"description": "The parameters that are common to all close recommendations.",
"title": "The parameters that are common to all close recommendations."
},
"outlier_multiplier": {
"type": "number",
"format": "float",
"description": "The number of inter-quartile ranges a value needs to be beneath the lower\nquartile/ above the upper quartile to be considered a lower/upper outlier.\nLower values will be more aggressive in recommending channel closes, and\nupper values will be more conservative. Recommended values are 1.5 for\naggressive recommendations and 3 for conservative recommendations."
}
}
},
"FaradayServerThresholdRecommendationsBody": {
"type": "object",
"properties": {
"rec_request": {
"type": "object",
"properties": {
"minimum_monitored": {
"type": "string",
"format": "int64",
"description": "The minimum amount of time in seconds that a channel should have been\nmonitored by lnd to be eligible for close. This value is in place to\nprotect against closing of newer channels."
}
},
"description": "The parameters that are common to all close recommendations.",
"title": "The parameters that are common to all close recommendations."
},
"threshold_value": {
"type": "number",
"format": "float",
"description": "The threshold that recommendations will be calculated based on.\nFor uptime: ratio of uptime to observed lifetime beneath which channels\nwill be recommended for closure.\n\nFor revenue: revenue per block that capital has been committed to the\nchannel beneath which channels will be recommended for closure. This\nvalue is provided per block so that channels that have been open for\ndifferent periods of time can be compared.\n\nFor incoming volume: The incoming volume per block that capital has\nbeen committed to the channel beneath which channels will be recommended\nfor closure. This value is provided per block so that channels that have\nbeen open for different periods of time can be compared.\n\nFor outgoing volume: The outgoing volume per block that capital has been\ncommitted to the channel beneath which channels will be recommended for\nclosure. This value is provided per block so that channels that have been\nopen for different periods of time can be compared.\n\nFor total volume: The total volume per block that capital has been\ncommitted to the channel beneath which channels will be recommended for\nclosure. This value is provided per block so that channels that have been\nopen for different periods of time can be compared."
}
}
},
"frdrpcBitcoinPrice": {
"type": "object",
"properties": {
@ -696,97 +427,6 @@
}
}
},
"frdrpcChannelEvent": {
"type": "object",
"properties": {
"timestamp": {
"type": "string",
"format": "int64",
"description": "The timestamp of the event, as Unix seconds."
},
"event_type": {
"$ref": "#/definitions/frdrpcChannelEventType",
"description": "The type of the event."
},
"local_balance": {
"type": "string",
"format": "uint64",
"description": "The channel's local balance at the time of the event in sat."
},
"remote_balance": {
"type": "string",
"format": "uint64",
"description": "The channel's remote balance at the time of the event in sat."
},
"id": {
"type": "string",
"format": "int64",
"description": "Server-assigned monotonic identity. Echo this back as the next\nrequest's last_id when paginating."
}
}
},
"frdrpcChannelEventType": {
"type": "string",
"enum": [
"CHAN_EVENT_UNKNOWN",
"CHAN_EVENT_ONLINE",
"CHAN_EVENT_OFFLINE",
"CHAN_EVENT_UPDATE"
],
"default": "CHAN_EVENT_UNKNOWN",
"description": " - CHAN_EVENT_UNKNOWN: An unknown event type.\n - CHAN_EVENT_ONLINE: An online event.\n - CHAN_EVENT_OFFLINE: An offline event.\n - CHAN_EVENT_UPDATE: A channel balance update event."
},
"frdrpcChannelEventsRequest": {
"type": "object",
"properties": {
"chan_point": {
"type": "string",
"description": "The channel point of the channel to get events for, formatted txid:outpoint."
},
"start_time": {
"type": "string",
"format": "int64",
"description": "Lower time bound, inclusive, as Unix seconds. Independent filter — does\nnot need to advance between paginated calls. Zero means no lower bound."
},
"end_time": {
"type": "string",
"format": "int64",
"description": "Upper time bound, exclusive, as Unix seconds. Must be greater than or\nequal to start_time. Zero means \"use the server's current time\"."
},
"max_events": {
"type": "integer",
"format": "int64",
"description": "The maximum number of events to return. If zero, the server default is\nused. The server enforces a hard cap; values above the cap are clamped."
},
"last_id": {
"type": "string",
"format": "int64",
"description": "Pagination cursor: id of the last event from the previous response.\nPass zero on the first call; for subsequent calls pass the response's\nlast_id to resume past already-returned events."
}
}
},
"frdrpcChannelEventsResponse": {
"type": "object",
"properties": {
"events": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/frdrpcChannelEvent"
},
"description": "The list of channel events."
},
"last_id": {
"type": "string",
"format": "int64",
"description": "Id of the last event returned, suitable as the next request's last_id.\nZero when events is empty."
},
"has_more": {
"type": "boolean",
"description": "True when the page filled to the requested limit and more events may be\navailable; callers should keep paginating until this is false."
}
}
},
"frdrpcChannelInsight": {
"type": "object",
"properties": {
@ -836,7 +476,6 @@
"channel_insights": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/frdrpcChannelInsight"
},
"description": "Insights for the set of currently open channels."
@ -873,7 +512,6 @@
"recommendations": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/frdrpcRecommendation"
},
"description": "A set of channel close recommendations. The absence of a channel in this\nset implies that it was not considered for close because it did not meet\nthe criteria for close recommendations (it is private, or has not been\nmonitored for long enough)."
@ -970,42 +608,12 @@
}
}
},
"frdrpcExchangeRateRequest": {
"type": "object",
"properties": {
"timestamps": {
"type": "array",
"items": {
"type": "string",
"format": "uint64"
},
"description": "A set of timestamps for which we want the bitcoin price."
},
"granularity": {
"$ref": "#/definitions/frdrpcGranularity",
"description": "The level of granularity at which we want the bitcoin price to be quoted."
},
"fiat_backend": {
"$ref": "#/definitions/frdrpcFiatBackend",
"description": "The api to be used for fiat related queries."
},
"custom_prices": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/frdrpcBitcoinPrice"
},
"description": "Custom price points to use if the CUSTOM FiatBackend option is set."
}
}
},
"frdrpcExchangeRateResponse": {
"type": "object",
"properties": {
"rates": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/frdrpcExchangeRate"
},
"title": "Rates contains a set of exchange rates for the set of timestamps"
@ -1019,72 +627,10 @@
"COINCAP",
"COINDESK",
"CUSTOM",
"COINGECKO",
"BITFINEX"
"COINGECKO"
],
"default": "UNKNOWN_FIATBACKEND",
"description": "FiatBackend is the API endpoint to be used for any fiat related queries.\n\n - COINCAP: Use the CoinCap API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coincap.io/v2/assets/bitcoin/history\n - COINDESK: Use the CoinDesk API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coindesk.com/v1/bpi/historical/close.json\n - CUSTOM: Use custom price data provided in a CSV file for fiat price information.\n - COINGECKO: Use the CoinGecko API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coingecko.com/api/v3/coins/bitcoin/market_chart\n - BITFINEX: Use the Bitfinex API for fiat price information.\nThis API is reached through the following URL:\nhttps://api-pub.bitfinex.com/v2/candles/trade:1h:tBTCUSD/hist"
},
"frdrpcForwardingAbilityEntry": {
"type": "object",
"properties": {
"packed_idx": {
"type": "integer",
"format": "int64",
"description": "The indices of the incoming and outgoing peers packed into a single\n32-bit integer: packed_idx = (in \u003c\u003c 16) | out. This caps the peer set at\n65535 peers per direction."
},
"effective_uptime_s": {
"type": "string",
"format": "int64",
"description": "Seconds the peer pair held at least the requested liquidity floor of\ndirectional forwardable liquidity over the window."
},
"forwarded_sat": {
"type": "string",
"format": "int64",
"description": "Total successfully forwarded amount over the window, in satoshis."
}
}
},
"frdrpcForwardingAbilityResponse": {
"type": "object",
"properties": {
"peers": {
"type": "array",
"items": {
"type": "string",
"format": "byte"
},
"description": "Sorted list of unique compressed 33-byte public keys of the peers."
},
"entries": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/frdrpcForwardingAbilityEntry"
},
"description": "Sparse list of forwarding ability entries. An entry is present only for a\npair that forwarded volume, carrying its exact effective_uptime_s and\nforwarded_sat. Pairs that did not forward are never listed here; they are\neither flagged in quiet_uptime_bitmask or absent. Entries take precedence\nover the bitmask for the same pair."
},
"start_time": {
"type": "string",
"format": "int64",
"description": "The start of the window the metrics cover, as unix seconds."
},
"end_time": {
"type": "string",
"format": "int64",
"description": "The end of the window the metrics cover, as unix seconds."
},
"up_but_idle_bitmask": {
"type": "string",
"format": "byte",
"description": "A packed bitmask over the n*n ordered peer pairs, where n is the length\nof peers. The bit at index in*n+out is set when that pair held at least\nthe uptime_threshold fraction of effective uptime over the window but did\nnot forward: the dense \"up but idle\" population. A pair with a forwarded\nentry is never flagged here. A pair that is neither listed in entries nor\nflagged here had sub-threshold uptime and no forwards: treat it as zero."
},
"uptime_threshold": {
"type": "number",
"format": "double",
"description": "The uptime fraction in [0, 1] used to populate up_but_idle_bitmask,\nechoed back so consumers know the threshold the server applied."
}
}
"description": "FiatBackend is the API endpoint to be used for any fiat related queries.\n\n - COINCAP: Use the CoinCap API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coincap.io/v2/assets/bitcoin/history\n - COINDESK: Use the CoinDesk API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coindesk.com/v1/bpi/historical/close.json\n - CUSTOM: Use custom price data provided in a CSV file for fiat price information.\n - COINGECKO: Use the CoinGecko API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coingecko.com/api/v3/coins/bitcoin/market_chart"
},
"frdrpcGranularity": {
"type": "string",
@ -1102,56 +648,12 @@
"default": "UNKNOWN_GRANULARITY",
"description": "Granularity describes the aggregation level at which the Bitcoin price should\nbe queried. Note that setting lower levels of granularity may require more\nqueries to the fiat backend."
},
"frdrpcNodeAuditRequest": {
"type": "object",
"properties": {
"start_time": {
"type": "string",
"format": "uint64",
"description": "The unix time from which to produce the report, inclusive."
},
"end_time": {
"type": "string",
"format": "uint64",
"description": "The unix time until which to produce the report, exclusive."
},
"disable_fiat": {
"type": "boolean",
"description": "Set to generate a report without conversion to fiat. If set, fiat values\nwill display as 0."
},
"granularity": {
"$ref": "#/definitions/frdrpcGranularity",
"description": "The level of granularity at which we wish to produce fiat prices."
},
"custom_categories": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/frdrpcCustomCategory"
},
"description": "An optional set of custom categories which can be used to identify bespoke\ncategories in the report. Each category must have a unique name, and may not\nhave common identifier regexes. Transactions that are matched to these\ncategories report the category name in the CustomCategory field."
},
"fiat_backend": {
"$ref": "#/definitions/frdrpcFiatBackend",
"description": "The api to be used for fiat related queries."
},
"custom_prices": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/frdrpcBitcoinPrice"
},
"description": "Custom price points to use if the CUSTOM FiatBackend option is set."
}
}
},
"frdrpcNodeAuditResponse": {
"type": "object",
"properties": {
"reports": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/frdrpcReportEntry"
},
"description": "On chain reports for the period queried."
@ -1272,35 +774,12 @@
}
}
},
"frdrpcRevenueReportRequest": {
"type": "object",
"properties": {
"chan_points": {
"type": "array",
"items": {
"type": "string"
},
"description": "The funding transaction outpoints for the channels to generate a revenue\nreport for. If this is empty, it will be generated for all open and closed\nchannels. Channel funding points should be expressed with the format\nfundingTxID:outpoint."
},
"start_time": {
"type": "string",
"format": "uint64",
"description": "Start time is beginning of the range over which the report will be\ngenerated, expressed as unix epoch offset in seconds."
},
"end_time": {
"type": "string",
"format": "uint64",
"description": "End time is end of the range over which the report will be\ngenerated, expressed as unix epoch offset in seconds."
}
}
},
"frdrpcRevenueReportResponse": {
"type": "object",
"properties": {
"reports": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/frdrpcRevenueReport"
},
"description": "Reports is a set of pairwise revenue report generated for the channel(s)\nover the period specified."
@ -1310,11 +789,14 @@
"protobufAny": {
"type": "object",
"properties": {
"@type": {
"type_url": {
"type": "string"
},
"value": {
"type": "string",
"format": "byte"
}
},
"additionalProperties": {}
}
},
"rpcStatus": {
"type": "object",
@ -1329,7 +811,6 @@
"details": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/protobufAny"
}
}

View file

@ -6,33 +6,15 @@ http:
# rpc.proto
- selector: frdrpc.FaradayServer.OutlierRecommendations
get: "/v1/faraday/outliers/{rec_request.metric}"
additional_bindings:
- post: "/v1/faraday/outliers/{rec_request.metric}"
body: "*"
- selector: frdrpc.FaradayServer.ThresholdRecommendations
get: "/v1/faraday/threshold/{rec_request.metric}"
additional_bindings:
- post: "/v1/faraday/threshold/{rec_request.metric}"
body: "*"
- selector: frdrpc.FaradayServer.RevenueReport
get: "/v1/faraday/revenue"
additional_bindings:
- post: "/v1/faraday/revenue"
body: "*"
- selector: frdrpc.FaradayServer.ChannelInsights
get: "/v1/faraday/insights"
- selector: frdrpc.FaradayServer.ExchangeRate
get: "/v1/faraday/exchangerate"
additional_bindings:
- post: "/v1/faraday/exchangerate"
body: "*"
- selector: frdrpc.FaradayServer.NodeAudit
get: "/v1/faraday/nodeaudit"
additional_bindings:
- post: "/v1/faraday/nodeaudit"
body: "*"
- selector: frdrpc.FaradayServer.CloseReport
get: "/v1/faraday/closereport"
- selector: frdrpc.FaradayServer.GetChannelEvents
post: "/v1/faraday/getchannelevents"
body: "*"

View file

@ -62,12 +62,6 @@ type FaradayServerClient interface {
// Example request:
// http://localhost:8466/v1/faraday/closereport
CloseReport(ctx context.Context, in *CloseReportRequest, opts ...grpc.CallOption) (*CloseReportResponse, error)
// *
// Get a list of channel events that occurred for a given channel.
GetChannelEvents(ctx context.Context, in *ChannelEventsRequest, opts ...grpc.CallOption) (*ChannelEventsResponse, error)
// *
// Get forwarding ability analysis of peer pairs.
ForwardingAbility(ctx context.Context, in *ForwardingAbilityRequest, opts ...grpc.CallOption) (*ForwardingAbilityResponse, error)
}
type faradayServerClient struct {
@ -141,24 +135,6 @@ func (c *faradayServerClient) CloseReport(ctx context.Context, in *CloseReportRe
return out, nil
}
func (c *faradayServerClient) GetChannelEvents(ctx context.Context, in *ChannelEventsRequest, opts ...grpc.CallOption) (*ChannelEventsResponse, error) {
out := new(ChannelEventsResponse)
err := c.cc.Invoke(ctx, "/frdrpc.FaradayServer/GetChannelEvents", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *faradayServerClient) ForwardingAbility(ctx context.Context, in *ForwardingAbilityRequest, opts ...grpc.CallOption) (*ForwardingAbilityResponse, error) {
out := new(ForwardingAbilityResponse)
err := c.cc.Invoke(ctx, "/frdrpc.FaradayServer/ForwardingAbility", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// FaradayServerServer is the server API for FaradayServer service.
// All implementations must embed UnimplementedFaradayServerServer
// for forward compatibility
@ -207,12 +183,6 @@ type FaradayServerServer interface {
// Example request:
// http://localhost:8466/v1/faraday/closereport
CloseReport(context.Context, *CloseReportRequest) (*CloseReportResponse, error)
// *
// Get a list of channel events that occurred for a given channel.
GetChannelEvents(context.Context, *ChannelEventsRequest) (*ChannelEventsResponse, error)
// *
// Get forwarding ability analysis of peer pairs.
ForwardingAbility(context.Context, *ForwardingAbilityRequest) (*ForwardingAbilityResponse, error)
mustEmbedUnimplementedFaradayServerServer()
}
@ -241,12 +211,6 @@ func (UnimplementedFaradayServerServer) NodeAudit(context.Context, *NodeAuditReq
func (UnimplementedFaradayServerServer) CloseReport(context.Context, *CloseReportRequest) (*CloseReportResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CloseReport not implemented")
}
func (UnimplementedFaradayServerServer) GetChannelEvents(context.Context, *ChannelEventsRequest) (*ChannelEventsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetChannelEvents not implemented")
}
func (UnimplementedFaradayServerServer) ForwardingAbility(context.Context, *ForwardingAbilityRequest) (*ForwardingAbilityResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ForwardingAbility not implemented")
}
func (UnimplementedFaradayServerServer) mustEmbedUnimplementedFaradayServerServer() {}
// UnsafeFaradayServerServer may be embedded to opt out of forward compatibility for this service.
@ -386,42 +350,6 @@ func _FaradayServer_CloseReport_Handler(srv interface{}, ctx context.Context, de
return interceptor(ctx, in, info, handler)
}
func _FaradayServer_GetChannelEvents_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ChannelEventsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(FaradayServerServer).GetChannelEvents(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/frdrpc.FaradayServer/GetChannelEvents",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(FaradayServerServer).GetChannelEvents(ctx, req.(*ChannelEventsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _FaradayServer_ForwardingAbility_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ForwardingAbilityRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(FaradayServerServer).ForwardingAbility(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/frdrpc.FaradayServer/ForwardingAbility",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(FaradayServerServer).ForwardingAbility(ctx, req.(*ForwardingAbilityRequest))
}
return interceptor(ctx, in, info, handler)
}
// FaradayServer_ServiceDesc is the grpc.ServiceDesc for FaradayServer service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -457,14 +385,6 @@ var FaradayServer_ServiceDesc = grpc.ServiceDesc{
MethodName: "CloseReport",
Handler: _FaradayServer_CloseReport_Handler,
},
{
MethodName: "GetChannelEvents",
Handler: _FaradayServer_GetChannelEvents_Handler,
},
{
MethodName: "ForwardingAbility",
Handler: _FaradayServer_ForwardingAbility_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "faraday.proto",

View file

@ -195,54 +195,4 @@ func RegisterFaradayServerJSONCallbacks(registry map[string]func(ctx context.Con
}
callback(string(respBytes), nil)
}
registry["frdrpc.FaradayServer.GetChannelEvents"] = func(ctx context.Context,
conn *grpc.ClientConn, reqJSON string, callback func(string, error)) {
req := &ChannelEventsRequest{}
err := marshaler.Unmarshal([]byte(reqJSON), req)
if err != nil {
callback("", err)
return
}
client := NewFaradayServerClient(conn)
resp, err := client.GetChannelEvents(ctx, req)
if err != nil {
callback("", err)
return
}
respBytes, err := marshaler.Marshal(resp)
if err != nil {
callback("", err)
return
}
callback(string(respBytes), nil)
}
registry["frdrpc.FaradayServer.ForwardingAbility"] = func(ctx context.Context,
conn *grpc.ClientConn, reqJSON string, callback func(string, error)) {
req := &ForwardingAbilityRequest{}
err := marshaler.Unmarshal([]byte(reqJSON), req)
if err != nil {
callback("", err)
return
}
client := NewFaradayServerClient(conn)
resp, err := client.ForwardingAbility(ctx, req)
if err != nil {
callback("", err)
return
}
respBytes, err := marshaler.Marshal(resp)
if err != nil {
callback("", err)
return
}
callback(string(respBytes), nil)
}
}

View file

@ -1,374 +0,0 @@
package frdrpc
import (
"bytes"
"encoding/hex"
"errors"
"fmt"
"math"
"sort"
"strings"
)
// maxPackedPeers is the largest peer set a response can address. packed_idx
// splits a uint32 into two 16-bit indices (in << 16 | out), so each direction
// can reference at most 65535 distinct peers.
const maxPackedPeers = 1<<16 - 1
// ForwardingAbility is a client-facing mirror of the raw forwarding facts for
// one direction of a peer pair over the analysis window. Derived rates and
// categories are left to the consumer.
type ForwardingAbility struct {
// EffectiveUptimeS is the seconds the pair held at least the requested
// liquidity floor of directional forwardable liquidity over the window.
// The value is whole seconds: sub-second uptime floors to zero, so a
// pair that forwarded volume over a fleeting qualifying window can
// report a zero uptime alongside a non-zero ForwardedSat.
EffectiveUptimeS int64
// ForwardedSat is the total successfully forwarded amount over the
// window, in satoshis.
ForwardedSat int64
}
// abilityTier classifies how a pair is encoded: a full entry, a single "up but
// idle" bit, or omitted entirely.
type abilityTier int
const (
// tierAbsent omits the pair: it neither forwarded nor held enough
// uptime to clear the threshold. Consumers treat absence as zero.
tierAbsent abilityTier = iota
// tierBit flags the pair in the up-but-idle bitmask: it held at least
// the uptime threshold but did not forward.
tierBit
// tierEntry emits a full entry carrying the pair's exact uptime and
// forwarded volume. Reserved for pairs that actually forwarded.
tierEntry
)
// tier decides how a pair is encoded given the minimum qualifying uptime in
// seconds. Forwarding always wins, so a pair that forwarded keeps its exact
// facts even if its uptime is below the threshold; otherwise the pair is
// compacted to a bit when it was up enough, and dropped when it was not.
func (a ForwardingAbility) tier(minUptimeS int64) abilityTier {
switch {
case a.ForwardedSat > 0:
return tierEntry
case a.EffectiveUptimeS >= minUptimeS:
return tierBit
default:
return tierAbsent
}
}
// MinQualifyingUptime converts an uptime fraction threshold into the smallest
// whole-second uptime that clears it over the given window. It is the single
// source of truth shared by the encoder (to bucket pairs) and the server (to
// apply the node-down guard), so the two cannot drift. A pair clears the
// threshold when EffectiveUptimeS >= the returned value, matching the "at least
// the threshold fraction" contract. The result is floored at one second so a
// pair with zero uptime is never treated as up. A non-positive window admits
// nothing.
func MinQualifyingUptime(threshold float64, windowSeconds int64) int64 {
if windowSeconds <= 0 {
return math.MaxInt64
}
v := int64(math.Ceil(threshold * float64(windowSeconds)))
if v < 1 {
v = 1
}
return v
}
// setBit sets the bit at the given index in a packed bitmask. The index is an
// int64 because an n*n bitmask over the full peer set overflows a 32-bit int.
func setBit(mask []byte, index int64) {
mask[index/8] |= 1 << (index % 8)
}
// getBit reports whether the bit at the given index in a packed bitmask is set.
// The index is an int64 because an n*n bitmask over the full peer set overflows
// a 32-bit int.
func getBit(mask []byte, index int64) bool {
return mask[index/8]&(1<<(index%8)) != 0
}
// EncodeForwardingAbility serializes a nested map of peer forwarding abilities
// into a memory-efficient sparse gRPC response over [startTime, endTime]. To
// optimize payload size it tiers each pair: pairs that forwarded keep a full
// entry, pairs that were up at least uptimeThreshold of the window but did not
// forward collapse to a single bit in the up-but-idle bitmask, and pairs below
// the threshold that did not forward are omitted entirely. Public keys are
// deduplicated and peer pairs packed into 32-bit indices.
func EncodeForwardingAbility(abilities map[string]map[string]ForwardingAbility,
startTime, endTime int64,
uptimeThreshold float64) (*ForwardingAbilityResponse, error) {
minUptimeS := MinQualifyingUptime(uptimeThreshold, endTime-startTime)
// First, find all unique peers involved in pairs that warrant either an
// entry or a bit. Keys are normalized to lower-case hex so a peer that
// appears in mixed case across entries collapses to a single index
// rather than being silently dropped at lookup time.
peerSet := make(map[string]struct{})
for inPeer, outMap := range abilities {
for outPeer, ability := range outMap {
if ability.tier(minUptimeS) == tierAbsent {
continue
}
peerSet[strings.ToLower(inPeer)] = struct{}{}
peerSet[strings.ToLower(outPeer)] = struct{}{}
}
}
// Decode to raw bytes and sort.
var rawPeers [][]byte
for peerHex := range peerSet {
b, err := hex.DecodeString(peerHex)
if err != nil {
return nil, err
}
rawPeers = append(rawPeers, b)
}
sort.Slice(
rawPeers,
func(i, j int) bool {
return bytes.Compare(rawPeers[i], rawPeers[j]) < 0
},
)
// Peer indices occupy 16 bits each in packed_idx, so the set must stay
// within maxPackedPeers. Beyond it, an index would overflow its field
// and silently decode to the wrong peer pair, so fail loudly instead.
if len(rawPeers) > maxPackedPeers {
return nil, fmt.Errorf("peer set of %d exceeds the %d "+
"addressable by packed_idx", len(rawPeers),
maxPackedPeers)
}
// Create map for index lookup using normalized lowercase hex strings.
peerIndex := make(map[string]uint32)
for idx, b := range rawPeers {
peerIndex[hex.EncodeToString(b)] = uint32(idx)
}
// The bitmask addresses every ordered pair over the peer set, so it
// needs n*n bits. Allocation is deferred until a bit is actually set so
// a response with no up-but-idle pairs carries no bitmask at all.
n := int64(len(rawPeers))
var bitmask []byte
// Build the entries and bitmask. seen guards against two input keys
// that differ only by hex case collapsing onto the same packed pair.
var entries []*ForwardingAbilityEntry
seen := make(map[uint32]struct{})
// addEntry appends a full entry for a forwarded pair.
addEntry := func(packed uint32, a ForwardingAbility) {
entries = append(entries, &ForwardingAbilityEntry{
PackedIdx: packed,
EffectiveUptimeS: a.EffectiveUptimeS,
ForwardedSat: a.ForwardedSat,
})
}
for inPeer, outMap := range abilities {
inIdx, okIn := peerIndex[strings.ToLower(inPeer)]
if !okIn {
continue
}
for outPeer, ability := range outMap {
tier := ability.tier(minUptimeS)
if tier == tierAbsent {
continue
}
outIdx, okOut := peerIndex[strings.ToLower(outPeer)]
if !okOut {
continue
}
// Pack the in-peer index into the high 16 bits and the
// out-peer index into the low 16.
packed := (inIdx << 16) | outIdx
// Reject a case-folded collision rather than silently
// dropping one of the two entries' facts.
if _, dup := seen[packed]; dup {
return nil, fmt.Errorf("duplicate peer pair "+
"after case normalization: "+
"in=%s out=%s", inPeer, outPeer)
}
seen[packed] = struct{}{}
switch tier {
case tierEntry:
addEntry(packed, ability)
case tierBit:
// The bitmask addresses n*n ordered pairs, one
// bit each.
if bitmask == nil {
bitmask = make(
[]byte, (n*n+7)/8,
)
}
setBit(
bitmask,
int64(inIdx)*n+int64(outIdx),
)
}
}
}
// Sort entries by packed_idx for deterministic output and testability.
sort.Slice(
entries,
func(i, j int) bool {
return entries[i].PackedIdx < entries[j].PackedIdx
},
)
return &ForwardingAbilityResponse{
Peers: rawPeers,
Entries: entries,
StartTime: startTime,
EndTime: endTime,
UpButIdleBitmask: bitmask,
UptimeThreshold: uptimeThreshold,
}, nil
}
// DecodeForwardingAbility reconstructs the nested map of peer forwarding
// abilities from a sparse packed gRPC response. Forwarded pairs come back with
// their exact facts; up-but-idle pairs flagged in the bitmask come back at full
// window uptime with zero forwarded volume. It validates packed indices and the
// bitmask length against the decoded peer list to prevent out-of-bounds errors.
func DecodeForwardingAbility(resp *ForwardingAbilityResponse) (
map[string]map[string]ForwardingAbility, error) {
result := make(map[string]map[string]ForwardingAbility)
if resp == nil {
return result, nil
}
numPeers := len(resp.Peers)
// packed_idx addresses peers with 16-bit indices, so a response with
// more than maxPackedPeers peers is malformed. Rejecting it here also
// keeps the n*n bitmask-length computation below from overflowing a
// 32-bit int.
if numPeers > maxPackedPeers {
return nil, fmt.Errorf("peer set of %d exceeds the %d "+
"addressable by packed_idx", numPeers, maxPackedPeers)
}
record := func(inIdx, outIdx int, ability ForwardingAbility) {
inPeer := hex.EncodeToString(resp.Peers[inIdx])
outPeer := hex.EncodeToString(resp.Peers[outIdx])
if _, ok := result[inPeer]; !ok {
result[inPeer] = make(map[string]ForwardingAbility)
}
result[inPeer][outPeer] = ability
}
// Decode the forwarded entries first so they take precedence over any
// bit set for the same pair.
for _, entry := range resp.Entries {
// Unpack the pair: the in-peer index is the high 16 bits, the
// out-peer index the low 16.
inIdx := int(entry.PackedIdx >> 16)
outIdx := int(entry.PackedIdx & 0xffff)
if inIdx >= numPeers || outIdx >= numPeers {
return nil, errors.New("decoded peer index out of " +
"bounds")
}
record(
inIdx, outIdx, ForwardingAbility{
EffectiveUptimeS: entry.EffectiveUptimeS,
ForwardedSat: entry.ForwardedSat,
},
)
}
// Expand the up-but-idle bitmask. An absent bitmask simply means no
// pair was flagged; a present one must address exactly the n*n pairs.
bitmask := resp.UpButIdleBitmask
if len(bitmask) == 0 {
return result, nil
}
// Compute the expected length in int64 so the n*n multiplication does
// not overflow a 32-bit int for a large peer set.
totalPairs := int64(numPeers) * int64(numPeers)
if want := int((totalPairs + 7) / 8); len(bitmask) != want {
return nil, fmt.Errorf("bitmask length %d does not match the "+
"%d expected for %d peers", len(bitmask), want,
numPeers)
}
// Up-but-idle pairs were up the whole window by definition of the
// threshold bucket, so reconstruct them at full window uptime with zero
// forwarded volume. Iterate over the bitmask bytes directly, skipping
// zero bytes, so cost scales with the number of set bits rather than
// the O(n*n) pair space; padding bits beyond n*n are ignored.
windowSeconds := resp.EndTime - resp.StartTime
for i, b := range bitmask {
if b == 0 {
continue
}
for bit := range 8 {
// If the bit is not set, skip the pair. This also
// implicitly ignores any padding bits in the last byte
// beyond the n*n pairs.
if b&(1<<bit) == 0 {
continue
}
// Compute the pair index from the byte index and bit
// position.
k := int64(i)*8 + int64(bit)
if k >= totalPairs {
break
}
// Unpack the pair: the in-peer index is the high 16
// bits, the out-peer index the low 16. The bounds were
// already checked against the bitmask length, so this
// cannot overflow.
inIdx := int(k / int64(numPeers))
outIdx := int(k % int64(numPeers))
// An entry for this pair takes precedence; never
// overwrite it.
inPeer := hex.EncodeToString(resp.Peers[inIdx])
outPeer := hex.EncodeToString(resp.Peers[outIdx])
if _, ok := result[inPeer][outPeer]; ok {
continue
}
record(
inIdx, outIdx, ForwardingAbility{
EffectiveUptimeS: windowSeconds,
ForwardedSat: 0,
},
)
}
}
return result, nil
}

View file

@ -1,587 +0,0 @@
package frdrpc
import (
"encoding/hex"
"fmt"
"math"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// fwdKey returns a distinct 33-byte compressed-pubkey hex string for n. Keys
// sort ascending in n, matching the byte ordering the encoder applies.
func fwdKey(n int) string {
return fmt.Sprintf("02%064x", n)
}
// pair is one expected decoded entry, flattened from the nested result map for
// easy comparison.
type pair struct {
in string
out string
ability ForwardingAbility
}
// TestForwardingAbilityCodecRoundTrip verifies the three-tier encoding: pairs
// that forwarded keep exact facts as entries, pairs up at least the threshold
// but idle collapse to a bitmask bit (decoded at full window uptime), and
// sub-threshold idle pairs are dropped. The window is [0, 100) and the
// threshold 0.5, so the minimum qualifying uptime is 50 seconds.
func TestForwardingAbilityCodecRoundTrip(t *testing.T) {
const (
startTime, endTime int64 = 0, 100
threshold float64 = 0.5
)
tests := []struct {
name string
abilities map[string]map[string]ForwardingAbility
wantPeers []string
wantPairs []pair
wantBitmask bool
}{
{
// Forwarding wins regardless of uptime, so a
// zero-uptime pair that moved volume survives with its
// exact facts and never lands in the bitmask.
name: "forwarded pairs keep exact facts",
abilities: map[string]map[string]ForwardingAbility{
fwdKey(1): {
fwdKey(2): {
EffectiveUptimeS: 80,
ForwardedSat: 1500,
},
},
fwdKey(2): {
fwdKey(1): {
EffectiveUptimeS: 0,
ForwardedSat: 2500,
},
},
},
wantPeers: []string{
fwdKey(1),
fwdKey(2),
},
wantPairs: []pair{
{
fwdKey(1),
fwdKey(2),
ForwardingAbility{
80,
1500,
},
},
{
fwdKey(2),
fwdKey(1),
ForwardingAbility{
0,
2500,
},
},
},
wantBitmask: false,
},
{
// Up at or above the threshold but no forwards: a bit,
// decoded back at the full window's uptime.
name: "up but idle becomes a bit",
abilities: map[string]map[string]ForwardingAbility{
fwdKey(1): {
fwdKey(2): {
EffectiveUptimeS: 80,
},
},
fwdKey(3): {
fwdKey(1): {
EffectiveUptimeS: 50,
},
},
},
wantPeers: []string{
fwdKey(1),
fwdKey(2),
fwdKey(3),
},
wantPairs: []pair{
{
fwdKey(1),
fwdKey(2),
ForwardingAbility{
100,
0,
},
},
{
fwdKey(3),
fwdKey(1),
ForwardingAbility{
100,
0,
},
},
},
wantBitmask: true,
},
{
// Below the threshold with no forwards: dropped.
name: "sub-threshold idle pairs dropped",
abilities: map[string]map[string]ForwardingAbility{
fwdKey(1): {
fwdKey(2): {
EffectiveUptimeS: 49,
},
},
},
wantPeers: []string{},
wantPairs: []pair{},
wantBitmask: false,
},
{
// All three tiers at once, including a peer that only
// appears via the bitmask.
name: "mixed tiers",
abilities: map[string]map[string]ForwardingAbility{
fwdKey(1): {
fwdKey(2): {
EffectiveUptimeS: 80,
ForwardedSat: 1500,
},
fwdKey(3): {
EffectiveUptimeS: 60,
},
},
fwdKey(2): {
fwdKey(3): {
EffectiveUptimeS: 10,
},
},
fwdKey(3): {
fwdKey(1): {
ForwardedSat: 500,
},
},
},
wantPeers: []string{
fwdKey(1),
fwdKey(2),
fwdKey(3),
},
wantPairs: []pair{
{
fwdKey(1),
fwdKey(2),
ForwardingAbility{
80,
1500,
},
},
{
fwdKey(1),
fwdKey(3),
ForwardingAbility{
100,
0,
},
},
{
fwdKey(3),
fwdKey(1),
ForwardingAbility{
0,
500,
},
},
},
wantBitmask: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
resp, err := EncodeForwardingAbility(
tc.abilities, startTime, endTime, threshold,
)
require.NoError(t, err)
require.Equal(t, startTime, resp.StartTime)
require.Equal(t, endTime, resp.EndTime)
require.Equal(t, threshold, resp.UptimeThreshold)
require.Equal(
t, tc.wantBitmask,
len(resp.UpButIdleBitmask) > 0,
)
// A present bitmask must address exactly n*n bits.
if tc.wantBitmask {
n := len(resp.Peers)
require.Len(t, resp.UpButIdleBitmask, (n*n+7)/8)
}
gotPeers := make([]string, len(resp.Peers))
for i, p := range resp.Peers {
gotPeers[i] = hex.EncodeToString(p)
}
require.Equal(t, tc.wantPeers, gotPeers)
decoded, err := DecodeForwardingAbility(resp)
require.NoError(t, err)
got := make(map[string]ForwardingAbility)
for in, outMap := range decoded {
for out, ability := range outMap {
got[in+"->"+out] = ability
}
}
require.Len(t, got, len(tc.wantPairs))
for _, wp := range tc.wantPairs {
require.Equal(
t, wp.ability, got[wp.in+"->"+wp.out],
)
}
})
}
}
// TestMinQualifyingUptime verifies the threshold-to-seconds conversion shared
// by the encoder and the server guard, including its boundary behavior.
func TestMinQualifyingUptime(t *testing.T) {
tests := []struct {
name string
threshold float64
window int64
want int64
}{
{
"half of clean window",
0.5,
100,
50,
},
{
"rounds up a fraction",
0.333,
100,
34,
},
{
"integer boundary",
0.9,
2_592_000,
2_332_800,
},
{
"floored at one second",
0.0,
100,
1,
},
{
"non-positive window admits nothing",
0.5,
0,
math.MaxInt64,
},
}
for _, tc := range tests {
t.Run(
tc.name,
func(t *testing.T) {
require.Equal(
t, tc.want, MinQualifyingUptime(
tc.threshold, tc.window,
),
)
},
)
}
}
// TestBitmaskHelpers verifies that setBit and getBit address the same bit.
func TestBitmaskHelpers(t *testing.T) {
mask := make([]byte, 2)
require.False(t, getBit(mask, 9))
setBit(mask, 9)
require.True(t, getBit(mask, 9))
require.False(t, getBit(mask, 8))
require.False(t, getBit(mask, 10))
}
// TestForwardingAbilityDecodeEntryPrecedence verifies that when a pair is both
// listed as an entry and flagged in the bitmask, the entry's exact facts win.
func TestForwardingAbilityDecodeEntryPrecedence(t *testing.T) {
// Two peers => a 2*2 bitmask needs (4+7)/8 = 1 byte. Set the bit for
// pair (0, 1) at index 0*2+1 = 1, and also list it as an entry.
mask := make([]byte, 1)
setBit(mask, 1)
resp := &ForwardingAbilityResponse{
Peers: [][]byte{
{
1,
},
{
2,
},
},
StartTime: 0,
EndTime: 100,
Entries: []*ForwardingAbilityEntry{
{
PackedIdx: (0 << 16) | 1,
EffectiveUptimeS: 42,
ForwardedSat: 7,
},
},
UpButIdleBitmask: mask,
}
decoded, err := DecodeForwardingAbility(resp)
require.NoError(t, err)
require.Equal(
t, ForwardingAbility{42, 7},
decoded[hex.EncodeToString([]byte{1})][hex.EncodeToString(
[]byte{2},
)],
)
}
// TestForwardingAbilityDecodeBadIndex verifies that a packed index referencing
// a peer beyond the decoded peer list is rejected rather than silently mapped.
func TestForwardingAbilityDecodeBadIndex(t *testing.T) {
resp := &ForwardingAbilityResponse{
Peers: [][]byte{
{
1,
2,
3,
},
},
Entries: []*ForwardingAbilityEntry{
{
// Out index 1 is out of bounds for a single
// peer.
PackedIdx: (0 << 16) | 1,
EffectiveUptimeS: 3600,
ForwardedSat: 1000,
},
},
}
_, err := DecodeForwardingAbility(resp)
require.ErrorContains(t, err, "peer index out of bounds")
}
// TestForwardingAbilityDecodeBadBitmaskLen verifies that a bitmask whose length
// does not match the n*n pairs of the peer set is rejected.
func TestForwardingAbilityDecodeBadBitmaskLen(t *testing.T) {
resp := &ForwardingAbilityResponse{
// Two peers expect a 1-byte bitmask; supply two bytes.
Peers: [][]byte{
{
1,
},
{
2,
},
},
UpButIdleBitmask: []byte{
0x00,
0x00,
},
}
_, err := DecodeForwardingAbility(resp)
require.ErrorContains(t, err, "bitmask length")
}
// TestForwardingAbilityEncodePeerCap verifies that a peer set too large to
// address with packed_idx is rejected loudly instead of overflowing an index
// into the wrong peer pair.
func TestForwardingAbilityEncodePeerCap(t *testing.T) {
outMap := make(map[string]ForwardingAbility)
for i := 1; i <= maxPackedPeers+1; i++ {
// Use forwarded volume so inclusion is threshold-independent.
outMap[fwdKey(i)] = ForwardingAbility{ForwardedSat: 1}
}
abilities := map[string]map[string]ForwardingAbility{
fwdKey(0): outMap,
}
_, err := EncodeForwardingAbility(abilities, 0, 1, 0.5)
require.ErrorContains(t, err, "exceeds")
}
// TestForwardingAbilityEncodeNormalizesCase verifies that a peer appearing in
// mixed hex case collapses to a single index rather than producing a duplicate
// peer entry.
func TestForwardingAbilityEncodeNormalizesCase(t *testing.T) {
// Use a key with hex letters so its upper- and lower-case forms are
// genuinely distinct map keys.
peer := fwdKey(0xabcdef)
abilities := map[string]map[string]ForwardingAbility{
strings.ToUpper(peer): {
fwdKey(2): {
ForwardedSat: 20,
},
},
peer: {
fwdKey(3): {
ForwardedSat: 40,
},
},
}
resp, err := EncodeForwardingAbility(abilities, 0, 1, 0.5)
require.NoError(t, err)
// The upper- and lower-case forms of the shared peer must dedup to one
// index, leaving exactly three distinct peers.
require.Len(t, resp.Peers, 3)
decoded, err := DecodeForwardingAbility(resp)
require.NoError(t, err)
require.Equal(
t, ForwardingAbility{
ForwardedSat: 20,
},
decoded[peer][fwdKey(2)],
)
require.Equal(
t, ForwardingAbility{
ForwardedSat: 40,
},
decoded[peer][fwdKey(3)],
)
}
// TestForwardingAbilityEncodeRejectsCaseCollision verifies that two input keys
// that differ only by hex case but address the same peer pair are rejected
// rather than silently collapsing onto one packed index and dropping a fact.
func TestForwardingAbilityEncodeRejectsCaseCollision(t *testing.T) {
inPeer := fwdKey(0xabcdef)
outPeer := fwdKey(2)
// Both in-peer spellings normalize to the same index and share the same
// out-peer, so they collide on packed_idx.
abilities := map[string]map[string]ForwardingAbility{
strings.ToUpper(inPeer): {
outPeer: {
ForwardedSat: 10,
},
},
inPeer: {
outPeer: {
ForwardedSat: 20,
},
},
}
_, err := EncodeForwardingAbility(abilities, 0, 1, 0.5)
require.Error(t, err)
}
// TestForwardingAbilityCodecRoundTripHighIndices round-trips a large peer set
// so that packed indices exceed a single byte and exercise the high bits of
// each 16-bit direction field, and the up-but-idle bitmask spans many bytes. It
// guards index packing and bitmask addressing against regressions that only
// surface beyond the small indices the other round-trip cases use.
func TestForwardingAbilityCodecRoundTripHighIndices(t *testing.T) {
const (
numPeers = 300
startTime, endTime = int64(0), int64(100)
threshold = 0.5
)
// Build a cycle so every peer appears and takes a stable index equal to
// its fwdKey ordinal. Even edges forward (kept as exact entries); odd
// edges are up but idle at >= threshold (collapsed to a bitmask bit,
// decoded back at the full window uptime).
abilities := make(map[string]map[string]ForwardingAbility, numPeers)
want := make(map[string]ForwardingAbility, numPeers)
for i := range numPeers {
in, out := fwdKey(i), fwdKey((i+1)%numPeers)
var enc, dec ForwardingAbility
if i%2 == 0 {
// Add pair that forwarded.
enc = ForwardingAbility{
EffectiveUptimeS: 70,
ForwardedSat: int64(i + 1),
}
dec = enc
} else {
// Add up, but idle pair.
enc = ForwardingAbility{EffectiveUptimeS: 60}
dec = ForwardingAbility{
EffectiveUptimeS: endTime - startTime,
}
}
abilities[in] = map[string]ForwardingAbility{out: enc}
want[in+"->"+out] = dec
}
resp, err := EncodeForwardingAbility(
abilities, startTime, endTime, threshold,
)
require.NoError(t, err)
require.Len(t, resp.Peers, numPeers)
// With 300 peers the indices exceed one byte, so at least one packed
// index must use the high bits of its 16-bit field.
var sawHighIdx bool
for _, e := range resp.Entries {
if e.PackedIdx>>16 > 0xff || e.PackedIdx&0xffff > 0xff {
sawHighIdx = true
break
}
}
require.True(t, sawHighIdx, "expected an index beyond one byte")
decoded, err := DecodeForwardingAbility(resp)
require.NoError(t, err)
got := make(map[string]ForwardingAbility)
for in, outMap := range decoded {
for out, ability := range outMap {
got[in+"->"+out] = ability
}
}
require.Equal(t, want, got)
}
// TestForwardingAbilityDecodeNil verifies that decoding a nil response yields
// an empty map rather than panicking.
func TestForwardingAbilityDecodeNil(t *testing.T) {
decoded, err := DecodeForwardingAbility(nil)
require.NoError(t, err)
require.Empty(t, decoded)
}
// TestForwardingAbilityDecodeIgnoresPaddingBit verifies that a bit set in the
// padding region beyond the n*n pairs of the final byte is ignored rather than
// decoded into a bogus pair.
func TestForwardingAbilityDecodeIgnoresPaddingBit(t *testing.T) {
// Two peers => 2*2 = 4 valid bits in a 1-byte mask; bits 4..7 are
// padding. Set padding bit 5 and assert nothing decodes from it.
mask := make([]byte, 1)
setBit(mask, 5)
resp := &ForwardingAbilityResponse{
Peers: [][]byte{{1}, {2}},
StartTime: 0,
EndTime: 100,
UpButIdleBitmask: mask,
}
decoded, err := DecodeForwardingAbility(resp)
require.NoError(t, err)
require.Empty(t, decoded)
}

View file

@ -1,23 +0,0 @@
module github.com/lightninglabs/faraday/frdrpc
require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0
github.com/stretchr/testify v1.10.0
google.golang.org/grpc v1.65.0
google.golang.org/protobuf v1.34.2
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
go 1.25.10

View file

@ -1,36 +0,0 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -10,11 +10,6 @@ import (
"github.com/lightninglabs/faraday/frdrpc"
)
// fiatBackendBitfinex is the rpc enum value for BITFINEX.
// TODO: Replace with frdrpc.FiatBackend_BITFINEX once the frdrpc module is
// tagged and the dependency is bumped.
const fiatBackendBitfinex = frdrpc.FiatBackend(5)
func priceCfgFromRPC(rpcBackend frdrpc.FiatBackend,
rpcGranularity frdrpc.Granularity, disable bool, start, end time.Time,
prices []*frdrpc.BitcoinPrice) (*fiat.PriceSourceConfig, error) {
@ -39,8 +34,7 @@ func priceCfgFromRPC(rpcBackend frdrpc.FiatBackend,
// Get additional values for backends that require additional
// information.
switch backend {
case fiat.CoinCapPriceBackend, fiat.CoinbasePriceBackend,
fiat.BitfinexPriceBackend:
case fiat.CoinCapPriceBackend:
granularity, err = granularityFromRPC(
rpcGranularity, disable, end.Sub(start),
)
@ -131,9 +125,6 @@ func fiatBackendFromRPC(backend frdrpc.FiatBackend) (fiat.PriceBackend, error) {
case frdrpc.FiatBackend_COINGECKO:
return fiat.CoinGeckoPriceBackend, nil
case fiatBackendBitfinex:
return fiat.BitfinexPriceBackend, nil
default:
return fiat.UnknownPriceBackend,
fmt.Errorf("unknown fiat backend: %v", backend)

View file

@ -1,94 +0,0 @@
package frdrpcserver
import (
"testing"
"time"
"github.com/lightninglabs/faraday/fiat"
"github.com/lightninglabs/faraday/frdrpc"
"github.com/stretchr/testify/require"
)
// TestFiatBackendFromRPC checks mapping from rpc enum values to fiat backend
// implementations.
func TestFiatBackendFromRPC(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in frdrpc.FiatBackend
expected fiat.PriceBackend
expectErr bool
}{
{
name: "unknown",
in: frdrpc.FiatBackend_UNKNOWN_FIATBACKEND,
expected: fiat.UnknownPriceBackend,
},
{
name: "coincap",
in: frdrpc.FiatBackend_COINCAP,
expected: fiat.CoinCapPriceBackend,
},
{
name: "coindesk",
in: frdrpc.FiatBackend_COINDESK,
expected: fiat.CoinDeskPriceBackend,
},
{
name: "custom",
in: frdrpc.FiatBackend_CUSTOM,
expected: fiat.CustomPriceBackend,
},
{
name: "coingecko",
in: frdrpc.FiatBackend_COINGECKO,
expected: fiat.CoinGeckoPriceBackend,
},
{
name: "bitfinex",
in: fiatBackendBitfinex,
expected: fiat.BitfinexPriceBackend,
},
{
name: "invalid enum",
in: frdrpc.FiatBackend(999),
expectErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
backend, err := fiatBackendFromRPC(test.in)
if test.expectErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, test.expected, backend)
}
})
}
}
// TestPriceCfgFromRPCBitfinexGranularity verifies that priceCfgFromRPC sets
// a granularity for bitfinex so that the resulting config passes validation.
func TestPriceCfgFromRPCBitfinexGranularity(t *testing.T) {
t.Parallel()
start := time.Unix(1711929600, 0).UTC()
end := start.Add(2 * time.Hour)
cfg, err := priceCfgFromRPC(
fiatBackendBitfinex, frdrpc.Granularity_HOUR, false,
start, end, nil,
)
require.NoError(t, err)
require.NotNil(t, cfg)
require.Equal(t, fiat.BitfinexPriceBackend, cfg.Backend)
require.NotNil(t, cfg.Granularity)
require.Equal(t, fiat.GranularityHour, *cfg.Granularity)
// Validate that this config can be used to construct a price source.
_, err = fiat.NewPriceSource(cfg)
require.NoError(t, err)
}

View file

@ -1,169 +0,0 @@
package frdrpcserver
import (
"context"
"log/slog"
"math"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/faraday/frdrpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// defaultLiquidityFloorSat is the liquidity floor applied when the request
// leaves liquidity_floor_sat unset. It approximates the smallest amount a
// rebalancer would still move, below which a pair is not economically
// forwardable.
const defaultLiquidityFloorSat = 50_000
// defaultUptimeThreshold is the uptime fraction applied when the request leaves
// uptime_threshold unset. A pair that was up at least this fraction of the
// window but did not forward is reported as a single bit rather than a full
// entry. It is high so that only reliably available pairs are flagged, keeping
// the response sparse and the node-down guard meaningful.
const defaultUptimeThreshold = 0.9
// ForwardingAbility returns the raw effective-uptime and forwarded-volume facts
// for each peer pair over the requested window. An unset end_time defaults to
// the current time and an unset liquidity_floor_sat to
// defaultLiquidityFloorSat.
func (s *RPCServer) ForwardingAbility(ctx context.Context,
req *frdrpc.ForwardingAbilityRequest) (
*frdrpc.ForwardingAbilityResponse, error) {
log.DebugS(
ctx, "Handling ForwardingAbility request",
slog.Uint64("start_time", req.StartTime),
slog.Uint64("end_time", req.EndTime),
slog.Uint64("liquidity_floor_sat", req.LiquidityFloorSat),
)
// time.Unix takes an int64, so reject any request value that would
// overflow when its uint64 seconds are narrowed below.
if req.StartTime > math.MaxInt64 {
return nil, status.Error(
codes.InvalidArgument,
"start_time exceeds maximum allowed value",
)
}
if req.EndTime > math.MaxInt64 {
return nil, status.Error(
codes.InvalidArgument,
"end_time exceeds maximum allowed value",
)
}
startTime := time.Unix(int64(req.StartTime), 0)
endTime := time.Now()
if req.EndTime != 0 {
endTime = time.Unix(int64(req.EndTime), 0)
}
if startTime.After(endTime) {
return nil, status.Error(
codes.InvalidArgument,
"start_time must be less than or equal to end_time",
)
}
if s.cfg.ForwardingAnalyzer == nil {
return nil, status.Error(
codes.Unavailable,
"forwarding analyzer is not configured",
)
}
liquidityFloor := req.LiquidityFloorSat
if liquidityFloor == 0 {
liquidityFloor = defaultLiquidityFloorSat
}
uptimeThreshold := req.UptimeThreshold
if uptimeThreshold == 0 {
uptimeThreshold = defaultUptimeThreshold
}
// Reject NaN explicitly: NaN comparisons are always false, so a bare
// range check would let it slip through.
if math.IsNaN(uptimeThreshold) || uptimeThreshold < 0 ||
uptimeThreshold > 1 {
return nil, status.Error(
codes.InvalidArgument,
"uptime_threshold must be in [0, 1]",
)
}
abilities, err := s.cfg.ForwardingAnalyzer.EffectiveUptime(
ctx, startTime, endTime, btcutil.Amount(liquidityFloor),
)
if err != nil {
log.ErrorS(
ctx, "EffectiveUptime failed", err,
slog.Time("start_time", startTime),
slog.Time("end_time", endTime),
slog.Uint64("liquidity_floor_sat", liquidityFloor),
)
return nil, status.Errorf(codes.Internal, "failed to "+
"calculate effective uptime: %v", err)
}
// Convert the flat map into the nested map the codec expects, carrying
// the raw facts through unchanged. EffectiveUptime is truncated to
// whole seconds to match the second-granularity wire field. A pair with
// only sub-second qualifying uptime therefore reports zero uptime while
// still carrying its forwarded volume.
nested := make(map[string]map[string]frdrpc.ForwardingAbility)
for pair, ability := range abilities {
if _, ok := nested[pair.PeerIn]; !ok {
nested[pair.PeerIn] =
make(map[string]frdrpc.ForwardingAbility)
}
nested[pair.PeerIn][pair.PeerOut] = frdrpc.ForwardingAbility{
EffectiveUptimeS: int64(
ability.EffectiveUptime.Seconds(),
),
ForwardedSat: int64(ability.ForwardedAmount),
}
}
// Guard against returning data when the node itself was down for the
// window. If no pair held at least the threshold fraction of uptime,
// the response carries no signal and lowering the threshold to surface
// something would only inflate it, so fail loudly instead.
minUptimeS := frdrpc.MinQualifyingUptime(
uptimeThreshold, endTime.Unix()-startTime.Unix(),
)
var qualifying int
for _, outMap := range nested {
for _, ability := range outMap {
if ability.EffectiveUptimeS >= minUptimeS {
qualifying++
}
}
}
if qualifying == 0 {
return nil, status.Error(codes.FailedPrecondition, "no peer "+
"pair met the uptime threshold over the window; the "+
"node may have been offline")
}
resp, err := frdrpc.EncodeForwardingAbility(
nested, startTime.Unix(), endTime.Unix(), uptimeThreshold,
)
if err != nil {
log.ErrorS(
ctx, "EncodeForwardingAbility failed", err,
slog.Int("pairs", len(abilities)),
)
return nil, status.Errorf(codes.Internal, "failed to encode "+
"forwarding ability: %v", err)
}
return resp, nil
}

View file

@ -1,299 +0,0 @@
package frdrpcserver
import (
"context"
"errors"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/faraday/chanevents"
"github.com/lightninglabs/faraday/frdrpc"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type mockForwardingAnalyzer struct {
effectiveUptimeFunc func(ctx context.Context, startTime, endTime time.Time,
liquidityFloor btcutil.Amount) (
map[chanevents.PeerPair]chanevents.ForwardingAbility, error)
}
func (m *mockForwardingAnalyzer) EffectiveUptime(ctx context.Context, startTime,
endTime time.Time, liquidityFloor btcutil.Amount) (
map[chanevents.PeerPair]chanevents.ForwardingAbility, error) {
return m.effectiveUptimeFunc(ctx, startTime, endTime, liquidityFloor)
}
// TestForwardingAbility tests the ForwardingAbility RPC method, covering both
// successful and error cases.
func TestForwardingAbility(t *testing.T) {
const (
peerIn = "02aaaabbbbcccc0000000000000000000000000000000000000000000000000001"
peerOut = "02aaaabbbbcccc0000000000000000000000000000000000000000000000000002"
)
// analyzerResult is the canned analyzer return for a case. A nil
// analyzerResult means the case leaves ForwardingAnalyzer unconfigured.
type analyzerResult func() (
map[chanevents.PeerPair]chanevents.ForwardingAbility, error)
tests := []struct {
name string
analyzer analyzerResult
req *frdrpc.ForwardingAbilityRequest
// wantCode is the expected gRPC status; codes.OK denotes
// success.
wantCode codes.Code
// check runs on success with the response and the floor the
// handler resolved and passed to the analyzer.
check func(t *testing.T, resp *frdrpc.ForwardingAbilityResponse,
gotFloor btcutil.Amount)
}{
{
name: "encodes analyzer facts",
analyzer: func() (
map[chanevents.PeerPair]chanevents.ForwardingAbility,
error) {
return map[chanevents.PeerPair]chanevents.ForwardingAbility{
{
PeerIn: peerIn,
PeerOut: peerOut,
}: {
EffectiveUptime: 90 * time.Second,
ForwardedAmount: 550,
},
}, nil
},
req: &frdrpc.ForwardingAbilityRequest{
StartTime: 100,
EndTime: 200,
LiquidityFloorSat: 1000,
},
wantCode: codes.OK,
check: func(t *testing.T,
resp *frdrpc.ForwardingAbilityResponse,
gotFloor btcutil.Amount) {
// The explicit floor passes straight through.
require.Equal(t, btcutil.Amount(1000), gotFloor)
require.Len(t, resp.Peers, 2)
require.Len(t, resp.Entries, 1)
require.EqualValues(t, 100, resp.StartTime)
require.EqualValues(t, 200, resp.EndTime)
require.EqualValues(
t, 90, resp.Entries[0].EffectiveUptimeS,
)
require.EqualValues(
t, 550, resp.Entries[0].ForwardedSat,
)
// An unset threshold echoes the server default,
// and a forwarded pair leaves the bitmask empty.
require.Equal(
t, defaultUptimeThreshold,
resp.UptimeThreshold,
)
require.Empty(t, resp.UpButIdleBitmask)
},
},
{
name: "unset floor uses server default",
analyzer: func() (
map[chanevents.PeerPair]chanevents.ForwardingAbility,
error) {
// Return a fully-up pair so the node-down guard
// passes and the default floor can be observed.
return map[chanevents.PeerPair]chanevents.ForwardingAbility{
{PeerIn: peerIn, PeerOut: peerOut}: {
EffectiveUptime: 100 * time.Second,
},
}, nil
},
req: &frdrpc.ForwardingAbilityRequest{
StartTime: 100,
EndTime: 200,
},
wantCode: codes.OK,
check: func(t *testing.T,
_ *frdrpc.ForwardingAbilityResponse,
gotFloor btcutil.Amount) {
require.Equal(
t, btcutil.Amount(
defaultLiquidityFloorSat,
), gotFloor,
)
},
},
{
name: "node down trips guard",
analyzer: func() (
map[chanevents.PeerPair]chanevents.ForwardingAbility,
error) {
// A single pair, up well below the default 0.9
// threshold over the 100s window and with no
// forwards, leaves nothing that clears it.
return map[chanevents.PeerPair]chanevents.ForwardingAbility{
{PeerIn: peerIn, PeerOut: peerOut}: {
EffectiveUptime: 10 * time.Second,
},
}, nil
},
req: &frdrpc.ForwardingAbilityRequest{
StartTime: 100,
EndTime: 200,
},
wantCode: codes.FailedPrecondition,
},
{
name: "low-uptime forward does not rescue guard",
analyzer: func() (
map[chanevents.PeerPair]chanevents.ForwardingAbility,
error) {
// Forwarded volume at sub-threshold uptime must
// not satisfy the guard.
return map[chanevents.PeerPair]chanevents.ForwardingAbility{
{PeerIn: peerIn, PeerOut: peerOut}: {
EffectiveUptime: 10 * time.Second,
ForwardedAmount: 999,
},
}, nil
},
req: &frdrpc.ForwardingAbilityRequest{
StartTime: 100,
EndTime: 200,
},
wantCode: codes.FailedPrecondition,
},
{
name: "explicit threshold flags up-but-idle pair",
analyzer: func() (
map[chanevents.PeerPair]chanevents.ForwardingAbility,
error) {
return map[chanevents.PeerPair]chanevents.ForwardingAbility{
{PeerIn: peerIn, PeerOut: peerOut}: {
EffectiveUptime: 60 * time.Second,
},
}, nil
},
req: &frdrpc.ForwardingAbilityRequest{
StartTime: 100,
EndTime: 200,
UptimeThreshold: 0.5,
},
wantCode: codes.OK,
check: func(t *testing.T,
resp *frdrpc.ForwardingAbilityResponse,
_ btcutil.Amount) {
// Up 60s of a 100s window at a 0.5 threshold:
// idle, so a bit and no entry.
require.Equal(t, 0.5, resp.UptimeThreshold)
require.Empty(t, resp.Entries)
require.NotEmpty(t, resp.UpButIdleBitmask)
},
},
{
name: "out of range threshold is rejected",
analyzer: func() (
map[chanevents.PeerPair]chanevents.ForwardingAbility,
error) {
return nil, nil
},
req: &frdrpc.ForwardingAbilityRequest{
StartTime: 100,
EndTime: 200,
UptimeThreshold: 1.5,
},
wantCode: codes.InvalidArgument,
},
{
name: "start after end is rejected",
req: &frdrpc.ForwardingAbilityRequest{
StartTime: 200,
EndTime: 100,
},
wantCode: codes.InvalidArgument,
},
{
name: "missing analyzer is unavailable",
req: &frdrpc.ForwardingAbilityRequest{
StartTime: 100,
EndTime: 200,
LiquidityFloorSat: 1000,
},
wantCode: codes.Unavailable,
},
{
name: "analyzer error is internal",
analyzer: func() (
map[chanevents.PeerPair]chanevents.ForwardingAbility,
error) {
return nil, errors.New("db lookup failed")
},
req: &frdrpc.ForwardingAbilityRequest{
StartTime: 100,
EndTime: 200,
LiquidityFloorSat: 1000,
},
wantCode: codes.Internal,
},
}
for _, tc := range tests {
t.Run(
tc.name,
func(t *testing.T) {
var gotFloor btcutil.Amount
cfg := &Config{}
if tc.analyzer != nil {
cfg.ForwardingAnalyzer = &mockForwardingAnalyzer{
effectiveUptimeFunc: func(
_ context.Context, _,
_ time.Time,
floor btcutil.Amount) (
map[chanevents.PeerPair]chanevents.ForwardingAbility,
error) {
gotFloor = floor
return tc.analyzer()
},
}
}
server := NewRPCServer(cfg)
resp, err := server.ForwardingAbility(
t.Context(), tc.req,
)
if tc.wantCode != codes.OK {
st, ok := status.FromError(err)
require.True(t, ok)
require.Equal(t, tc.wantCode, st.Code())
return
}
require.NoError(t, err)
require.NotNil(t, resp)
if tc.check != nil {
tc.check(t, resp, gotFloor)
}
},
)
}
}

View file

@ -1,148 +0,0 @@
package frdrpcserver
import (
"context"
"errors"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/faraday/chanevents"
"github.com/lightninglabs/faraday/frdrpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// maxChannelEventsLimit is the hard cap the server will return in a single
// GetChannelEvents response, regardless of what the client asks for. It also
// serves as the default when the request leaves max_events at zero.
const maxChannelEventsLimit = 10000
// GetChannelEvents serves a paginated read of a channel's events. A zero
// end_time defaults to the server's current time, max_events is clamped to
// the server's hard cap, and an unknown channel point yields NotFound.
func (s *RPCServer) GetChannelEvents(ctx context.Context,
req *frdrpc.ChannelEventsRequest) (*frdrpc.ChannelEventsResponse,
error) {
log.Debugf("[GetChannelEvents]: chan_point=%s, start_time=%d, "+
"end_time=%d, max_events=%d, last_id=%d", req.ChanPoint,
req.StartTime, req.EndTime, req.MaxEvents, req.LastId)
if req.ChanPoint == "" {
return nil, status.Error(
codes.InvalidArgument, "channel point required",
)
}
if req.StartTime < 0 || req.EndTime < 0 {
return nil, status.Error(
codes.InvalidArgument,
"start_time and end_time must be >= 0",
)
}
startTime := time.Unix(req.StartTime, 0)
endTime := time.Now()
if req.EndTime != 0 {
endTime = time.Unix(req.EndTime, 0)
}
if startTime.After(endTime) {
return nil, status.Error(
codes.InvalidArgument, "start_time must be <= end_time",
)
}
if req.LastId < 0 {
return nil, status.Error(
codes.InvalidArgument, "last_id must be >= 0",
)
}
channel, err := s.cfg.ChanEvents.GetChannel(ctx, req.ChanPoint)
if err != nil {
if errors.Is(err, chanevents.ErrUnknownChannel) {
return nil, status.Errorf(codes.NotFound, "channel %s "+
"not found", req.ChanPoint)
}
log.Errorf("GetChannel(%s): %v", req.ChanPoint, err)
return nil, status.Error(
codes.Internal, "failed to look up channel",
)
}
limit := int32(maxChannelEventsLimit)
if req.MaxEvents != 0 && req.MaxEvents < maxChannelEventsLimit {
limit = int32(req.MaxEvents)
}
events, err := s.cfg.ChanEvents.GetChannelEvents(
ctx, channel.ID, req.LastId, startTime, endTime, limit,
)
if err != nil {
log.Errorf("GetChannelEvents(%s): %v", req.ChanPoint, err)
return nil, status.Error(
codes.Internal, "failed to query channel events",
)
}
resp := &frdrpc.ChannelEventsResponse{
Events: marshalRPCChannelEvents(events),
HasMore: int32(len(events)) == limit,
}
if n := len(events); n > 0 {
resp.LastId = events[n-1].ID
}
return resp, nil
}
// marshalRPCChannelEvents converts a slice of chanevents.ChannelEvent into a
// slice of frdrpc.ChannelEvent.
func marshalRPCChannelEvents(
events []*chanevents.ChannelEvent) []*frdrpc.ChannelEvent {
rpcEvents := make([]*frdrpc.ChannelEvent, len(events))
for i, event := range events {
rpcEvent := &frdrpc.ChannelEvent{
Id: event.ID,
Timestamp: event.Timestamp.Unix(),
EventType: rpcEventType(event.EventType),
}
event.LocalBalance.WhenSome(
func(b btcutil.Amount) {
rpcEvent.LocalBalance = uint64(b)
},
)
event.RemoteBalance.WhenSome(
func(b btcutil.Amount) {
rpcEvent.RemoteBalance = uint64(b)
},
)
rpcEvents[i] = rpcEvent
}
return rpcEvents
}
// rpcEventType maps a stored chanevents.EventType to its proto counterpart.
func rpcEventType(e chanevents.EventType) frdrpc.ChannelEventType {
switch e {
case chanevents.EventTypeOnline:
return frdrpc.ChannelEventType_CHAN_EVENT_ONLINE
case chanevents.EventTypeOffline:
return frdrpc.ChannelEventType_CHAN_EVENT_OFFLINE
case chanevents.EventTypeUpdate:
return frdrpc.ChannelEventType_CHAN_EVENT_UPDATE
default:
return frdrpc.ChannelEventType_CHAN_EVENT_UNKNOWN
}
}

View file

@ -1,7 +1,7 @@
package frdrpcserver
import (
"github.com/btcsuite/btclog/v2"
"github.com/btcsuite/btclog"
"github.com/lightningnetwork/lnd/build"
)

View file

@ -1,4 +1,4 @@
package faraday
package frdrpcserver
import (
"time"

View file

@ -7,23 +7,14 @@ import (
"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")
@ -93,21 +84,8 @@ func parseNodeAuditRequest(ctx context.Context, cfg *Config,
"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,
ctx, cfg.Lnd, start, end, req.DisableFiat,
feeLookup, priceSourceCfg, onChainCategories,
)
@ -300,129 +278,3 @@ func rpcEntryType(t accounting.EntryType) (frdrpc.EntryType, error) {
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
}

View file

@ -33,12 +33,4 @@ var RequiredPermissions = map[string][]bakery.Op{
Entity: "report",
Action: "read",
}},
"/frdrpc.FaradayServer/GetChannelEvents": {{
Entity: "events",
Action: "read",
}},
"/frdrpc.FaradayServer/ForwardingAbility": {{
Entity: "insights",
Action: "read",
}},
}

View file

@ -11,22 +11,54 @@ package frdrpcserver
import (
"context"
"crypto/tls"
"errors"
"time"
"fmt"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
"github.com/btcsuite/btcd/btcutil"
proxy "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/lightninglabs/faraday/accounting"
"github.com/lightninglabs/faraday/chain"
"github.com/lightninglabs/faraday/chanevents"
"github.com/lightninglabs/faraday/fiat"
"github.com/lightninglabs/faraday/frdrpc"
"github.com/lightninglabs/faraday/frdrpcserver/perms"
"github.com/lightninglabs/faraday/recommend"
"github.com/lightninglabs/faraday/resolutions"
"github.com/lightninglabs/faraday/revenue"
"github.com/lightninglabs/lndclient"
"github.com/lightningnetwork/lnd/kvdb"
"github.com/lightningnetwork/lnd/lncfg"
"github.com/lightningnetwork/lnd/macaroons"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/protobuf/encoding/protojson"
"gopkg.in/macaroon-bakery.v2/bakery"
)
var (
// customMarshalerOption is the configuratino we use for the JSON
// marshaler of the REST proxy. The default JSON marshaler only sets
// OrigName to true, which instructs it to use the same field names as
// specified in the proto file and not switch to camel case. What we
// also want is that the marshaler prints all values, even if they are
// falsey.
customMarshalerOption = proxy.WithMarshalerOption(
proxy.MIMEWildcard, &proxy.JSONPb{
MarshalOptions: protojson.MarshalOptions{
UseProtoNames: true,
EmitUnpopulated: true,
},
},
)
// maxMsgRecvSize is the largest message our REST proxy will receive. We
// set this to 200MiB atm.
maxMsgRecvSize = grpc.MaxCallRecvMsgSize(1 * 1024 * 1024 * 200)
// maxInvoiceQueries is the maximum number of invoices we request from
// lnd at a time.
maxInvoiceQueries = 1000
@ -41,6 +73,10 @@ var (
// because forwards have less data.
maxForwardQueries = 2000
// errServerAlreadyStarted is the error that is returned if the server
// is requested to start while it's already been started.
errServerAlreadyStarted = fmt.Errorf("server can only be started once")
// ErrBitcoinNodeRequired is required when an endpoint which requires
// a bitcoin node backend is hit and we are not connected to one.
ErrBitcoinNodeRequired = errors.New("bitcoin node required")
@ -48,20 +84,35 @@ var (
// RPCServer implements the faraday service, serving requests over grpc.
type RPCServer struct {
// To be used atomically.
started int32
// To be used atomically.
stopped int32
// Required by the grpc-gateway/v2 library for forward compatibility.
// Must be after the atomically used variables to not break struct
// alignment.
frdrpc.UnimplementedFaradayServerServer
// cfg contains closures and settings required for operation.
cfg *Config
}
// ForwardingAnalyzer is the seam the RPC server uses to compute per-peer-pair
// forwarding facts. It is an interface so the handler can be exercised against
// a mock analyzer in tests.
type ForwardingAnalyzer interface {
EffectiveUptime(ctx context.Context, startTime, endTime time.Time,
liquidityFloor btcutil.Amount) (
map[chanevents.PeerPair]chanevents.ForwardingAbility, error)
// grpcServer is the main gRPC RPCServer that this RPC server will
// register itself with and accept client requests from.
grpcServer *grpc.Server
// rpcListener is the listener to use when starting the gRPC server.
rpcListener net.Listener
// restServer is the REST proxy server.
restServer *http.Server
macaroonService *lndclient.MacaroonService
macaroonDB kvdb.Backend
restCancel func()
wg sync.WaitGroup
}
// Config provides closures and settings required to run the rpc server.
@ -69,27 +120,322 @@ type Config struct {
// Lnd is a client which can be used to query lnd.
Lnd lndclient.LndServices
// ChanEvents is a database of channel events.
ChanEvents *chanevents.Store
// RPCListen is the address:port that the gRPC server should listen on.
RPCListen string
// ForwardingAnalyzer computes forwarding-ability facts for the
// ForwardingAbility RPC. When nil, that endpoint returns
// codes.Unavailable.
ForwardingAnalyzer ForwardingAnalyzer
// RESTListen is the address:port that the REST server should listen on.
RESTListen string
// BitcoinClient is an optional client which can be used to query
// on-chain data from a connected bitcoin node. If nil, faraday will
// not be able to serve endpoints which require on-chain data.
// CORSOrigin specifies the CORS header that should be set on REST
// responses. No header is added if the value is empty.
CORSOrigin string
// BitcoinClient is set if the client opted to connect to a bitcoin
// backend, if not, it will be nil.
BitcoinClient chain.BitcoinClient
// TLSServerConfig is the configuration to serve a secure connection
// over TLS.
TLSServerConfig *tls.Config
// RestClientConfig is the client configuration to connect to a TLS
// server started with the TLS config above. This is used for the REST
// proxy that connects internally to the gRPC server and therefore is a
// TLS client.
RestClientConfig *credentials.TransportCredentials
// FaradayDir is the main directory faraday uses. The macaroon database
// will be created there.
FaradayDir string
// MacaroonPath is the full path to the default faraday macaroon file
// that is created automatically. This path normally is within
// FaradayDir unless otherwise specified by the user.
MacaroonPath string
}
// NewRPCServer returns a new RPCServer backed by the given config.
// NewRPCServer returns a server which will listen for rpc requests on the
// rpc listen address provided. Note that the server returned is not running,
// and should be started using Start().
func NewRPCServer(cfg *Config) *RPCServer {
return &RPCServer{
cfg: cfg,
}
}
// Start starts the listener and server.
func (s *RPCServer) Start() error {
if atomic.AddInt32(&s.started, 1) != 1 {
return errServerAlreadyStarted
}
// Depending on how far we got in initializing the server, we might need
// to clean up certain services that were already started. Keep track of
// them with this map of service name to shutdown function.
shutdownFuncs := make(map[string]func() error)
defer func() {
for serviceName, shutdownFn := range shutdownFuncs {
if err := shutdownFn(); err != nil {
log.Errorf("Error shutting down %s service: %v",
serviceName, err)
}
}
}()
// Set up the macaroon service.
rks, db, err := lndclient.NewBoltMacaroonStore(
s.cfg.FaradayDir, lncfg.MacaroonDBName, macDatabaseOpenTimeout,
)
if err != nil {
return err
}
shutdownFuncs["macaroondb"] = db.Close
s.macaroonDB = db
s.macaroonService, err = lndclient.NewMacaroonService(
&lndclient.MacaroonServiceConfig{
RootKeyStore: rks,
MacaroonLocation: faradayMacaroonLocation,
MacaroonPath: s.cfg.MacaroonPath,
Checkers: []macaroons.Checker{
macaroons.IPLockChecker,
},
RequiredPerms: perms.RequiredPermissions,
DBPassword: macDbDefaultPw,
LndClient: &s.cfg.Lnd,
EphemeralKey: lndclient.SharedKeyNUMS,
KeyLocator: lndclient.SharedKeyLocator,
},
)
if err != nil {
return fmt.Errorf("error creating macroon service: %v", err)
}
// Start the macaroon service and let it create its default macaroon in
// case it doesn't exist yet.
if err := s.macaroonService.Start(); err != nil {
return fmt.Errorf("error starting macaroon service: %v", err)
}
shutdownFuncs["macaroon"] = s.macaroonService.Stop
// First we add the security interceptor to our gRPC server options that
// checks the macaroons for validity.
unaryInterceptor, streamInterceptor, err := s.macaroonService.Interceptors()
if err != nil {
return fmt.Errorf("error with macaroon interceptor: %v", err)
}
// Add our TLS configuration and then create our server instance. It's
// important that we let gRPC create the TLS listener and we don't just
// use tls.NewListener(). Otherwise we run into the ALPN error with non-
// golang clients.
tlsCredentials := credentials.NewTLS(s.cfg.TLSServerConfig)
s.grpcServer = grpc.NewServer(
grpc.UnaryInterceptor(unaryInterceptor),
grpc.StreamInterceptor(streamInterceptor),
grpc.Creds(tlsCredentials),
)
// Start the gRPC RPCServer listening for HTTP/2 connections.
log.Info("Starting gRPC listener")
s.rpcListener, err = net.Listen("tcp", s.cfg.RPCListen)
if err != nil {
return fmt.Errorf("RPC RPCServer unable to listen on %v",
s.cfg.RPCListen)
}
shutdownFuncs["gRPC listener"] = s.rpcListener.Close
log.Infof("gRPC server listening on %s", s.rpcListener.Addr())
frdrpc.RegisterFaradayServerServer(s.grpcServer, s)
// We'll also create and start an accompanying proxy to serve clients
// through REST. An empty address indicates REST is disabled.
if s.cfg.RESTListen != "" {
log.Infof("Starting REST proxy listener ")
restListener, err := net.Listen("tcp", s.cfg.RESTListen)
if err != nil {
return fmt.Errorf("REST server unable to listen on "+
"%v: %v", s.cfg.RESTListen, err)
}
restListener = tls.NewListener(
restListener, s.cfg.TLSServerConfig,
)
shutdownFuncs["REST listener"] = restListener.Close
log.Infof("REST server listening on %s", restListener.Addr())
// We'll dial into the local gRPC server so we need to set some
// gRPC dial options and CORS settings.
var restCtx context.Context
restCtx, s.restCancel = context.WithCancel(context.Background())
mux := proxy.NewServeMux(customMarshalerOption)
var restHandler http.Handler = mux
if s.cfg.CORSOrigin != "" {
restHandler = allowCORS(restHandler, s.cfg.CORSOrigin)
}
proxyOpts := []grpc.DialOption{
grpc.WithTransportCredentials(*s.cfg.RestClientConfig),
grpc.WithDefaultCallOptions(maxMsgRecvSize),
}
// With TLS enabled by default, we cannot call 0.0.0.0
// internally from the REST proxy as that IP address isn't in
// the cert. We need to rewrite it to the loopback address.
restProxyDest := s.cfg.RPCListen
switch {
case strings.Contains(restProxyDest, "0.0.0.0"):
restProxyDest = strings.Replace(
restProxyDest, "0.0.0.0", "127.0.0.1", 1,
)
case strings.Contains(restProxyDest, "[::]"):
restProxyDest = strings.Replace(
restProxyDest, "[::]", "[::1]", 1,
)
}
err = frdrpc.RegisterFaradayServerHandlerFromEndpoint(
restCtx, mux, restProxyDest, proxyOpts,
)
if err != nil {
return err
}
s.restServer = &http.Server{Handler: restHandler}
s.wg.Add(1)
go func() {
defer s.wg.Done()
err := s.restServer.Serve(restListener)
// ErrServerClosed is always returned when the proxy is
// shut down, so don't log it.
if err != nil && err != http.ErrServerClosed {
log.Error(err)
}
}()
} else {
log.Infof("REST proxy disabled")
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
if err := s.grpcServer.Serve(s.rpcListener); err != nil {
log.Errorf("could not serve grpc server: %v", err)
}
}()
// If we got here successfully, there's no need to shutdown anything
// anymore.
shutdownFuncs = nil
return nil
}
// StartAsSubserver is an alternative to Start where the RPC server does not
// create its own gRPC server but registers to an existing one. The same goes
// for REST (if enabled), instead of creating an own mux and HTTP server, we
// register to an existing one.
func (s *RPCServer) StartAsSubserver(lndClient lndclient.LndServices,
withMacaroonService bool) error {
if atomic.AddInt32(&s.started, 1) != 1 {
return errServerAlreadyStarted
}
if withMacaroonService {
// Set up the macaroon service.
rks, db, err := lndclient.NewBoltMacaroonStore(
s.cfg.FaradayDir, lncfg.MacaroonDBName,
macDatabaseOpenTimeout,
)
if err != nil {
return err
}
s.macaroonDB = db
s.macaroonService, err = lndclient.NewMacaroonService(
&lndclient.MacaroonServiceConfig{
RootKeyStore: rks,
MacaroonLocation: faradayMacaroonLocation,
MacaroonPath: s.cfg.MacaroonPath,
Checkers: []macaroons.Checker{
macaroons.IPLockChecker,
},
RequiredPerms: perms.RequiredPermissions,
DBPassword: macDbDefaultPw,
LndClient: &lndClient,
EphemeralKey: lndclient.SharedKeyNUMS,
KeyLocator: lndclient.SharedKeyLocator,
},
)
if err != nil {
return fmt.Errorf("error creating macroon service: %v",
err)
}
// Start the macaroon service and let it create its default
// macaroon in case it doesn't exist yet.
if err := s.macaroonService.Start(); err != nil {
return fmt.Errorf("error starting macaroon service: %v",
err)
}
}
s.cfg.Lnd = lndClient
return nil
}
// ValidateMacaroon extracts the macaroon from the context's gRPC metadata,
// checks its signature, makes sure all specified permissions for the called
// method are contained within and finally ensures all caveat conditions are
// met. A non-nil error is returned if any of the checks fail. This method is
// needed to enable faraday running as an external subserver in the same process
// as lnd but still validate its own macaroons.
func (s *RPCServer) ValidateMacaroon(ctx context.Context,
requiredPermissions []bakery.Op, fullMethod string) error {
if s.macaroonService == nil {
return fmt.Errorf("macaroon service not yet initialised")
}
// Delegate the call to faraday's own macaroon validator service.
return s.macaroonService.ValidateMacaroon(
ctx, requiredPermissions, fullMethod,
)
}
// Stop stops the grpc listener and server.
func (s *RPCServer) Stop() error {
if atomic.AddInt32(&s.stopped, 1) != 1 {
return nil
}
if s.restServer != nil {
s.restCancel()
err := s.restServer.Close()
if err != nil {
log.Errorf("unable to close REST listener: %v", err)
}
}
if s.macaroonService != nil {
if err := s.macaroonService.Stop(); err != nil {
log.Errorf("Error stopping macaroon service: %v", err)
}
}
if s.macaroonDB != nil {
if err := s.macaroonDB.Close(); err != nil {
log.Errorf("Error closing macaroon DB: %v", err)
}
}
// Stop the grpc server and wait for all go routines to terminate.
if s.grpcServer != nil {
s.grpcServer.Stop()
}
s.wg.Wait()
return nil
}
// OutlierRecommendations provides a set of close recommendations for the
// current set of open channels based on whether they are outliers.
func (s *RPCServer) OutlierRecommendations(ctx context.Context,
@ -244,3 +590,12 @@ func (s *RPCServer) requireNode() error {
return nil
}
// allowCORS wraps the given http.Handler with a function that adds the
// Access-Control-Allow-Origin header to the response.
func allowCORS(handler http.Handler, origin string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", origin)
handler.ServeHTTP(w, r)
})
}

253
go.mod
View file

@ -1,199 +1,164 @@
module github.com/lightninglabs/faraday
require (
github.com/btcsuite/btcd v0.25.1-0.20260310163610-1c55c7c18179
github.com/btcsuite/btcd/btcutil v1.1.6
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0
github.com/btcsuite/btclog/v2 v2.0.1-0.20250728225537-6090e87c6c5b
github.com/golang-migrate/migrate/v4 v4.19.0
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0
github.com/jarcoal/httpmock v1.4.0
github.com/jessevdk/go-flags v1.6.1
github.com/lightninglabs/faraday/frdrpc v1.0.1
github.com/lightninglabs/lndclient v0.21.0-1
github.com/lightningnetwork/lnd v0.21.0-beta
github.com/lightningnetwork/lnd/cert v1.2.2
github.com/lightningnetwork/lnd/clock v1.1.1
github.com/lightningnetwork/lnd/fn/v2 v2.0.9
github.com/lightningnetwork/lnd/kvdb v1.4.16
github.com/lightningnetwork/lnd/sqldb/v2 v2.0.0-20260326184657-f7cc56305bae
github.com/btcsuite/btcd v0.23.5-0.20230125025938-be056b0a0b2f
github.com/btcsuite/btcd/btcutil v1.1.3
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f
github.com/grpc-ecosystem/grpc-gateway/v2 v2.5.0
github.com/jessevdk/go-flags v1.4.0
github.com/lightninglabs/lndclient v0.16.0-10
github.com/lightninglabs/protobuf-hex-display v1.4.3-hex-display
github.com/lightningnetwork/lnd v0.16.0-beta
github.com/lightningnetwork/lnd/cert v1.2.1
github.com/lightningnetwork/lnd/kvdb v1.4.1
github.com/shopspring/decimal v1.2.0
github.com/stretchr/testify v1.11.1
github.com/urfave/cli v1.22.14
google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.10
github.com/stretchr/testify v1.8.1
github.com/urfave/cli v1.22.9
google.golang.org/grpc v1.41.0
google.golang.org/protobuf v1.27.1
gopkg.in/macaroon-bakery.v2 v2.0.1
gopkg.in/macaroon.v2 v2.1.0
modernc.org/sqlite v1.38.2
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect
github.com/aead/siphash v1.0.1 // indirect
github.com/andybalholm/brotli v1.0.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.6 // indirect
github.com/btcsuite/btcd/btcutil/psbt v1.1.10 // indirect
github.com/btcsuite/btcd/v2transport v1.0.1 // indirect
github.com/btcsuite/btclog v1.0.0 // indirect
github.com/btcsuite/btcwallet v0.16.18 // indirect
github.com/btcsuite/btcwallet/wallet/txauthor v1.3.5 // indirect
github.com/btcsuite/btcwallet/wallet/txrules v1.2.2 // indirect
github.com/btcsuite/btcwallet/wallet/txsizes v1.2.5 // indirect
github.com/btcsuite/btcwallet/walletdb v1.5.1 // indirect
github.com/btcsuite/btcwallet/wtxmgr v1.5.6 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
github.com/btcsuite/btcd/btcutil/psbt v1.1.5 // indirect
github.com/btcsuite/btcwallet v0.16.7 // indirect
github.com/btcsuite/btcwallet/wallet/txauthor v1.3.2 // indirect
github.com/btcsuite/btcwallet/wallet/txrules v1.2.0 // indirect
github.com/btcsuite/btcwallet/wallet/txsizes v1.2.3 // indirect
github.com/btcsuite/btcwallet/walletdb v1.4.0 // indirect
github.com/btcsuite/btcwallet/wtxmgr v1.5.0 // indirect
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect
github.com/btcsuite/winsvc v1.0.0 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/continuity v0.3.0 // indirect
github.com/cenkalti/backoff/v4 v4.1.1 // indirect
github.com/cespare/xxhash/v2 v2.1.1 // indirect
github.com/coreos/go-semver v0.3.0 // indirect
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
github.com/coreos/go-systemd/v22 v22.3.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/decred/dcrd/lru v1.1.2 // indirect
github.com/docker/cli v28.1.1+incompatible // indirect
github.com/docker/docker v28.3.3+incompatible // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fergusstrange/embedded-postgres v1.25.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/decred/dcrd/crypto/blake256 v1.0.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/decred/dcrd/lru v1.0.0 // indirect
github.com/dsnet/compress v0.0.1 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/fergusstrange/embedded-postgres v1.10.0 // indirect
github.com/go-errors/errors v1.0.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang-jwt/jwt/v4 v4.4.2 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/btree v1.0.1 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect
github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.14.3 // indirect
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 // indirect
github.com/jackc/pgconn v1.10.0 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgtype v1.14.4 // indirect
github.com/jackc/pgx/v4 v4.18.3 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jackc/pgproto3/v2 v2.1.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/jackc/pgtype v1.8.1 // indirect
github.com/jackc/pgx/v4 v4.13.0 // indirect
github.com/jonboulle/clockwork v0.2.2 // indirect
github.com/jrick/logrotate v1.1.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/jrick/logrotate v1.0.0 // indirect
github.com/json-iterator/go v1.1.11 // indirect
github.com/juju/loggo v0.0.0-20210728185423-eebad3a902c4 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kkdai/bstream v1.0.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/klauspost/compress v1.13.6 // indirect
github.com/klauspost/pgzip v1.2.5 // indirect
github.com/lib/pq v1.10.3 // indirect
github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect
github.com/lightninglabs/neutrino v0.17.1 // indirect
github.com/lightninglabs/neutrino/cache v1.1.3 // indirect
github.com/lightningnetwork/lightning-onion v1.3.0 // indirect
github.com/lightningnetwork/lnd/actor v0.0.6 // indirect
github.com/lightningnetwork/lnd/healthcheck v1.2.6 // indirect
github.com/lightningnetwork/lnd/queue v1.2.0 // indirect
github.com/lightningnetwork/lnd/sqldb v1.0.13 // indirect
github.com/lightningnetwork/lnd/ticker v1.1.1 // indirect
github.com/lightningnetwork/lnd/tlv v1.3.2 // indirect
github.com/lightningnetwork/lnd/tor v1.1.6 // indirect
github.com/lightninglabs/neutrino v0.15.0 // indirect
github.com/lightninglabs/neutrino/cache v1.1.1 // indirect
github.com/lightningnetwork/lightning-onion v1.2.1-0.20221202012345-ca23184850a1 // indirect
github.com/lightningnetwork/lnd/clock v1.1.0 // indirect
github.com/lightningnetwork/lnd/healthcheck v1.2.2 // indirect
github.com/lightningnetwork/lnd/queue v1.1.0 // indirect
github.com/lightningnetwork/lnd/ticker v1.1.0 // indirect
github.com/lightningnetwork/lnd/tlv v1.1.0 // indirect
github.com/lightningnetwork/lnd/tor v1.1.0 // indirect
github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mholt/archiver/v3 v3.5.0 // indirect
github.com/miekg/dns v1.1.43 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/user v0.3.0 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/opencontainers/runc v1.2.8 // indirect
github.com/ory/dockertest/v3 v3.10.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/modern-go/reflect2 v1.0.1 // indirect
github.com/nwaples/rardecode v1.1.2 // indirect
github.com/pierrec/lz4/v4 v4.1.8 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.11.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.26.0 // indirect
github.com/prometheus/procfs v0.6.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/rogpeppe/fastuuid v1.2.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/sirupsen/logrus v1.7.0 // indirect
github.com/soheilhy/cmux v0.1.5 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect
github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/ulikunitz/xz v0.5.10 // indirect
github.com/xdg-go/stringprep v1.0.3 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.etcd.io/etcd/api/v3 v3.5.12 // indirect
go.etcd.io/etcd/client/pkg/v3 v3.5.12 // indirect
go.etcd.io/etcd/client/v2 v2.305.12 // indirect
go.etcd.io/etcd/client/v3 v3.5.12 // indirect
go.etcd.io/etcd/pkg/v3 v3.5.12 // indirect
go.etcd.io/etcd/raft/v3 v3.5.12 // indirect
go.etcd.io/etcd/server/v3 v3.5.12 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.20.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.etcd.io/bbolt v1.3.6 // indirect
go.etcd.io/etcd/api/v3 v3.5.7 // indirect
go.etcd.io/etcd/client/pkg/v3 v3.5.7 // indirect
go.etcd.io/etcd/client/v2 v2.305.7 // indirect
go.etcd.io/etcd/client/v3 v3.5.7 // indirect
go.etcd.io/etcd/pkg/v3 v3.5.7 // indirect
go.etcd.io/etcd/raft/v3 v3.5.7 // indirect
go.etcd.io/etcd/server/v3 v3.5.7 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.25.0 // indirect
go.opentelemetry.io/otel v1.0.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.0.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.0.1 // indirect
go.opentelemetry.io/otel/sdk v1.0.1 // indirect
go.opentelemetry.io/otel/trace v1.0.1 // indirect
go.opentelemetry.io/proto/otlp v0.9.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.17.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
golang.org/x/crypto v0.1.0 // indirect
golang.org/x/exp v0.0.0-20221111094246-ab4555d3164f // indirect
golang.org/x/mod v0.6.0 // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/term v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba // indirect
golang.org/x/tools v0.2.0 // indirect
google.golang.org/genproto v0.0.0-20210617175327-b9e0b3197ced // indirect
gopkg.in/errgo.v1 v1.0.1 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
pgregory.net/rapid v1.2.0 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.22.2 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.4.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/sqlite v1.20.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.0.1 // indirect
sigs.k8s.io/yaml v1.2.0 // indirect
)
// We want to format raw bytes as hex instead of base64. The forked version
// allows us to specify that as an option.
replace google.golang.org/protobuf => github.com/lightninglabs/protobuf-go-hex-display v1.30.0-hex-display
// We are using a fork of the migration library.
replace github.com/golang-migrate/migrate/v4 => github.com/lightninglabs/migrate/v4 v4.18.2-9023d66a-fork-pr-2.0.20251211093704-71c1eef09789
// We need to replace frdrpc locally until we have this PR merged.
replace github.com/lightninglabs/faraday/frdrpc => ./frdrpc
go 1.25.10
go 1.19

918
go.sum

File diff suppressed because it is too large Load diff

View file

@ -2,9 +2,9 @@
# base images. The first stage builds lnd with the golang base image.
# The second stage runs directly on the bitcoind base image and adds all
# binaries required to run the tests with.
FROM golang:1.25.10-alpine as builder
FROM golang:1.19.4-alpine as builder
ARG LND_VERSION=v0.21.0-beta
ARG LND_VERSION=v0.15.4-beta
RUN apk add --no-cache git make

View file

@ -1,435 +0,0 @@
package itest
import (
"context"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/faraday/frdrpc"
"github.com/lightninglabs/lndclient"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// TestGetChannelEvents pins the GetChannelEvents RPC contract: a regtest
// channel lifecycle surfaces the expected event-type counts, and a paginated
// walk over the same window matches the unpaginated result event-for-event.
func TestGetChannelEvents(t *testing.T) {
c := newTestContext(t)
defer c.stop()
ctx := context.Background()
// We will start by opening a channel from alice to bob.
var aliceChannelAmt = btcutil.Amount(500000)
err := c.aliceClient.Client.Connect(
ctx, c.bobPubkey, "localhost:10012", true,
)
require.NoError(c.t, err, "could not connect nodes")
aliceChannel, _ := c.openChannel(
c.aliceClient.Client, c.bobPubkey, aliceChannelAmt,
)
// Wait until alice can route a payment to bob through the new channel.
var paymentAmount lnwire.MilliSatoshi = 20000000
c.eventuallyf(func() bool {
return c.channelRoutable(c.bobPubkey, paymentAmount)
}, "channel did not become routable")
// Now we'll send a payment from alice to bob to generate a balance
// update event.
hash, payreq := c.addInvoice(c.bobClient.Client, paymentAmount)
c.makePayment(
c.aliceClient.LndServices, c.bobClient.LndServices,
lndclient.SendPaymentRequest{
Invoice: payreq,
PaymentHash: &hash,
Timeout: paymentTimeout,
}, lnrpc.Payment_SUCCEEDED,
)
// We now close the channel to generate an offline event.
c.closeChannel(c.aliceClient.Client, aliceChannel, true)
endTime := time.Now().Add(time.Second).Unix()
events, err := c.faradayClient.GetChannelEvents(
ctx, &frdrpc.ChannelEventsRequest{
ChanPoint: aliceChannel.String(),
EndTime: endTime,
},
)
require.NoError(c.t, err, "could not get channel events")
// Check that we have the expected event types.
var (
onlineEvents int
updateEvents int
offlineEvents int
)
for _, event := range events.Events {
switch event.EventType {
case frdrpc.ChannelEventType_CHAN_EVENT_ONLINE:
onlineEvents++
case frdrpc.ChannelEventType_CHAN_EVENT_UPDATE:
updateEvents++
case frdrpc.ChannelEventType_CHAN_EVENT_OFFLINE:
offlineEvents++
}
}
// We expect exactly these events for this channel:
// 1. Channel Open: online, update (initial balance)
// 2. Channel Active: online
// 3. Payment sent: two updates (update_add, update_fulfill)
// 4. Channel Offline: offline
// 5. Channel Close: offline
require.Len(t, events.Events, 7)
require.Equal(t, 2, onlineEvents)
require.Equal(t, 3, updateEvents)
require.Equal(t, 2, offlineEvents)
// Walk the same window with a small page size and assert the
// concatenated pages match the unpaginated result. Catches
// last_id round-trip, has_more termination, and MaxEvents
// clamping in one pass.
const pageSize = 2
var (
paged []*frdrpc.ChannelEvent
lastID int64
pages int
)
for {
page, err := c.faradayClient.GetChannelEvents(
ctx, &frdrpc.ChannelEventsRequest{
ChanPoint: aliceChannel.String(),
EndTime: endTime,
MaxEvents: pageSize,
LastId: lastID,
},
)
require.NoError(c.t, err, "could not get paginated events")
// Non-final pages must fill exactly pageSize; the final
// page (HasMore == false) holds the remainder.
if page.HasMore {
require.Len(t, page.Events, pageSize,
"non-final page %d not full", pages)
} else {
require.LessOrEqual(t, len(page.Events), pageSize,
"final page %d exceeds pageSize", pages)
}
paged = append(paged, page.Events...)
pages++
if !page.HasMore {
break
}
lastID = page.LastId
}
// With 7 events and pageSize 2 we expect ceil(7/2) = 4 pages.
require.Equal(t, 4, pages, "unexpected page count")
require.Equal(t, len(events.Events), len(paged),
"paginated and unpaginated counts differ")
for i, e := range events.Events {
require.Equal(t, e.Id, paged[i].Id,
"event order mismatch at index %d", i)
}
}
// TestForwardingAbility integration test opens a channel, sends payments to
// seed events, and verifies that calling the ForwardingAbility RPC returns
// the peer pair analytics successfully and can be decoded.
func TestForwardingAbility(t *testing.T) {
c := newTestContext(t)
defer c.stop()
ctx := context.Background()
// Connect nodes and open a channel from alice to bob.
var aliceChannelAmt = btcutil.Amount(500000)
err := c.aliceClient.Client.Connect(
ctx, c.bobPubkey, "localhost:10012", true,
)
require.NoError(c.t, err, "could not connect nodes")
_, _ = c.openChannel(
c.aliceClient.Client, c.bobPubkey, aliceChannelAmt,
)
// Wait until alice can route a payment to bob.
var paymentAmount lnwire.MilliSatoshi = 20000000
c.eventuallyf(func() bool {
return c.channelRoutable(c.bobPubkey, paymentAmount)
}, "channel did not become routable")
// Send a payment from alice to bob.
hash, payreq := c.addInvoice(c.bobClient.Client, paymentAmount)
c.makePayment(
c.aliceClient.LndServices, c.bobClient.LndServices,
lndclient.SendPaymentRequest{
Invoice: payreq,
PaymentHash: &hash,
Timeout: paymentTimeout,
}, lnrpc.Payment_SUCCEEDED,
)
// The alice->bob payment moved liquidity onto bob's side of the only
// channel, so from this point on the bob self-pair holds at least the
// requested floor. Measuring over a window that starts now keeps the
// pair's uptime fraction high, which both clears the uptime threshold
// and keeps the node-down guard satisfied. A future end time extends
// the window over the still-funded state.
bobHex := c.bobPubkey.String()
windowStart := time.Now()
// The events store ingests channel updates asynchronously, so retry
// until the bob self-pair surfaces.
var ability frdrpc.ForwardingAbility
c.eventuallyf(func() bool {
endTime := time.Now().Add(2 * time.Second).Unix()
resp, err := c.faradayClient.ForwardingAbility(
ctx, &frdrpc.ForwardingAbilityRequest{
StartTime: uint64(windowStart.Unix()),
EndTime: uint64(endTime),
LiquidityFloorSat: 1000,
UptimeThreshold: 0.1,
},
)
if err != nil {
return false
}
decoded, err := frdrpc.DecodeForwardingAbility(resp)
if err != nil {
return false
}
a, ok := decoded[bobHex][bobHex]
if !ok {
return false
}
ability = a
return true
}, "expected bob self-pair in forwarding ability")
// The bob self-pair was up but never forwarded through itself, so it
// surfaces via the up-but-idle bitmask: non-zero effective uptime and
// zero forwarded volume.
require.Greater(c.t, ability.EffectiveUptimeS, int64(0))
require.Zero(c.t, ability.ForwardedSat)
}
// TestForwardingDowntime exercises the offline/online plumbing end to end. It
// disconnects the only channel peer to take the channel offline, asserts that
// faraday records the resulting offline event, then reconnects the peer and
// asserts the recovering online event lands and the bob self-pair surfaces in
// ForwardingAbility again. This proves downtime and recovery flow through to
// the analyzer; the exact per-second uptime math is covered deterministically
// by the analyzer unit tests.
func TestForwardingDowntime(t *testing.T) {
c := newTestContext(t)
defer c.stop()
ctx := context.Background()
// Connect nodes and open a channel from alice to bob.
var aliceChannelAmt = btcutil.Amount(500000)
err := c.aliceClient.Client.Connect(
ctx, c.bobPubkey, "localhost:10012", true,
)
require.NoError(c.t, err, "could not connect nodes")
aliceChannel, _ := c.openChannel(
c.aliceClient.Client, c.bobPubkey, aliceChannelAmt,
)
// Wait until alice can route a payment to bob.
var paymentAmount lnwire.MilliSatoshi = 20000000
c.eventuallyf(func() bool {
return c.channelRoutable(c.bobPubkey, paymentAmount)
}, "channel did not become routable")
// Move liquidity onto bob's side so the bob self-pair clears the
// liquidity floor while the channel is up.
hash, payreq := c.addInvoice(c.bobClient.Client, paymentAmount)
c.makePayment(
c.aliceClient.LndServices, c.bobClient.LndServices,
lndclient.SendPaymentRequest{
Invoice: payreq,
PaymentHash: &hash,
Timeout: paymentTimeout,
}, lnrpc.Payment_SUCCEEDED,
)
chanPoint := aliceChannel.String()
bobHex := c.bobPubkey.String()
// Snapshot the event counts before the disconnect so we can detect the
// new offline and online events the disconnect and recovery produce.
onlineBefore, offlineBefore := c.channelEventCounts(chanPoint)
// Disconnect bob to take the only channel offline.
c.disconnectPeer(c.aliceClient, c.bobPubkey)
// faraday should ingest the resulting offline event: this is the
// downtime signal that the channel went inactive.
c.eventuallyf(func() bool {
_, offline := c.channelEventCounts(chanPoint)
return offline > offlineBefore
}, "expected an offline event after disconnect")
// An explicit DisconnectPeer is sticky: lnd does not auto-reconnect, so
// the channel stays offline until we reconnect. A window that sits
// entirely in this offline period leaves no pair clearing the uptime
// threshold, so the node-down guard rejects the request with
// FailedPrecondition rather than returning an empty response. The
// threshold is irrelevant here since the only pair has zero uptime.
c.eventuallyf(func() bool {
now := time.Now()
_, err := c.faradayClient.ForwardingAbility(
ctx, &frdrpc.ForwardingAbilityRequest{
StartTime: uint64(now.Unix()),
EndTime: uint64(
now.Add(2 * time.Second).Unix(),
),
LiquidityFloorSat: 1000,
UptimeThreshold: 0.9,
},
)
return status.Code(err) == codes.FailedPrecondition
}, "expected node-down guard while bob is disconnected")
// An explicit DisconnectPeer drops lnd's persistent connection, so the
// channel only comes back up once we reconnect. Reconnect bob to bring
// the channel active again.
err = c.aliceClient.Client.Connect(
ctx, c.bobPubkey, "localhost:10012", true,
)
require.NoError(c.t, err, "could not reconnect nodes")
// The channel goes active again on reconnect, which faraday records as
// an online event.
c.eventuallyf(func() bool {
online, _ := c.channelEventCounts(chanPoint)
return online > onlineBefore
}, "expected an online event after reconnect")
// With the channel back up and liquidity still on bob's side, the bob
// self-pair surfaces in ForwardingAbility again over a fresh window
// that opens after recovery.
c.eventuallyf(func() bool {
now := time.Now()
resp, err := c.faradayClient.ForwardingAbility(
ctx, &frdrpc.ForwardingAbilityRequest{
StartTime: uint64(now.Unix()),
EndTime: uint64(
now.Add(2 * time.Second).Unix(),
),
LiquidityFloorSat: 1000,
UptimeThreshold: 0.1,
},
)
if err != nil {
return false
}
decoded, err := frdrpc.DecodeForwardingAbility(resp)
if err != nil {
return false
}
_, ok := decoded[bobHex][bobHex]
return ok
}, "expected bob self-pair after reconnect")
}
// TestChannelEventsPruning verifies that starting Faraday with low size limits
// (e.g. max-events=1 and retention=2s) executes live background pruning
// successfully and bounds the database size correctly.
func TestChannelEventsPruning(t *testing.T) {
c := newTestContext(
t, "--chanevents.max-events=1", "--chanevents.retention=2s",
)
defer c.stop()
ctx := context.Background()
// We will start by opening a channel from alice to bob.
var aliceChannelAmt = btcutil.Amount(500000)
err := c.aliceClient.Client.Connect(
ctx, c.bobPubkey, "localhost:10012", true,
)
require.NoError(c.t, err, "could not connect nodes")
aliceChannel, _ := c.openChannel(
c.aliceClient.Client, c.bobPubkey, aliceChannelAmt,
)
// Use a far-future end time so the query window never excludes a stored
// event on a slow host. A tight wall-clock window here would make the
// counts below racy.
endTime := time.Now().Add(time.Hour).Unix()
// We deliberately do not assert on the initial event count here: opening
// a channel records several events, but the 2-second background prune can
// fire before we observe them on a slow host, so any such pre-prune
// assertion would be flaky. The eventuallyf checks below verify the
// pruning behaviour directly instead.
// Wait for the live background pruning ticker to bound the table to the
// max-events ceiling. We assert at most one event rather than exactly
// one: the size limit keeps a single event, but the 2-second retention
// limit then ages it out since no new events follow the channel open,
// so the steady state is zero or one.
var eventsAfter *frdrpc.ChannelEventsResponse
c.eventuallyf(func() bool {
var err error
eventsAfter, err = c.faradayClient.GetChannelEvents(
ctx, &frdrpc.ChannelEventsRequest{
ChanPoint: aliceChannel.String(),
EndTime: endTime,
},
)
if err != nil {
return false
}
return len(eventsAfter.Events) <= 1
}, "expected channel events to be pruned down to at most one in the "+
"background")
// No further events follow the channel open, so once the remaining
// event ages past the 2-second retention window the age-based prune
// removes it too, draining the table to zero.
c.eventuallyf(func() bool {
eventsAfter, err := c.faradayClient.GetChannelEvents(
ctx, &frdrpc.ChannelEventsRequest{
ChanPoint: aliceChannel.String(),
EndTime: endTime,
},
)
if err != nil {
return false
}
return len(eventsAfter.Events) == 0
}, "expected channel events to be pruned down to zero once all events "+
"age out of the retention window")
}

View file

@ -1,8 +1,10 @@
package itest
import "github.com/btcsuite/btclog/v2"
import (
"github.com/btcsuite/btclog"
)
var (
handler = btclog.NewDefaultHandler(newPrefixStdout("itest"))
log = btclog.NewSLogger(handler)
backend = btclog.NewBackend(newPrefixStdout("itest"))
log = backend.Logger("")
)

View file

@ -85,7 +85,7 @@ func TestNodeAudit(t *testing.T) {
feeRef := accounting.FeeReference(aliceChannel.Hash.String())
expected[feeRef] = expectedReport{
eventType: frdrpc.EntryType_CHANNEL_OPEN_FEE,
amount: lnwire.MilliSatoshi(4118 * 1000),
amount: lnwire.MilliSatoshi(8237 * 1000),
onChain: true,
}
@ -100,10 +100,6 @@ func TestNodeAudit(t *testing.T) {
invoiceAmount lnwire.MilliSatoshi = 1000
)
// TODO: there are some timing issues. We should upgrade the test
// framework to lntest.
time.Sleep(time.Second * 3)
// Make a payment from alice to bob, we need to make this payment first
// because we do not have any incoming liquidity.
hash, payreq := c.addInvoice(c.bobClient.Client, paymentAmount)
@ -116,10 +112,6 @@ func TestNodeAudit(t *testing.T) {
}, lnrpc.Payment_SUCCEEDED,
)
// TODO: there are some timing issues. We should upgrade the test
// framework to lntest.
time.Sleep(time.Second * 3)
// Add an entry for our payment to our set of expected entries. We do
// not expect a fee entry because we made a single hop payment. Since
// this is the first payment we send, we expect it to have a sequence
@ -188,14 +180,14 @@ func TestNodeAudit(t *testing.T) {
expected[accounting.FeeReference(closeTx.String())] = expectedReport{
eventType: frdrpc.EntryType_CHANNEL_CLOSE_FEE,
amount: lnwire.MilliSatoshi(4525 * 1000),
amount: lnwire.MilliSatoshi(9060 * 1000),
onChain: true,
}
// Because we force closed our channels, we also expect to have sweep
// transactions for our commitment. Bob should have claimed our htlc on
// chain, so we do not expect it to be swept.
sweeps, err := c.aliceClient.WalletKit.ListSweeps(ctx, 0)
sweeps, err := c.aliceClient.WalletKit.ListSweeps(ctx)
require.NoError(c.t, err, "could not get sweeps")
require.Len(c.t, sweeps, 1)

View file

@ -38,7 +38,7 @@ var (
faradayArgs = []string{
"--rpclisten=localhost:8465",
"--network=regtest",
"--lnd.macaroonpath=lnd-alice/faraday-custom.macaroon",
"--lnd.macaroonpath=lnd-alice/data/chain/bitcoin/regtest/admin.macaroon",
"--lnd.tlscertpath=lnd-alice/tls.cert",
"--debuglevel=debug",
"--connect_bitcoin",
@ -68,7 +68,7 @@ type testContext struct {
}
// newTestContext returns a new context instance.
func newTestContext(t *testing.T, extraFaradayArgs ...string) *testContext {
func newTestContext(t *testing.T) *testContext {
var err error
ctx := &testContext{
@ -123,11 +123,7 @@ func newTestContext(t *testing.T, extraFaradayArgs ...string) *testContext {
require.NoError(t, err)
// Start faraday.
ctx.startFaraday(extraFaradayArgs...)
// Wait for faraday's channel events monitor to finish its initial
// chain-sync.
time.Sleep(5 * time.Second)
ctx.startFaraday()
return ctx
}
@ -157,6 +153,15 @@ func (c *testContext) mine() int {
return len(block.Transactions) - 1
}
// mine mines a block and verifies that the expected number of transactions is
// present (excluding the coinbase tx).
func (c *testContext) mineExactly(expectedTxCount int) {
c.t.Helper()
txCount := c.mine()
require.Equal(c.t, expectedTxCount, txCount)
}
// mempoolTxCount returns the number of txes currently in the mempool.
func (c *testContext) mempoolTxCount() int {
txes, err := c.bitcoindClient.GetRawMempool()
@ -299,9 +304,8 @@ func (c *testContext) closeChannel(client lndclient.LightningClient,
require.NoError(c.t, err, "could not close channel")
var (
closeTx chainhash.Hash
closeFee btcutil.Amount
gotPending bool
closeTx chainhash.Hash
closeFee btcutil.Amount
)
// Wait for us to get an update from our channel indicating that it is
@ -315,10 +319,7 @@ func (c *testContext) closeChannel(client lndclient.LightningClient,
case *lndclient.PendingCloseUpdate:
// Get our close tx from the mempool to get its fee
// and add an expected entry because we opened the
// channel so we pay the fees. This must happen
// before we mine any block, otherwise the close tx
// is confirmed out of the mempool and the lookup
// fails.
// channel so we pay the fees.
close, err := c.bitcoindClient.GetMempoolEntry(
closeTx.String(),
)
@ -327,8 +328,6 @@ func (c *testContext) closeChannel(client lndclient.LightningClient,
closeFee, err = btcutil.NewAmount(close.Fee)
require.NoError(c.t, err, "could not get fee")
gotPending = true
case *lndclient.ChannelClosedUpdate:
return true
}
@ -337,15 +336,9 @@ func (c *testContext) closeChannel(client lndclient.LightningClient,
c.t.Fatalf("error closing channel: %v, %v", channel,
err)
// If we have not received an update yet, wait for the pending
// close to broadcast. Only once we have captured the close tx
// fee from the mempool do we start mining blocks to drive the
// channel to its fully resolved state, so that mining does not
// confirm the close tx before we read its fee.
// If we have not received an update yet, mine a block.
default:
if gotPending {
c.mine()
}
c.mine()
}
return false
@ -461,75 +454,6 @@ func (c *testContext) waitForChannelOpen(targetChannel *wire.OutPoint) {
)
}
// channelRoutable reports whether alice's router can build a route to dest
// for amount. It gates on QueryRoutes rather than channel activation: lnd
// marks a channel Active on channel_ready, but the local channel_update the
// router needs lands a moment later.
func (c *testContext) channelRoutable(dest route.Vertex,
amount lnwire.MilliSatoshi) bool {
_, err := c.aliceClient.Client.QueryRoutes(
context.Background(), lndclient.QueryRoutesRequest{
PubKey: dest,
AmtMsat: amount,
},
)
return err == nil
}
// disconnectPeer disconnects the given client from a peer, taking any channels
// between them offline. lnd normally refuses to disconnect from a peer with an
// active channel, but the itest lnd is a non-integration build where unsafe
// disconnect is always permitted. The raw lnrpc client is used because the
// high-level lndclient interface exposes no Disconnect, and the admin macaroon
// is attached at call time since the shared connection carries none.
func (c *testContext) disconnectPeer(client *lndclient.GrpcLndServices,
peer route.Vertex) {
c.t.Helper()
ctx, err := client.WithMacaroonAuthForService(
context.Background(), lndclient.AdminServiceMac,
)
require.NoError(c.t, err, "could not attach macaroon")
raw := lnrpc.NewLightningClient(client.ClientConn)
_, err = raw.DisconnectPeer(ctx, &lnrpc.DisconnectPeerRequest{
PubKey: peer.String(),
})
require.NoError(c.t, err, "could not disconnect peer")
}
// channelEventCounts returns how many online and offline events faraday has
// recorded for the given channel up to the present.
func (c *testContext) channelEventCounts(chanPoint string) (online,
offline int) {
c.t.Helper()
endTime := time.Now().Add(time.Second).Unix()
events, err := c.faradayClient.GetChannelEvents(
context.Background(), &frdrpc.ChannelEventsRequest{
ChanPoint: chanPoint,
EndTime: endTime,
},
)
require.NoError(c.t, err, "could not get channel events")
for _, event := range events.Events {
switch event.EventType {
case frdrpc.ChannelEventType_CHAN_EVENT_ONLINE:
online++
case frdrpc.ChannelEventType_CHAN_EVENT_OFFLINE:
offline++
}
}
return online, offline
}
// findChannel finds a channel in a set of open channels, returning nil if it
// is not found.
// nolint:interfacer
@ -561,16 +485,29 @@ func (c *testContext) waitForMempoolTxCount(txCount int, msg string) {
)
}
// waitForTxesAndMine waits for a specified number of txes to arrive in the
// mempool and then mines a block.
func (c *testContext) waitForTxesAndMine(txCount int, msg string) {
c.t.Helper()
c.waitForMempoolTxCount(txCount, msg)
c.mineExactly(txCount)
}
// mempoolEmpty asserts that the mempool is empty.
func (c *testContext) mempoolEmpty() {
c.t.Helper()
require.Equal(c.t, 0, c.mempoolTxCount(), "mempool not empty")
}
// startFaraday starts faraday, connecting to our test context's alice lnd node.
// It returns process start errors and an error channel for errors that occur
// after the start.
func (c *testContext) startFaraday(extraArgs ...string) {
args := append([]string{}, faradayArgs...)
args = append(args, extraArgs...)
func (c *testContext) startFaraday() {
// Start loop client daemon.
c.faradayCmd = exec.Command(
faradayCmd, args...,
faradayCmd, faradayArgs...,
)
attachPrefixStdout(c.faradayCmd, "faraday")

View file

@ -43,13 +43,6 @@ function start_lnds() {
waitnoerror $LNCLI_SERVER getinfo
waitnoerror $LNCLI_CLIENT getinfo
waitnoerror $LNCLI_SERVER 'state | grep -q SERVER_ACTIVE'
waitnoerror $LNCLI_CLIENT 'state | grep -q SERVER_ACTIVE'
# Create custom macaroon for faraday to use.
PERMS="onchain:read offchain:read address:read peers:read info:read invoices:read uri:/signrpc.Signer/DeriveSharedKey"
$LNCLI_SERVER bakemacaroon --save_to lnd-alice/faraday-custom.macaroon $PERMS
}
function stop_all() {

12
log.go
View file

@ -1,9 +1,8 @@
package faraday
import (
"github.com/btcsuite/btclog/v2"
"github.com/btcsuite/btclog"
"github.com/lightninglabs/faraday/accounting"
"github.com/lightninglabs/faraday/chanevents"
"github.com/lightninglabs/faraday/dataset"
"github.com/lightninglabs/faraday/fiat"
"github.com/lightninglabs/faraday/frdrpcserver"
@ -24,7 +23,7 @@ var (
)
// SetupLoggers initializes all package-global logger variables.
func SetupLoggers(root *build.SubLoggerManager, intercept signal.Interceptor) {
func SetupLoggers(root *build.RotatingLogWriter, intercept signal.Interceptor) {
genLogger := genSubLogger(root, intercept)
log = build.NewSubLogger(Subsystem, genLogger)
@ -38,7 +37,6 @@ func SetupLoggers(root *build.SubLoggerManager, intercept signal.Interceptor) {
addSubLogger(root, revenue.Subsystem, intercept, revenue.UseLogger)
addSubLogger(root, fiat.Subsystem, intercept, fiat.UseLogger)
addSubLogger(root, accounting.Subsystem, intercept, accounting.UseLogger)
addSubLogger(root, chanevents.Subsystem, intercept, chanevents.UseLogger)
}
// UseLogger uses a specified Logger to output package logging info.
@ -50,7 +48,7 @@ func UseLogger(logger btclog.Logger) {
// genSubLogger creates a logger for a subsystem. We provide an instance of
// a signal.Interceptor to be able to shutdown in the case of a critical error.
func genSubLogger(root *build.SubLoggerManager,
func genSubLogger(root *build.RotatingLogWriter,
interceptor signal.Interceptor) func(string) btclog.Logger {
// Create a shutdown function which will request shutdown from our
@ -72,7 +70,7 @@ func genSubLogger(root *build.SubLoggerManager,
// addSubLogger is a helper method to conveniently create and register the
// logger of a sub system.
func addSubLogger(root *build.SubLoggerManager, subsystem string,
func addSubLogger(root *build.RotatingLogWriter, subsystem string,
interceptor signal.Interceptor, useLogger func(btclog.Logger)) {
logger := build.NewSubLogger(subsystem, genSubLogger(root, interceptor))
@ -81,7 +79,7 @@ func addSubLogger(root *build.SubLoggerManager, subsystem string,
// setSubLogger is a helper method to conveniently register the logger of a sub
// system.
func setSubLogger(root *build.SubLoggerManager, subsystem string,
func setSubLogger(root *build.RotatingLogWriter, subsystem string,
logger btclog.Logger, useLogger func(btclog.Logger)) {
root.RegisterSubLogger(subsystem, logger)

View file

@ -1,19 +1,6 @@
TEST_FLAGS =
DEV_TAGS = dev
COVER_PKG = $$(go list -deps ./... | grep '$(PKG)')
# Add the build tag for running unit tests against a postgres DB.
ifeq ($(dbbackend),postgres)
DEV_TAGS += test_db_postgres
else
DEV_TAGS += test_db_sqlite
endif
# Add any additional tags that are passed in to make.
ifneq ($(tags),)
DEV_TAGS += ${tags}
endif
# If specific package is being unit tested, construct the full name of the
# subpackage.
ifneq ($(pkg),)
@ -43,11 +30,11 @@ UNIT_TARGETED ?= no
# If a specific package/test case was requested, run the unit test for the
# targeted case. Otherwise, default to running all tests.
ifeq ($(UNIT_TARGETED), yes)
UNIT := $(GOTEST) -tags="$(DEV_TAGS)" $(TEST_FLAGS) $(UNITPKG)
UNIT_RACE := $(GOTEST) -tags="$(DEV_TAGS)" $(TEST_FLAGS) -race $(UNITPKG)
UNIT := $(GOTEST) $(TEST_FLAGS) $(UNITPKG)
UNIT_RACE := $(GOTEST) $(TEST_FLAGS) -race $(UNITPKG)
endif
ifeq ($(UNIT_TARGETED), no)
UNIT := $(GOLIST) | $(XARGS) env $(GOTEST) -tags="$(DEV_TAGS)" $(TEST_FLAGS)
UNIT := $(GOLIST) | $(XARGS) env $(GOTEST) $(TEST_FLAGS)
UNIT_RACE := $(UNIT) -race
endif

View file

@ -1,7 +1,7 @@
package recommend
import (
"github.com/btcsuite/btclog/v2"
"github.com/btcsuite/btclog"
"github.com/lightningnetwork/lnd/build"
)

View file

@ -1,7 +1,7 @@
package revenue
import (
"github.com/btcsuite/btclog/v2"
"github.com/btcsuite/btclog"
"github.com/lightningnetwork/lnd/build"
)

View file

@ -1,48 +0,0 @@
#!/bin/bash
set -e
# restore_files is a function to restore original schema files.
restore_files() {
echo "Restoring SQLite bigint patch..."
for file in db/sqlc/migrations/*.up.sql.bak; do
mv "$file" "${file%.bak}"
done
}
# Set trap to call restore_files on script exit. This makes sure the old files
# are always restored.
trap restore_files EXIT
# Directory of the script file, independent of where it's called from.
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Use the user's cache directories
GOCACHE=$(go env GOCACHE)
GOMODCACHE=$(go env GOMODCACHE)
# SQLite doesn't support "BIGINT PRIMARY KEY" for auto-incrementing primary
# keys, only "INTEGER PRIMARY KEY". Internally it uses 64-bit integers for
# numbers anyway, independent of the column type. So we can just use
# "INTEGER PRIMARY KEY" and it will work the same under the hood, giving us
# auto incrementing 64-bit integers.
# _BUT_, sqlc will generate Go code with int32 if we use "INTEGER PRIMARY KEY",
# even though we want int64. So before we run sqlc, we need to patch the
# source schema SQL files to use "BIGINT PRIMARY KEY" instead of "INTEGER
# PRIMARY KEY".
echo "Applying SQLite bigint patch..."
for file in db/sqlc/migrations/*.up.sql; do
echo "Patching $file"
sed -i.bak -E 's/INTEGER PRIMARY KEY/BIGINT PRIMARY KEY/g' "$file"
done
echo "Generating sql models and queries in go..."
# Run the script to generate the new generated code. Once the script exits, we
# use `trap` to make sure all files are restored.
docker run \
--rm \
--user "$UID:$(id -g)" \
-e UID=$UID \
-v "$DIR/../:/build" \
-w /build \
sqlc/sqlc:1.25.0 generate

View file

@ -1,10 +0,0 @@
version: "2"
sql:
- engine: "postgresql"
schema: "db/sqlc/migrations"
queries: "db/sqlc/queries"
gen:
go:
out: db/sqlc
package: sqlc
emit_interface: true

View file

@ -1,4 +1,4 @@
FROM golang:1.25.10-bookworm
FROM golang:1.19.4
RUN apt-get update && apt-get install -y git
ENV GOCACHE=/tmp/build/.cache
@ -11,7 +11,7 @@ RUN cd /tmp \
&& mkdir -p /tmp/build/.cache \
&& mkdir -p /tmp/build/.modcache \
&& cd /tmp/tools \
&& go install -trimpath github.com/golangci/golangci-lint/cmd/golangci-lint \
&& go install -trimpath -tags=tools github.com/golangci/golangci-lint/cmd/golangci-lint \
&& chmod -R 777 /tmp/build/
WORKDIR /build

View file

@ -1,201 +1,9 @@
module github.com/lightninglabs/faraday/tools
go 1.16
require (
github.com/golangci/golangci-lint v1.64.5
github.com/golangci/golangci-lint v1.45.2
github.com/ory/go-acc v0.2.6
github.com/rinchsan/gosimports v0.1.5
)
require (
4d63.com/gocheckcompilerdirectives v1.2.1 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
github.com/4meepo/tagalign v1.4.1 // indirect
github.com/Abirdcfly/dupword v0.1.3 // indirect
github.com/Antonboom/errname v1.0.0 // indirect
github.com/Antonboom/nilnil v1.0.1 // indirect
github.com/Antonboom/testifylint v1.5.2 // indirect
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
github.com/Crocmagnon/fatcontext v0.7.1 // indirect
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.0 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.0 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.5 // indirect
github.com/alexkohler/prealloc v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.1.2 // indirect
github.com/ashanbrown/forbidigo v1.6.0 // indirect
github.com/ashanbrown/makezero v1.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.5.0 // indirect
github.com/breml/bidichk v0.3.2 // indirect
github.com/breml/errchkjson v0.4.0 // indirect
github.com/butuzov/ireturn v0.3.1 // indirect
github.com/butuzov/mirror v1.3.0 // indirect
github.com/catenacyber/perfsprint v0.8.1 // indirect
github.com/ccojocar/zxcvbn-go v1.0.2 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.10 // indirect
github.com/chavacava/garif v0.1.0 // indirect
github.com/ckaznocha/intrange v0.3.0 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dgraph-io/ristretto v0.0.2 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.5 // indirect
github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/ghostiam/protogetter v0.3.9 // indirect
github.com/go-critic/go-critic v0.12.0 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
github.com/go-toolsmith/astcopy v1.1.0 // indirect
github.com/go-toolsmith/astequal v1.2.0 // indirect
github.com/go-toolsmith/astfmt v1.1.0 // indirect
github.com/go-toolsmith/astp v1.1.0 // indirect
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a // indirect
github.com/golangci/go-printf-func-name v0.1.0 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/misspell v0.6.0 // indirect
github.com/golangci/plugin-module-register v0.1.1 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gordonklaus/ineffassign v0.1.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.4.2 // indirect
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
github.com/gostaticanalysis/nilerr v0.1.1 // indirect
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jgautheron/goconst v1.7.1 // indirect
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
github.com/jjti/go-spancheck v0.6.4 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect
github.com/kisielk/errcheck v1.8.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.5 // indirect
github.com/kulti/thelper v0.6.3 // indirect
github.com/kunwardeep/paralleltest v1.0.10 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.1 // indirect
github.com/ldez/gomoddirectives v0.6.1 // indirect
github.com/ldez/grignotin v0.9.0 // indirect
github.com/ldez/tagliatelle v0.7.1 // indirect
github.com/ldez/usetesting v0.4.2 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/macabu/inamedparam v0.1.3 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/maratori/testableexamples v1.0.0 // indirect
github.com/maratori/testpackage v1.1.1 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.6.1 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.19.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/ory/viper v1.7.5 // indirect
github.com/pborman/uuid v1.2.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/polyfloyd/go-errorlint v1.7.1 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.4.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/raeperd/recvcheck v0.2.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/ryancurrah/gomodguard v1.3.5 // indirect
github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect
github.com/securego/gosec/v2 v2.22.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sivchari/tenv v1.12.1 // indirect
github.com/sonatard/noctx v0.1.0 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cobra v1.8.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/tdakkota/asciicheck v0.4.0 // indirect
github.com/tetafro/godot v1.4.20 // indirect
github.com/timakin/bodyclose v0.0.0-20241017074812-ed6a65f985e3 // indirect
github.com/timonwong/loggercheck v0.10.1 // indirect
github.com/tomarrell/wrapcheck/v2 v2.10.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/uudashr/gocognit v1.2.0 // indirect
github.com/uudashr/iface v1.3.1 // indirect
github.com/xen0n/gosmopolitan v1.2.2 // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.13.0 // indirect
go-simpler.org/sloglint v0.9.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/mod v0.23.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/tools v0.30.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.6.0 // indirect
mvdan.cc/gofumpt v0.7.0 // indirect
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
)
go 1.25.10

Some files were not shown because too many files have changed in this diff Show more