commit 2774bcee412e201b2dbfbabbe1d9f172413d6d3b Author: Oliver Gugger Date: Sat Nov 9 15:41:31 2019 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87739b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/chansummary diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..e3e2a03 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,24 @@ +run: + # timeout for analysis + deadline: 4m + + # Linting uses a lot of memory. Keep it under control by only running a single + # worker. + concurrency: 1 + +linters-settings: + govet: + # Don't report about shadowed variables + check-shadowing: false + gofmt: + # simplify code: gofmt with `-s` option, true by default + simplify: true + +linters: + enable-all: true + disable: + # Init functions are used by loggers throughout the codebase. + - gochecknoinits + + # Global variables are used by loggers. + - gochecknoglobals diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..599a8f0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Oliver Gugger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8177418 --- /dev/null +++ b/Makefile @@ -0,0 +1,49 @@ +PKG := github.com/guggero/chansummary + +GOTEST := GO111MODULE=on go test -v + +GO_BIN := ${GOPATH}/bin + +GOFILES_NOVENDOR = $(shell find . -type f -name '*.go' -not -path "./vendor/*") +GOLIST := go list $(PKG)/... | grep -v '/vendor/' + +LINT_BIN := $(GO_BIN)/golangci-lint +LINT_PKG := github.com/golangci/golangci-lint/cmd/golangci-lint +LINT_COMMIT := v1.18.0 +LINT = $(LINT_BIN) run -v + +DEPGET := cd /tmp && GO111MODULE=on go get -v +GOBUILD := GO111MODULE=on go build -v +GOINSTALL := GO111MODULE=on go install -v +GOTEST := GO111MODULE=on go test -v +XARGS := xargs -L 1 + +TEST_FLAGS = -test.timeout=20m + +UNIT := $(GOLIST) | $(XARGS) env $(GOTEST) $(TEST_FLAGS) + +default: build + +$(LINT_BIN): + @$(call print, "Fetching linter") + $(DEPGET) $(LINT_PKG)@$(LINT_COMMIT) + +unit: + @$(call print, "Running unit tests.") + $(UNIT) + +build: + @$(call print, "Building chansummary.") + $(GOBUILD) $(PKG)/cmd/chansummary + +install: + @$(call print, "Installing chansummary.") + $(GOINSTALL) $(PKG)/cmd/chansummary + +fmt: + @$(call print, "Formatting source.") + gofmt -l -w -s $(GOFILES_NOVENDOR) + +lint: $(LINT_BIN) + @$(call print, "Linting source.") + $(LINT) \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..58e92df --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Channel summary + +This tool works with the output of lnd's `listchannels` command and creates +a summary of the on-chain state of these channels. + +**WARNING**: This tool will query public block explorer APIs, your privacy +might not be preserved. Use at your own risk. diff --git a/chainapi.go b/chainapi.go new file mode 100644 index 0000000..159eb98 --- /dev/null +++ b/chainapi.go @@ -0,0 +1,80 @@ +package chansummary + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +type chainApi struct { + baseUrl string +} + +type transaction struct { + Vin []*vin `json:"vin"` + Vout []*vout `json:"vout"` +} + +type vin struct { + Tixid string `json:"txid"` + Vout int `json:"vout"` + Prevout *vout `json:"prevout"` +} + +type vout struct { + ScriptPubkey string `json:"scriptpubkey"` + ScriptPubkeyAsm string `json:"scriptpubkey_asm"` + ScriptPubkeyType string `json:"scriptpubkey_type"` + ScriptPubkeyAddr string `json:"scriptpubkey_addr"` + Value uint64 `json:"value"` + outspend *outspend +} + +type outspend struct { + Spent bool `json:"spent"` + Txid string `json:"txid"` + Vin int `json:"vin"` + Status *status `json:"status"` +} + +type status struct { + Confirmed bool `json:"confirmed"` + BlockHeight int `json:"block_height"` + BlockHash string `json:"block_hash"` +} + +func (a *chainApi) Transaction(txid string) (*transaction, error) { + tx := &transaction{} + err := Fetch(fmt.Sprintf("%s/tx/%s", a.baseUrl, txid), tx) + if err != nil { + return nil, err + } + for idx, vout := range tx.Vout { + url := fmt.Sprintf( + "%s/tx/%s/outspend/%d", a.baseUrl, txid, idx, + ) + outspend := outspend{} + err := Fetch(url, &outspend) + if err != nil { + return nil, err + } + vout.outspend = &outspend + } + return tx, nil +} + +func Fetch(url string, target interface{}) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + body := new(bytes.Buffer) + _, err = body.ReadFrom(resp.Body) + if err != nil { + return err + } + return json.Unmarshal(body.Bytes(), target) +} diff --git a/chansummary.go b/chansummary.go new file mode 100644 index 0000000..74c3d28 --- /dev/null +++ b/chansummary.go @@ -0,0 +1,162 @@ +package chansummary + +import ( + "fmt" + "strconv" + "strings" +) + +type channel struct { + RemotePubkey string `json:"remote_pubkey"` + ChannelPoint string `json:"channel_point"` + Capacity string `json:"capacity"` + Initiator bool `json:"initiator"` + LocalBalance string `json:"local_balance"` + RemoteBalance string `json:"remote_balance"` +} + +func (c *channel) FundingTXID() string { + parts := strings.Split(c.ChannelPoint, ":") + if len(parts) != 2 { + panic(fmt.Errorf("channel point not in format :")) + } + return parts[0] +} + +func (c *channel) FundingTXIndex() int { + parts := strings.Split(c.ChannelPoint, ":") + if len(parts) != 2 { + panic(fmt.Errorf("channel point not in format :")) + } + return parseInt(parts[1]) +} + +func (c *channel) localBalance() uint64 { + return uint64(parseInt(c.LocalBalance)) +} + +func (c *channel) remoteBalance() uint64 { + return uint64(parseInt(c.RemoteBalance)) +} + +func collectChanSummary(cfg *config, channels []*channel) error { + var ( + chansClosed = 0 + chansOpen = 0 + valueUnspent = uint64(0) + valueSalvage = uint64(0) + valueSafe = uint64(0) + ) + + chainApi := &chainApi{baseUrl: cfg.ApiUrl} + + for idx, channel := range channels { + tx, err := chainApi.Transaction(channel.FundingTXID()) + if err != nil { + return err + } + outspend := tx.Vout[channel.FundingTXIndex()].outspend + if outspend.Spent { + chansClosed++ + + s, f, err := reportOutspend(chainApi, channel, outspend) + if err != nil { + return err + } + valueSalvage += s + valueSafe += f + } else { + chansOpen++ + valueUnspent += channel.localBalance() + } + + if idx%50 == 0 { + fmt.Printf("Queried channel %d of %d.\n", idx, + len(channels)) + } + } + + fmt.Printf("Finished scanning.\nClosed channels: %d\nOpen channels: "+ + "%d\nSats in open channels: %d\nSats that can possibly be "+ + "salvaged: %d\nSats in co-op close channels: %d\n", chansClosed, + chansOpen, valueUnspent, valueSalvage, valueSafe) + + return nil +} + +func reportOutspend(api *chainApi, ch *channel, os *outspend) (uint64, uint64, + error) { + + spendTx, err := api.Transaction(os.Txid) + if err != nil { + return 0, 0, err + } + + numSpent := 0 + salvageBalance := uint64(0) + safeBalance := uint64(0) + for _, vout := range spendTx.Vout { + if vout.outspend.Spent { + numSpent++ + } + } + if numSpent != len(spendTx.Vout) { + fmt.Printf("Channel %s spent by %s:%d which has %d outputs of "+ + "which %d are spent:\n", ch.ChannelPoint, os.Txid, + os.Vin, len(spendTx.Vout), numSpent) + var utxo []*vout + for _, vout := range spendTx.Vout { + if !vout.outspend.Spent { + utxo = append(utxo, vout) + } + } + + if salvageable(ch, utxo) { + salvageBalance += utxo[0].Value + + outs := spendTx.Vout + + switch { + case len(outs) == 1 && + outs[0].ScriptPubkeyType == "v0_p2wpkh" && + outs[0].outspend.Spent == false: + + safeBalance += utxo[0].Value + + case len(outs) == 2 && + outs[0].ScriptPubkeyType == "v0_p2wpkh" && + outs[1].ScriptPubkeyType == "v0_p2wpkh": + + safeBalance += utxo[0].Value + } + } else { + for idx, vout := range spendTx.Vout { + if !vout.outspend.Spent { + fmt.Printf("UTXO %d of type %s with "+ + "value %d\n", idx, + vout.ScriptPubkeyType, + vout.Value) + } + } + fmt.Printf("Local balance: %s\n", ch.LocalBalance) + fmt.Printf("Remote balance: %s\n", ch.RemoteBalance) + fmt.Printf("Initiator: %v\n", ch.Initiator) + } + + } + + return salvageBalance, safeBalance, nil +} + +func salvageable(ch *channel, utxo []*vout) bool { + return ch.localBalance() == utxo[0].Value || + ch.remoteBalance() == 0 +} + +func parseInt(str string) int { + index, err := strconv.Atoi(str) + if err != nil { + panic(fmt.Errorf("error parsing '%s' as int: %v", str, err)) + } + return index +} diff --git a/cmd/chansummary/main.go b/cmd/chansummary/main.go new file mode 100644 index 0000000..1bfc0f7 --- /dev/null +++ b/cmd/chansummary/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "fmt" + "os" + + "github.com/guggero/chansummary" +) + +func main() { + if err := chansummary.Main(); err != nil { + fmt.Printf("Error running chansummary: %v\n", err) + } + + os.Exit(0) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e3535fc --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/guggero/chansummary + +require github.com/jessevdk/go-flags v1.4.0 + +go 1.13 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1b3c118 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= diff --git a/main.go b/main.go new file mode 100644 index 0000000..c541c28 --- /dev/null +++ b/main.go @@ -0,0 +1,54 @@ +package chansummary + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/jessevdk/go-flags" + "io/ioutil" +) + +const ( + defaultApiUrl = "https://blockstream.info/api" +) + +type config struct { + ApiUrl string `long:"apiurl" description:"API URL to use (must be esplora compatible)"` +} + +type fileContent struct { + Channels []*channel `json:"channels"` +} + +func Main() error { + var ( + err error + args []string + ) + + // Parse command line. + config := &config{ + ApiUrl: defaultApiUrl, + } + if args, err = flags.Parse(config); err != nil { + return err + } + if len(args) != 1 { + return fmt.Errorf("exactly one file argument needed") + } + file := args[0] + + // Read file and parse into channel. + content, err := ioutil.ReadFile(file) + if err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(content)) + channels := fileContent{} + err = decoder.Decode(&channels) + if err != nil { + return err + } + + return collectChanSummary(config, channels.Channels) +}