From 2774bcee412e201b2dbfbabbe1d9f172413d6d3b Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Sat, 9 Nov 2019 15:41:31 +0100 Subject: [PATCH] Initial commit --- .gitignore | 1 + .golangci.yml | 24 ++++++ LICENSE | 21 ++++++ Makefile | 49 ++++++++++++ README.md | 7 ++ chainapi.go | 80 ++++++++++++++++++++ chansummary.go | 162 ++++++++++++++++++++++++++++++++++++++++ cmd/chansummary/main.go | 16 ++++ go.mod | 5 ++ go.sum | 2 + main.go | 54 ++++++++++++++ 11 files changed, 421 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 chainapi.go create mode 100644 chansummary.go create mode 100644 cmd/chansummary/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go 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) +}