Miner finder for interactive client deal CLI

This commit is contained in:
Łukasz Magiera 2020-10-20 21:54:19 +02:00
parent c779e4e8ee
commit dc40469cb3
3 changed files with 406 additions and 96 deletions

View File

@ -1,19 +1,25 @@
package cli package cli
import ( import (
"bufio"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math/rand"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings"
"sync"
"sync/atomic"
"text/tabwriter" "text/tabwriter"
"time" "time"
tm "github.com/buger/goterm" tm "github.com/buger/goterm"
"github.com/chzyer/readline"
"github.com/docker/go-units" "github.com/docker/go-units"
"github.com/fatih/color" "github.com/fatih/color"
datatransfer "github.com/filecoin-project/go-data-transfer" datatransfer "github.com/filecoin-project/go-data-transfer"
@ -74,6 +80,7 @@ var clientCmd = &cli.Command{
WithCategory("storage", clientQueryAskCmd), WithCategory("storage", clientQueryAskCmd),
WithCategory("storage", clientListDeals), WithCategory("storage", clientListDeals),
WithCategory("storage", clientGetDealCmd), WithCategory("storage", clientGetDealCmd),
WithCategory("storage", clientListAsksCmd),
WithCategory("data", clientImportCmd), WithCategory("data", clientImportCmd),
WithCategory("data", clientDropCmd), WithCategory("data", clientDropCmd),
WithCategory("data", clientLocalCmd), WithCategory("data", clientLocalCmd),
@ -468,16 +475,26 @@ func interactiveDeal(cctx *cli.Context) error {
} }
defer closer() defer closer()
ctx := ReqContext(cctx) ctx := ReqContext(cctx)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
state := "import" state := "import"
gib := types.NewInt(1 << 30)
var data cid.Cid var data cid.Cid
var days int var days int
var maddr address.Address var maddrs []address.Address
var ask storagemarket.StorageAsk var ask []storagemarket.StorageAsk
var epochPrice big.Int var epochPrices []big.Int
var dur time.Duration
var epochs abi.ChainEpoch var epochs abi.ChainEpoch
var verified bool var verified bool
var ds lapi.DataSize
// find
var candidateAsks []*storagemarket.StorageAsk
var budget types.FIL
var dealCount int64
var a address.Address var a address.Address
if from := cctx.String("from"); from != "" { if from := cctx.String("from"); from != "" {
@ -494,10 +511,24 @@ func interactiveDeal(cctx *cli.Context) error {
a = def a = def
} }
fromBal, err := api.WalletBalance(ctx, a)
if err != nil {
return xerrors.Errorf("checking from address balance: %w", err)
}
printErr := func(err error) { printErr := func(err error) {
fmt.Printf("%s %s\n", color.RedString("Error:"), err.Error()) fmt.Printf("%s %s\n", color.RedString("Error:"), err.Error())
} }
cs := readline.NewCancelableStdin(os.Stdin)
go func() {
<-ctx.Done()
cs.Close() // nolint:errcheck
}()
rl := bufio.NewReader(cs)
uiLoop:
for { for {
// TODO: better exit handling // TODO: better exit handling
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
@ -508,8 +539,8 @@ func interactiveDeal(cctx *cli.Context) error {
case "import": case "import":
fmt.Print("Data CID (from " + color.YellowString("lotus client import") + "): ") fmt.Print("Data CID (from " + color.YellowString("lotus client import") + "): ")
var cidStr string _cidStr, _, err := rl.ReadLine()
_, err := fmt.Scan(&cidStr) cidStr := string(_cidStr)
if err != nil { if err != nil {
printErr(xerrors.Errorf("reading cid string: %w", err)) printErr(xerrors.Errorf("reading cid string: %w", err))
continue continue
@ -521,11 +552,23 @@ func interactiveDeal(cctx *cli.Context) error {
continue continue
} }
color.Blue(".. calculating data size\n")
ds, err = api.ClientDealSize(ctx, data)
if err != nil {
return err
}
state = "duration" state = "duration"
case "duration": case "duration":
fmt.Print("Deal duration (days): ") fmt.Print("Deal duration (days): ")
_, err := fmt.Scan(&days) _daystr, _, err := rl.ReadLine()
daystr := string(_daystr)
if err != nil {
return err
}
_, err = fmt.Sscan(daystr, &days)
if err != nil { if err != nil {
printErr(xerrors.Errorf("parsing duration: %w", err)) printErr(xerrors.Errorf("parsing duration: %w", err))
continue continue
@ -536,44 +579,9 @@ func interactiveDeal(cctx *cli.Context) error {
continue continue
} }
state = "miner" dur = 24 * time.Hour * time.Duration(days)
case "miner": epochs = abi.ChainEpoch(dur / (time.Duration(build.BlockDelaySecs) * time.Second))
fmt.Print("Miner Address (f0..): ")
var maddrStr string
_, err := fmt.Scan(&maddrStr)
if err != nil {
printErr(xerrors.Errorf("reading miner address: %w", err))
continue
}
maddr, err = address.NewFromString(maddrStr)
if err != nil {
printErr(xerrors.Errorf("parsing miner address: %w", err))
continue
}
state = "query"
case "query":
color.Blue(".. querying miner ask")
mi, err := api.StateMinerInfo(ctx, maddr, types.EmptyTSK)
if err != nil {
printErr(xerrors.Errorf("failed to get peerID for miner: %w", err))
state = "miner"
continue
}
a, err := api.ClientQueryAsk(ctx, *mi.PeerId, maddr)
if err != nil {
printErr(xerrors.Errorf("failed to query ask: %w", err))
state = "miner"
continue
}
ask = *a
// TODO: run more validation
state = "verified" state = "verified"
case "verified": case "verified":
ts, err := api.ChainHead(ctx) ts, err := api.ChainHead(ctx)
@ -587,26 +595,20 @@ func interactiveDeal(cctx *cli.Context) error {
} }
if dcap == nil { if dcap == nil {
state = "confirm" state = "miner"
continue continue
} }
color.Blue(".. checking verified deal eligibility\n")
ds, err := api.ClientDealSize(ctx, data)
if err != nil {
return err
}
if dcap.Uint64() < uint64(ds.PieceSize) { if dcap.Uint64() < uint64(ds.PieceSize) {
color.Yellow(".. not enough DataCap available for a verified deal\n") color.Yellow(".. not enough DataCap available for a verified deal\n")
state = "confirm" state = "miner"
continue continue
} }
fmt.Print("\nMake this a verified deal? (yes/no): ") fmt.Print("\nMake this a verified deal? (yes/no): ")
var yn string _yn, _, err := rl.ReadLine()
_, err = fmt.Scan(&yn) yn := string(_yn)
if err != nil { if err != nil {
return err return err
} }
@ -621,50 +623,204 @@ func interactiveDeal(cctx *cli.Context) error {
continue continue
} }
state = "confirm" state = "miner"
case "confirm": case "miner":
fromBal, err := api.WalletBalance(ctx, a) fmt.Print("Miner Addresses (f0.. f0..), none to find: ")
_maddrsStr, _, err := rl.ReadLine()
maddrsStr := string(_maddrsStr)
if err != nil { if err != nil {
return xerrors.Errorf("checking from address balance: %w", err) printErr(xerrors.Errorf("reading miner address: %w", err))
continue
} }
color.Blue(".. calculating data size\n") for _, s := range strings.Fields(maddrsStr) {
ds, err := api.ClientDealSize(ctx, data) maddr, err := address.NewFromString(strings.TrimSpace(s))
if err != nil {
printErr(xerrors.Errorf("parsing miner address: %w", err))
continue uiLoop
}
maddrs = append(maddrs, maddr)
}
state = "query"
if len(maddrs) == 0 {
state = "find"
}
case "find":
asks, err := getAsks(ctx, api)
if err != nil { if err != nil {
return err return err
} }
dur := 24 * time.Hour * time.Duration(days) for _, ask := range asks {
if ask.MinPieceSize > ds.PieceSize {
epochs = abi.ChainEpoch(dur / (time.Duration(build.BlockDelaySecs) * time.Second)) continue
// TODO: do some more or epochs math (round to miner PP, deal start buffer) }
if ask.MaxPieceSize < ds.PieceSize {
pricePerGib := ask.Price continue
if verified { }
pricePerGib = ask.VerifiedPrice candidateAsks = append(candidateAsks, ask)
} }
gib := types.NewInt(1 << 30) fmt.Printf("Found %d candidate asks\n", len(candidateAsks))
state = "find-budget"
case "find-budget":
fmt.Printf("Proposing from %s, Current Balance: %s\n", a, types.FIL(fromBal))
fmt.Print("Maximum budget (FIL): ") // TODO: Propose some default somehow?
// TODO: price is based on PaddedPieceSize, right? _budgetStr, _, err := rl.ReadLine()
epochPrice = types.BigDiv(types.BigMul(pricePerGib, types.NewInt(uint64(ds.PieceSize))), gib) budgetStr := string(_budgetStr)
if err != nil {
printErr(xerrors.Errorf("reading miner address: %w", err))
continue
}
budget, err = types.ParseFIL(budgetStr)
if err != nil {
printErr(xerrors.Errorf("parsing FIL: %w", err))
continue uiLoop
}
var goodAsks []*storagemarket.StorageAsk
for _, ask := range candidateAsks {
p := ask.Price
if verified {
p = ask.VerifiedPrice
}
epochPrice := types.BigDiv(types.BigMul(p, types.NewInt(uint64(ds.PieceSize))), gib)
totalPrice := types.BigMul(epochPrice, types.NewInt(uint64(epochs))) totalPrice := types.BigMul(epochPrice, types.NewInt(uint64(epochs)))
if totalPrice.LessThan(abi.TokenAmount(budget)) {
goodAsks = append(goodAsks, ask)
}
}
candidateAsks = goodAsks
fmt.Printf("%d asks within budget\n", len(candidateAsks))
state = "find-count"
case "find-count":
fmt.Print("Deals to make (1): ")
dealcStr, _, err := rl.ReadLine()
if err != nil {
printErr(xerrors.Errorf("reading deal count: %w", err))
continue
}
dealCount, err = strconv.ParseInt(string(dealcStr), 10, 64)
if err != nil {
return err
}
color.Blue(".. Picking miners")
// TODO: some better strategy (this tries to pick randomly)
var pickedAsks []*storagemarket.StorageAsk
pickLoop:
for i := 0; i < 64; i++ {
rand.Shuffle(len(candidateAsks), func(i, j int) {
candidateAsks[i], candidateAsks[j] = candidateAsks[j], candidateAsks[i]
})
remainingBudget := abi.TokenAmount(budget)
pickedAsks = []*storagemarket.StorageAsk{}
for _, ask := range candidateAsks {
p := ask.Price
if verified {
p = ask.VerifiedPrice
}
epochPrice := types.BigDiv(types.BigMul(p, types.NewInt(uint64(ds.PieceSize))), gib)
totalPrice := types.BigMul(epochPrice, types.NewInt(uint64(epochs)))
if totalPrice.GreaterThan(remainingBudget) {
continue
}
pickedAsks = append(pickedAsks, ask)
remainingBudget = big.Sub(remainingBudget, totalPrice)
if len(pickedAsks) == int(dealCount) {
break pickLoop
}
}
}
for _, pickedAsk := range pickedAsks {
maddrs = append(maddrs, pickedAsk.Miner)
ask = append(ask, *pickedAsk)
}
state = "confirm"
case "query":
color.Blue(".. querying miner asks")
for _, maddr := range maddrs {
mi, err := api.StateMinerInfo(ctx, maddr, types.EmptyTSK)
if err != nil {
printErr(xerrors.Errorf("failed to get peerID for miner: %w", err))
state = "miner"
continue uiLoop
}
a, err := api.ClientQueryAsk(ctx, *mi.PeerId, maddr)
if err != nil {
printErr(xerrors.Errorf("failed to query ask: %w", err))
state = "miner"
continue uiLoop
}
ask = append(ask, *a)
}
// TODO: run more validation
state = "confirm"
case "confirm":
// TODO: do some more or epochs math (round to miner PP, deal start buffer)
fmt.Printf("-----\n") fmt.Printf("-----\n")
fmt.Printf("Proposing from %s\n", a) fmt.Printf("Proposing from %s\n", a)
fmt.Printf("\tBalance: %s\n", types.FIL(fromBal)) fmt.Printf("\tBalance: %s\n", types.FIL(fromBal))
fmt.Printf("\n") fmt.Printf("\n")
fmt.Printf("Piece size: %s (Payload size: %s)\n", units.BytesSize(float64(ds.PieceSize)), units.BytesSize(float64(ds.PayloadSize))) fmt.Printf("Piece size: %s (Payload size: %s)\n", units.BytesSize(float64(ds.PieceSize)), units.BytesSize(float64(ds.PayloadSize)))
fmt.Printf("Duration: %s\n", dur) fmt.Printf("Duration: %s\n", dur)
fmt.Printf("Total price: ~%s (%s per epoch)\n", types.FIL(totalPrice), types.FIL(epochPrice))
pricePerGib := big.Zero()
for _, a := range ask {
p := a.Price
if verified {
p = a.VerifiedPrice
}
pricePerGib = big.Add(pricePerGib, p)
epochPrice := types.BigDiv(types.BigMul(p, types.NewInt(uint64(ds.PieceSize))), gib)
epochPrices = append(epochPrices, epochPrice)
mpow, err := api.StateMinerPower(ctx, a.Miner, types.EmptyTSK)
if err != nil {
return xerrors.Errorf("getting power (%s): %w", a.Miner, err)
}
if len(ask) > 1 {
totalPrice := types.BigMul(epochPrice, types.NewInt(uint64(epochs)))
fmt.Printf("Miner %s (Power:%s) price: ~%s (%s per epoch)\n", color.YellowString(a.Miner.String()), color.GreenString(types.SizeStr(mpow.MinerPower.QualityAdjPower)), color.BlueString(types.FIL(totalPrice).String()), types.FIL(epochPrice))
}
}
// TODO: price is based on PaddedPieceSize, right?
epochPrice := types.BigDiv(types.BigMul(pricePerGib, types.NewInt(uint64(ds.PieceSize))), gib)
totalPrice := types.BigMul(epochPrice, types.NewInt(uint64(epochs)))
fmt.Printf("Total price: ~%s (%s per epoch)\n", color.CyanString(types.FIL(totalPrice).String()), types.FIL(epochPrice))
fmt.Printf("Verified: %v\n", verified) fmt.Printf("Verified: %v\n", verified)
state = "accept" state = "accept"
case "accept": case "accept":
fmt.Print("\nAccept (yes/no): ") fmt.Print("\nAccept (yes/no): ")
var yn string _yn, _, err := rl.ReadLine()
_, err := fmt.Scan(&yn) yn := string(_yn)
if err != nil { if err != nil {
return err return err
} }
@ -680,7 +836,9 @@ func interactiveDeal(cctx *cli.Context) error {
state = "execute" state = "execute"
case "execute": case "execute":
color.Blue(".. executing") color.Blue(".. executing\n")
for i, maddr := range maddrs {
proposal, err := api.ClientStartDeal(ctx, &lapi.StartDealParams{ proposal, err := api.ClientStartDeal(ctx, &lapi.StartDealParams{
Data: &storagemarket.DataRef{ Data: &storagemarket.DataRef{
TransferType: storagemarket.TTGraphsync, TransferType: storagemarket.TTGraphsync,
@ -688,7 +846,7 @@ func interactiveDeal(cctx *cli.Context) error {
}, },
Wallet: a, Wallet: a,
Miner: maddr, Miner: maddr,
EpochPrice: epochPrice, EpochPrice: epochPrices[i],
MinBlocksDuration: uint64(epochs), MinBlocksDuration: uint64(epochs),
DealStartEpoch: abi.ChainEpoch(cctx.Int64("start-epoch")), DealStartEpoch: abi.ChainEpoch(cctx.Int64("start-epoch")),
FastRetrieval: cctx.Bool("fast-retrieval"), FastRetrieval: cctx.Bool("fast-retrieval"),
@ -703,7 +861,9 @@ func interactiveDeal(cctx *cli.Context) error {
return err return err
} }
fmt.Println("\nDeal CID:", color.GreenString(encoder.Encode(*proposal))) fmt.Printf("Deal (%s) CID: %s\n", maddr, color.GreenString(encoder.Encode(*proposal)))
}
return nil return nil
default: default:
return xerrors.Errorf("unknown state: %s", state) return xerrors.Errorf("unknown state: %s", state)
@ -944,6 +1104,152 @@ var clientRetrieveCmd = &cli.Command{
}, },
} }
var clientListAsksCmd = &cli.Command{
Name: "list-asks",
Usage: "List asks for top miners",
Action: func(cctx *cli.Context) error {
api, closer, err := GetFullNodeAPI(cctx)
if err != nil {
return err
}
defer closer()
ctx := ReqContext(cctx)
asks, err := getAsks(ctx, api)
if err != nil {
return err
}
for _, ask := range asks {
fmt.Printf("%s: min:%s max:%s price:%s/GiB/Epoch verifiedPrice:%s/GiB/Epoch\n", ask.Miner,
types.SizeStr(types.NewInt(uint64(ask.MinPieceSize))),
types.SizeStr(types.NewInt(uint64(ask.MaxPieceSize))),
types.FIL(ask.Price),
types.FIL(ask.VerifiedPrice),
)
}
return nil
},
}
func getAsks(ctx context.Context, api lapi.FullNode) ([]*storagemarket.StorageAsk, error) {
color.Blue(".. getting miner list")
miners, err := api.StateListMiners(ctx, types.EmptyTSK)
if err != nil {
return nil, xerrors.Errorf("getting miner list: %w", err)
}
var lk sync.Mutex
var found int64
var withMinPower []address.Address
done := make(chan struct{})
go func() {
defer close(done)
var wg sync.WaitGroup
wg.Add(len(miners))
throttle := make(chan struct{}, 50)
for _, miner := range miners {
throttle <- struct{}{}
go func(miner address.Address) {
defer wg.Done()
defer func() {
<-throttle
}()
power, err := api.StateMinerPower(ctx, miner, types.EmptyTSK)
if err != nil {
return
}
if power.HasMinPower { // TODO: Lower threshold
atomic.AddInt64(&found, 1)
lk.Lock()
withMinPower = append(withMinPower, miner)
lk.Unlock()
}
}(miner)
}
}()
loop:
for {
select {
case <-time.After(150 * time.Millisecond):
fmt.Printf("\r* Found %d miners with power", atomic.LoadInt64(&found))
case <-done:
break loop
}
}
fmt.Printf("\r* Found %d miners with power\n", atomic.LoadInt64(&found))
color.Blue(".. querying asks")
var asks []*storagemarket.StorageAsk
var queried, got int64
done = make(chan struct{})
go func() {
defer close(done)
var wg sync.WaitGroup
wg.Add(len(withMinPower))
throttle := make(chan struct{}, 50)
for _, miner := range withMinPower {
throttle <- struct{}{}
go func(miner address.Address) {
defer wg.Done()
defer func() {
<-throttle
atomic.AddInt64(&queried, 1)
}()
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
mi, err := api.StateMinerInfo(ctx, miner, types.EmptyTSK)
if err != nil {
return
}
if mi.PeerId == nil {
return
}
ask, err := api.ClientQueryAsk(ctx, *mi.PeerId, miner)
if err != nil {
return
}
atomic.AddInt64(&got, 1)
lk.Lock()
asks = append(asks, ask)
lk.Unlock()
}(miner)
}
}()
loop2:
for {
select {
case <-time.After(150 * time.Millisecond):
fmt.Printf("\r* Queried %d asks, got %d responses", atomic.LoadInt64(&queried), atomic.LoadInt64(&got))
case <-done:
break loop2
}
}
fmt.Printf("\r* Queried %d asks, got %d responses\n", atomic.LoadInt64(&queried), atomic.LoadInt64(&got))
sort.Slice(asks, func(i, j int) bool {
return asks[i].Price.LessThan(asks[j].Price)
})
return asks, nil
}
var clientQueryAskCmd = &cli.Command{ var clientQueryAskCmd = &cli.Command{
Name: "query-ask", Name: "query-ask",
Usage: "Find a miners ask", Usage: "Find a miners ask",

1
go.mod
View File

@ -11,6 +11,7 @@ require (
github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d // indirect github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d // indirect
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
github.com/buger/goterm v0.0.0-20200322175922-2f3e71b85129 github.com/buger/goterm v0.0.0-20200322175922-2f3e71b85129
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
github.com/cockroachdb/pebble v0.0.0-20200916222308-4e219a90ba5b github.com/cockroachdb/pebble v0.0.0-20200916222308-4e219a90ba5b
github.com/coreos/go-systemd/v22 v22.0.0 github.com/coreos/go-systemd/v22 v22.0.0
github.com/detailyang/go-fallocate v0.0.0-20180908115635-432fa640bd2e github.com/detailyang/go-fallocate v0.0.0-20180908115635-432fa640bd2e

3
go.sum
View File

@ -120,8 +120,11 @@ github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE= github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE=
github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=