lotus/cli/client.go

1562 lines
36 KiB
Go
Raw Permalink Normal View History

2019-07-12 10:17:16 +00:00
package cli
import (
2020-08-27 18:32:51 +00:00
"context"
"encoding/json"
"errors"
2019-07-12 10:17:16 +00:00
"fmt"
"io"
2019-11-08 01:45:45 +00:00
"os"
2019-10-23 07:05:22 +00:00
"path/filepath"
2020-07-07 11:45:02 +00:00
"sort"
2019-08-02 14:09:54 +00:00
"strconv"
2019-11-08 01:45:45 +00:00
"text/tabwriter"
2020-07-31 16:22:04 +00:00
"time"
2019-07-16 16:07:08 +00:00
tm "github.com/buger/goterm"
2020-07-31 16:22:04 +00:00
"github.com/docker/go-units"
2020-07-28 23:10:57 +00:00
"github.com/fatih/color"
datatransfer "github.com/filecoin-project/go-data-transfer"
2020-08-11 20:04:00 +00:00
"github.com/filecoin-project/go-fil-markets/retrievalmarket"
"github.com/filecoin-project/specs-actors/actors/builtin"
2019-08-02 14:09:54 +00:00
"github.com/ipfs/go-cid"
2020-06-05 21:22:29 +00:00
"github.com/ipfs/go-cidutil/cidenc"
2019-09-13 21:00:36 +00:00
"github.com/libp2p/go-libp2p-core/peer"
2020-06-05 21:22:29 +00:00
"github.com/multiformats/go-multibase"
"github.com/urfave/cli/v2"
2020-06-05 22:59:01 +00:00
"golang.org/x/xerrors"
2019-08-02 14:09:54 +00:00
"github.com/filecoin-project/go-address"
"github.com/filecoin-project/go-fil-markets/storagemarket"
"github.com/filecoin-project/go-multistore"
2020-09-07 03:49:10 +00:00
"github.com/filecoin-project/go-state-types/abi"
"github.com/filecoin-project/go-state-types/big"
2020-02-13 03:50:37 +00:00
"github.com/filecoin-project/lotus/api"
lapi "github.com/filecoin-project/lotus/api"
2020-07-31 16:22:04 +00:00
"github.com/filecoin-project/lotus/build"
2020-09-17 08:17:14 +00:00
"github.com/filecoin-project/lotus/chain/actors/builtin/market"
"github.com/filecoin-project/lotus/chain/types"
2020-08-04 17:07:17 +00:00
"github.com/filecoin-project/lotus/lib/tablewriter"
2019-07-12 10:17:16 +00:00
)
2020-06-05 21:22:29 +00:00
var CidBaseFlag = cli.StringFlag{
Name: "cid-base",
Hidden: true,
Value: "base32",
Usage: "Multibase encoding used for version 1 CIDs in output.",
DefaultText: "base32",
}
// GetCidEncoder returns an encoder using the `cid-base` flag if provided, or
// the default (Base32) encoder if not.
func GetCidEncoder(cctx *cli.Context) (cidenc.Encoder, error) {
val := cctx.String("cid-base")
e := cidenc.Encoder{Base: multibase.MustNewEncoder(multibase.Base32)}
if val != "" {
var err error
e.Base, err = multibase.EncoderByName(val)
if err != nil {
return e, err
}
}
return e, nil
}
2019-07-12 10:17:16 +00:00
var clientCmd = &cli.Command{
Name: "client",
Usage: "Make deals, store data, retrieve data",
Subcommands: []*cli.Command{
2020-07-24 01:52:29 +00:00
WithCategory("storage", clientDealCmd),
WithCategory("storage", clientQueryAskCmd),
WithCategory("storage", clientListDeals),
WithCategory("storage", clientGetDealCmd),
WithCategory("data", clientImportCmd),
WithCategory("data", clientDropCmd),
WithCategory("data", clientLocalCmd),
WithCategory("retrieval", clientFindCmd),
WithCategory("retrieval", clientRetrieveCmd),
WithCategory("util", clientCommPCmd),
WithCategory("util", clientCarGenCmd),
WithCategory("util", clientInfoCmd),
WithCategory("util", clientListTransfers),
WithCategory("util", clientRestartTransfer),
2019-07-12 10:17:16 +00:00
},
}
var clientImportCmd = &cli.Command{
2020-03-06 19:01:28 +00:00
Name: "import",
Usage: "Import data",
ArgsUsage: "[inputPath]",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "car",
Usage: "import from a car file instead of a regular file",
},
2020-07-07 11:45:02 +00:00
&cli.BoolFlag{
Name: "quiet",
Aliases: []string{"q"},
Usage: "Output root CID only",
},
2020-06-05 21:22:29 +00:00
&CidBaseFlag,
},
2019-07-12 10:17:16 +00:00
Action: func(cctx *cli.Context) error {
2019-10-03 18:12:30 +00:00
api, closer, err := GetFullNodeAPI(cctx)
2019-07-12 10:17:16 +00:00
if err != nil {
return err
}
2019-10-03 18:12:30 +00:00
defer closer()
2019-07-18 23:16:23 +00:00
ctx := ReqContext(cctx)
2020-07-13 05:13:29 +00:00
if cctx.NArg() != 1 {
return xerrors.New("expected input path as the only arg")
}
2019-10-23 07:05:22 +00:00
absPath, err := filepath.Abs(cctx.Args().First())
if err != nil {
return err
}
2019-07-12 10:17:16 +00:00
ref := lapi.FileRef{
Path: absPath,
IsCAR: cctx.Bool("car"),
}
c, err := api.ClientImport(ctx, ref)
2019-07-12 10:17:16 +00:00
if err != nil {
return err
}
encoder, err := GetCidEncoder(cctx)
if err != nil {
return err
}
2020-07-07 11:45:02 +00:00
if !cctx.Bool("quiet") {
fmt.Printf("Import %d, Root ", c.ImportID)
}
fmt.Println(encoder.Encode(c.Root))
return nil
},
}
var clientDropCmd = &cli.Command{
Name: "drop",
Usage: "Remove import",
ArgsUsage: "[import ID...]",
Action: func(cctx *cli.Context) error {
if !cctx.Args().Present() {
return xerrors.Errorf("no imports specified")
}
api, closer, err := GetFullNodeAPI(cctx)
if err != nil {
return err
}
defer closer()
ctx := ReqContext(cctx)
var ids []multistore.StoreID
2020-07-07 11:45:02 +00:00
for i, s := range cctx.Args().Slice() {
id, err := strconv.ParseInt(s, 10, 0)
2020-07-07 11:45:02 +00:00
if err != nil {
return xerrors.Errorf("parsing %d-th import ID: %w", i, err)
}
ids = append(ids, multistore.StoreID(id))
2020-07-07 11:45:02 +00:00
}
for _, id := range ids {
if err := api.ClientRemoveImport(ctx, id); err != nil {
return xerrors.Errorf("removing import %d: %w", id, err)
}
}
2019-07-12 10:17:16 +00:00
return nil
},
2019-07-12 10:44:01 +00:00
}
var clientCommPCmd = &cli.Command{
Name: "commP",
2020-07-24 01:52:29 +00:00
Usage: "Calculate the piece-cid (commP) of a CAR file",
ArgsUsage: "[inputFile]",
2020-06-05 21:22:29 +00:00
Flags: []cli.Flag{
&CidBaseFlag,
},
Action: func(cctx *cli.Context) error {
api, closer, err := GetFullNodeAPI(cctx)
if err != nil {
return err
}
defer closer()
ctx := ReqContext(cctx)
if cctx.Args().Len() != 1 {
return fmt.Errorf("usage: commP <inputPath>")
}
ret, err := api.ClientCalcCommP(ctx, cctx.Args().Get(0))
if err != nil {
return err
}
encoder, err := GetCidEncoder(cctx)
if err != nil {
return err
}
fmt.Println("CID: ", encoder.Encode(ret.Root))
fmt.Println("Piece size: ", types.SizeStr(types.NewInt(uint64(ret.Size))))
return nil
},
}
var clientCarGenCmd = &cli.Command{
Name: "generate-car",
2020-07-24 01:52:29 +00:00
Usage: "Generate a car file from input",
ArgsUsage: "[inputPath outputPath]",
Action: func(cctx *cli.Context) error {
api, closer, err := GetFullNodeAPI(cctx)
if err != nil {
return err
}
defer closer()
ctx := ReqContext(cctx)
if cctx.Args().Len() != 2 {
return fmt.Errorf("usage: generate-car <inputPath> <outputPath>")
}
ref := lapi.FileRef{
Path: cctx.Args().First(),
IsCAR: false,
}
op := cctx.Args().Get(1)
if err = api.ClientGenCar(ctx, ref, op); err != nil {
return err
}
return nil
},
}
2019-07-12 10:44:01 +00:00
var clientLocalCmd = &cli.Command{
Name: "local",
Usage: "List locally imported data",
2020-06-05 21:22:29 +00:00
Flags: []cli.Flag{
&CidBaseFlag,
},
2019-07-12 10:44:01 +00:00
Action: func(cctx *cli.Context) error {
2019-10-03 18:12:30 +00:00
api, closer, err := GetFullNodeAPI(cctx)
2019-07-12 10:44:01 +00:00
if err != nil {
return err
}
2019-10-03 18:12:30 +00:00
defer closer()
2019-07-18 23:16:23 +00:00
ctx := ReqContext(cctx)
2019-07-12 10:44:01 +00:00
list, err := api.ClientListImports(ctx)
if err != nil {
return err
}
encoder, err := GetCidEncoder(cctx)
if err != nil {
return err
}
2020-07-07 11:45:02 +00:00
sort.Slice(list, func(i, j int) bool {
return list[i].Key < list[j].Key
})
2019-07-12 10:44:01 +00:00
for _, v := range list {
2020-07-07 08:52:19 +00:00
cidStr := "<nil>"
if v.Root != nil {
cidStr = encoder.Encode(*v.Root)
}
fmt.Printf("%d: %s @%s (%s)\n", v.Key, cidStr, v.FilePath, v.Source)
2020-07-07 09:12:32 +00:00
if v.Err != "" {
2020-07-07 08:52:19 +00:00
fmt.Printf("\terror: %s\n", v.Err)
}
2019-07-12 10:44:01 +00:00
}
return nil
},
}
2019-08-02 14:09:54 +00:00
var clientDealCmd = &cli.Command{
2020-03-06 19:01:28 +00:00
Name: "deal",
Usage: "Initialize storage deal with a miner",
ArgsUsage: "[dataCid miner price duration]",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "manual-piece-cid",
Usage: "manually specify piece commitment for data (dataCid must be to a car file)",
},
&cli.Int64Flag{
Name: "manual-piece-size",
Usage: "if manually specifying piece cid, used to specify size (dataCid must be to a car file)",
},
&cli.StringFlag{
Name: "from",
Usage: "specify address to fund the deal with",
},
2020-04-30 17:42:16 +00:00
&cli.Int64Flag{
Name: "start-epoch",
Usage: "specify the epoch that the deal should start at",
Value: -1,
},
&cli.BoolFlag{
Name: "fast-retrieval",
Usage: "indicates that data should be available for fast retrieval",
Value: true,
},
&cli.BoolFlag{
Name: "verified-deal",
Usage: "indicate that the deal counts towards verified client total",
Value: false,
},
&cli.StringFlag{
Name: "provider-collateral",
Usage: "specify the requested provider collateral the miner should put up",
},
2020-06-05 21:22:29 +00:00
&CidBaseFlag,
},
2019-08-02 14:09:54 +00:00
Action: func(cctx *cli.Context) error {
2020-07-31 16:22:04 +00:00
if !cctx.Args().Present() {
return interactiveDeal(cctx)
}
2019-10-03 18:12:30 +00:00
api, closer, err := GetFullNodeAPI(cctx)
2019-08-02 14:09:54 +00:00
if err != nil {
return err
}
2019-10-03 18:12:30 +00:00
defer closer()
2019-08-02 14:09:54 +00:00
ctx := ReqContext(cctx)
2019-08-07 20:06:10 +00:00
if cctx.NArg() != 4 {
return xerrors.New("expected 4 args: dataCid, miner, price, duration")
2019-08-02 14:09:54 +00:00
}
// [data, miner, price, dur]
2019-08-02 14:09:54 +00:00
data, err := cid.Parse(cctx.Args().Get(0))
if err != nil {
return err
}
miner, err := address.NewFromString(cctx.Args().Get(1))
if err != nil {
return err
}
2019-10-29 12:02:13 +00:00
price, err := types.ParseFIL(cctx.Args().Get(2))
2019-08-02 14:09:54 +00:00
if err != nil {
return err
}
2019-08-07 20:06:10 +00:00
dur, err := strconv.ParseInt(cctx.Args().Get(3), 10, 32)
if err != nil {
return err
}
var provCol big.Int
2020-08-14 02:14:45 +00:00
if pcs := cctx.String("provider-collateral"); pcs != "" {
pc, err := big.FromString(pcs)
if err != nil {
return fmt.Errorf("failed to parse provider-collateral: %w", err)
}
provCol = pc
}
2020-07-28 17:51:47 +00:00
if abi.ChainEpoch(dur) < build.MinDealDuration {
return xerrors.Errorf("minimum deal duration is %d blocks", build.MinDealDuration)
}
var a address.Address
if from := cctx.String("from"); from != "" {
faddr, err := address.NewFromString(from)
if err != nil {
return xerrors.Errorf("failed to parse 'from' address: %w", err)
}
a = faddr
} else {
def, err := api.WalletDefaultAddress(ctx)
if err != nil {
return err
}
a = def
}
ref := &storagemarket.DataRef{
TransferType: storagemarket.TTGraphsync,
Root: data,
}
if mpc := cctx.String("manual-piece-cid"); mpc != "" {
c, err := cid.Parse(mpc)
if err != nil {
return xerrors.Errorf("failed to parse provided manual piece cid: %w", err)
}
ref.PieceCid = &c
psize := cctx.Int64("manual-piece-size")
if psize == 0 {
return xerrors.Errorf("must specify piece size when manually setting cid")
}
ref.PieceSize = abi.UnpaddedPieceSize(psize)
ref.TransferType = storagemarket.TTManual
}
// Check if the address is a verified client
dcap, err := api.StateVerifiedClientStatus(ctx, a, types.EmptyTSK)
if err != nil {
return err
}
2020-09-17 15:30:15 +00:00
isVerified := dcap != nil
// If the user has explicitly set the --verified-deal flag
if cctx.IsSet("verified-deal") {
// If --verified-deal is true, but the address is not a verified
// client, return an error
verifiedDealParam := cctx.Bool("verified-deal")
if verifiedDealParam && !isVerified {
return xerrors.Errorf("address %s does not have verified client status", a)
}
// Override the default
isVerified = verifiedDealParam
}
proposal, err := api.ClientStartDeal(ctx, &lapi.StartDealParams{
Data: ref,
Wallet: a,
Miner: miner,
EpochPrice: types.BigInt(price),
MinBlocksDuration: uint64(dur),
DealStartEpoch: abi.ChainEpoch(cctx.Int64("start-epoch")),
FastRetrieval: cctx.Bool("fast-retrieval"),
VerifiedDeal: isVerified,
ProviderCollateral: provCol,
})
2019-08-07 20:06:10 +00:00
if err != nil {
return err
}
encoder, err := GetCidEncoder(cctx)
if err != nil {
return err
}
fmt.Println(encoder.Encode(*proposal))
2019-08-07 20:06:10 +00:00
return nil
2019-08-02 14:09:54 +00:00
},
}
2019-08-26 13:45:36 +00:00
2020-07-31 16:22:04 +00:00
func interactiveDeal(cctx *cli.Context) error {
api, closer, err := GetFullNodeAPI(cctx)
if err != nil {
return err
}
defer closer()
ctx := ReqContext(cctx)
state := "import"
var data cid.Cid
var days int
var maddr address.Address
var ask storagemarket.StorageAsk
var epochPrice big.Int
var epochs abi.ChainEpoch
var verified bool
2020-07-31 16:22:04 +00:00
var a address.Address
if from := cctx.String("from"); from != "" {
faddr, err := address.NewFromString(from)
if err != nil {
return xerrors.Errorf("failed to parse 'from' address: %w", err)
}
a = faddr
} else {
def, err := api.WalletDefaultAddress(ctx)
if err != nil {
return err
}
a = def
}
printErr := func(err error) {
fmt.Printf("%s %s\n", color.RedString("Error:"), err.Error())
}
for {
// TODO: better exit handling
if err := ctx.Err(); err != nil {
return err
}
switch state {
case "import":
fmt.Print("Data CID (from " + color.YellowString("lotus client import") + "): ")
var cidStr string
_, err := fmt.Scan(&cidStr)
if err != nil {
printErr(xerrors.Errorf("reading cid string: %w", err))
continue
}
data, err = cid.Parse(cidStr)
if err != nil {
printErr(xerrors.Errorf("parsing cid string: %w", err))
continue
}
state = "duration"
case "duration":
fmt.Print("Deal duration (days): ")
_, err := fmt.Scan(&days)
if err != nil {
printErr(xerrors.Errorf("parsing duration: %w", err))
continue
}
if days < int(build.MinDealDuration/builtin.EpochsInDay) {
printErr(xerrors.Errorf("minimum duration is %d days", int(build.MinDealDuration/builtin.EpochsInDay)))
continue
}
2020-07-31 16:22:04 +00:00
state = "miner"
case "miner":
2020-10-08 20:45:07 +00:00
fmt.Print("Miner Address (f0..): ")
2020-07-31 16:22:04 +00:00
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)
2020-07-31 16:22:04 +00:00
if err != nil {
printErr(xerrors.Errorf("failed to query ask: %w", err))
state = "miner"
continue
}
2020-09-29 11:53:30 +00:00
ask = *a
2020-07-31 16:22:04 +00:00
// TODO: run more validation
state = "verified"
case "verified":
ts, err := api.ChainHead(ctx)
if err != nil {
return err
}
dcap, err := api.StateVerifiedClientStatus(ctx, a, ts.Key())
if err != nil {
return err
}
if dcap == nil {
state = "confirm"
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) {
color.Yellow(".. not enough DataCap available for a verified deal\n")
state = "confirm"
continue
}
fmt.Print("\nMake this a verified deal? (yes/no): ")
var yn string
_, err = fmt.Scan(&yn)
if err != nil {
return err
}
switch yn {
case "yes":
verified = true
case "no":
verified = false
default:
fmt.Println("Type in full 'yes' or 'no'")
continue
}
2020-07-31 16:22:04 +00:00
state = "confirm"
case "confirm":
fromBal, err := api.WalletBalance(ctx, a)
if err != nil {
return xerrors.Errorf("checking from address balance: %w", err)
}
color.Blue(".. calculating data size\n")
ds, err := api.ClientDealSize(ctx, data)
if err != nil {
return err
}
dur := 24 * time.Hour * time.Duration(days)
epochs = abi.ChainEpoch(dur / (time.Duration(build.BlockDelaySecs) * time.Second))
// TODO: do some more or epochs math (round to miner PP, deal start buffer)
2020-10-02 20:07:40 +00:00
pricePerGib := ask.Price
if verified {
pricePerGib = ask.VerifiedPrice
}
2020-07-31 16:22:04 +00:00
gib := types.NewInt(1 << 30)
// TODO: price is based on PaddedPieceSize, right?
2020-10-02 20:07:40 +00:00
epochPrice = types.BigDiv(types.BigMul(pricePerGib, types.NewInt(uint64(ds.PieceSize))), gib)
2020-07-31 16:22:04 +00:00
totalPrice := types.BigMul(epochPrice, types.NewInt(uint64(epochs)))
fmt.Printf("-----\n")
fmt.Printf("Proposing from %s\n", a)
fmt.Printf("\tBalance: %s\n", types.FIL(fromBal))
fmt.Printf("\n")
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("Total price: ~%s (%s per epoch)\n", types.FIL(totalPrice), types.FIL(epochPrice))
fmt.Printf("Verified: %v\n", verified)
2020-07-31 16:22:04 +00:00
state = "accept"
case "accept":
fmt.Print("\nAccept (yes/no): ")
var yn string
_, err := fmt.Scan(&yn)
if err != nil {
return err
}
if yn == "no" {
return nil
}
if yn != "yes" {
fmt.Println("Type in full 'yes' or 'no'")
continue
}
state = "execute"
case "execute":
color.Blue(".. executing")
proposal, err := api.ClientStartDeal(ctx, &lapi.StartDealParams{
Data: &storagemarket.DataRef{
TransferType: storagemarket.TTGraphsync,
Root: data,
},
Wallet: a,
Miner: maddr,
EpochPrice: epochPrice,
MinBlocksDuration: uint64(epochs),
DealStartEpoch: abi.ChainEpoch(cctx.Int64("start-epoch")),
FastRetrieval: cctx.Bool("fast-retrieval"),
VerifiedDeal: verified,
2020-07-31 16:22:04 +00:00
})
if err != nil {
return err
}
encoder, err := GetCidEncoder(cctx)
if err != nil {
return err
}
fmt.Println("\nDeal CID:", color.GreenString(encoder.Encode(*proposal)))
return nil
default:
return xerrors.Errorf("unknown state: %s", state)
}
}
}
2019-08-26 13:45:36 +00:00
var clientFindCmd = &cli.Command{
2020-03-06 19:01:28 +00:00
Name: "find",
2020-07-24 01:52:29 +00:00
Usage: "Find data in the network",
ArgsUsage: "[dataCid]",
2020-07-09 16:29:57 +00:00
Flags: []cli.Flag{
&cli.StringFlag{
Name: "pieceCid",
Usage: "require data to be retrieved from a specific Piece CID",
},
},
2019-08-26 13:45:36 +00:00
Action: func(cctx *cli.Context) error {
if !cctx.Args().Present() {
2019-08-28 23:01:28 +00:00
fmt.Println("Usage: find [CID]")
2019-08-26 13:45:36 +00:00
return nil
}
file, err := cid.Parse(cctx.Args().First())
if err != nil {
return err
}
2019-10-03 18:12:30 +00:00
api, closer, err := GetFullNodeAPI(cctx)
2019-08-26 13:45:36 +00:00
if err != nil {
return err
}
2019-10-03 18:12:30 +00:00
defer closer()
2019-08-26 13:45:36 +00:00
ctx := ReqContext(cctx)
// Check if we already have this data locally
has, err := api.ClientHasLocal(ctx, file)
if err != nil {
return err
}
if has {
fmt.Println("LOCAL")
}
2020-07-09 16:29:57 +00:00
var pieceCid *cid.Cid
if cctx.String("pieceCid") != "" {
parsed, err := cid.Parse(cctx.String("pieceCid"))
if err != nil {
return err
}
pieceCid = &parsed
}
offers, err := api.ClientFindData(ctx, file, pieceCid)
2019-08-26 13:45:36 +00:00
if err != nil {
return err
}
for _, offer := range offers {
if offer.Err != "" {
2020-08-05 22:35:59 +00:00
fmt.Printf("ERR %s@%s: %s\n", offer.Miner, offer.MinerPeer.ID, offer.Err)
2019-08-26 13:45:36 +00:00
continue
}
2020-08-05 22:35:59 +00:00
fmt.Printf("RETRIEVAL %s@%s-%s-%s\n", offer.Miner, offer.MinerPeer.ID, types.FIL(offer.MinPrice), types.SizeStr(types.NewInt(offer.Size)))
2019-08-26 13:45:36 +00:00
}
return nil
},
}
const DefaultMaxRetrievePrice = 1
2019-08-26 13:45:36 +00:00
var clientRetrieveCmd = &cli.Command{
2020-03-06 19:01:28 +00:00
Name: "retrieve",
2020-07-24 01:52:29 +00:00
Usage: "Retrieve data from network",
ArgsUsage: "[dataCid outputPath]",
2019-09-17 07:55:33 +00:00
Flags: []cli.Flag{
&cli.StringFlag{
Name: "from",
Usage: "address to send transactions from",
2019-09-17 07:55:33 +00:00
},
&cli.BoolFlag{
Name: "car",
Usage: "export to a car file instead of a regular file",
},
&cli.StringFlag{
Name: "miner",
Usage: "miner address for retrieval, if not present it'll use local discovery",
},
&cli.StringFlag{
Name: "maxPrice",
Usage: fmt.Sprintf("maximum price the client is willing to consider (default: %d FIL)", DefaultMaxRetrievePrice),
},
2020-07-09 16:29:57 +00:00
&cli.StringFlag{
Name: "pieceCid",
Usage: "require data to be retrieved from a specific Piece CID",
},
2019-09-17 07:55:33 +00:00
},
2019-08-26 13:45:36 +00:00
Action: func(cctx *cli.Context) error {
2019-09-17 07:55:33 +00:00
if cctx.NArg() != 2 {
2020-07-28 21:12:55 +00:00
return ShowHelp(cctx, fmt.Errorf("incorrect number of arguments"))
2019-08-26 13:45:36 +00:00
}
fapi, closer, err := GetFullNodeAPI(cctx)
2019-09-16 13:46:05 +00:00
if err != nil {
return err
}
2019-10-03 18:12:30 +00:00
defer closer()
2019-09-17 07:55:33 +00:00
ctx := ReqContext(cctx)
2019-09-16 13:46:05 +00:00
2019-09-17 07:55:33 +00:00
var payer address.Address
if cctx.String("from") != "" {
payer, err = address.NewFromString(cctx.String("from"))
2019-09-17 07:55:33 +00:00
} else {
payer, err = fapi.WalletDefaultAddress(ctx)
2019-09-17 07:55:33 +00:00
}
2019-08-26 13:45:36 +00:00
if err != nil {
return err
}
2019-09-17 07:55:33 +00:00
file, err := cid.Parse(cctx.Args().Get(0))
2019-08-26 13:45:36 +00:00
if err != nil {
return err
}
// Check if we already have this data locally
2019-08-27 18:45:21 +00:00
/*has, err := api.ClientHasLocal(ctx, file)
2019-08-26 13:45:36 +00:00
if err != nil {
return err
}
if has {
fmt.Println("Success: Already in local storage")
return nil
2019-08-27 22:10:23 +00:00
}*/ // TODO: fix
2019-08-26 13:45:36 +00:00
2020-07-09 16:29:57 +00:00
var pieceCid *cid.Cid
if cctx.String("pieceCid") != "" {
parsed, err := cid.Parse(cctx.String("pieceCid"))
if err != nil {
return err
}
pieceCid = &parsed
}
var offer api.QueryOffer
minerStrAddr := cctx.String("miner")
if minerStrAddr == "" { // Local discovery
2020-07-09 16:29:57 +00:00
offers, err := fapi.ClientFindData(ctx, file, pieceCid)
var cleaned []api.QueryOffer
// filter out offers that errored
for _, o := range offers {
2020-07-21 23:52:48 +00:00
if o.Err == "" {
cleaned = append(cleaned, o)
}
}
offers = cleaned
// sort by price low to high
sort.Slice(offers, func(i, j int) bool {
return offers[i].MinPrice.LessThan(offers[j].MinPrice)
})
if err != nil {
return err
}
2019-08-26 13:45:36 +00:00
// TODO: parse offer strings from `client find`, make this smarter
if len(offers) < 1 {
fmt.Println("Failed to find file")
return nil
}
offer = offers[0]
} else { // Directed retrieval
minerAddr, err := address.NewFromString(minerStrAddr)
if err != nil {
return err
}
offer, err = fapi.ClientMinerQueryOffer(ctx, minerAddr, file, pieceCid)
if err != nil {
return err
}
2019-11-20 08:47:24 +00:00
}
if offer.Err != "" {
return fmt.Errorf("The received offer errored: %s", offer.Err)
2019-11-20 08:47:24 +00:00
}
2019-09-16 13:46:05 +00:00
maxPrice := types.FromFil(DefaultMaxRetrievePrice)
if cctx.String("maxPrice") != "" {
maxPriceFil, err := types.ParseFIL(cctx.String("maxPrice"))
if err != nil {
return xerrors.Errorf("parsing maxPrice: %w", err)
}
maxPrice = types.BigInt(maxPriceFil)
}
if offer.MinPrice.GreaterThan(maxPrice) {
return xerrors.Errorf("failed to find offer satisfying maxPrice: %s", maxPrice)
}
ref := &lapi.FileRef{
Path: cctx.Args().Get(1),
IsCAR: cctx.Bool("car"),
}
updates, err := fapi.ClientRetrieveWithEvents(ctx, offer.Order(payer), ref)
2020-08-11 20:04:00 +00:00
if err != nil {
return xerrors.Errorf("error setting up retrieval: %w", err)
2019-08-27 18:45:21 +00:00
}
2019-11-04 19:03:11 +00:00
2020-08-11 20:04:00 +00:00
for {
select {
2020-08-17 18:17:46 +00:00
case evt, ok := <-updates:
if ok {
fmt.Printf("> Recv: %s, Paid %s, %s (%s)\n",
types.SizeStr(types.NewInt(evt.BytesReceived)),
types.FIL(evt.FundsSpent),
retrievalmarket.ClientEvents[evt.Event],
retrievalmarket.DealStatuses[evt.Status],
)
} else {
2020-08-11 20:04:00 +00:00
fmt.Println("Success")
return nil
}
if evt.Err != "" {
2020-08-18 13:27:56 +00:00
return xerrors.Errorf("retrieval failed: %s", evt.Err)
2020-08-11 20:04:00 +00:00
}
2020-08-11 20:48:56 +00:00
case <-ctx.Done():
return xerrors.Errorf("retrieval timed out")
2020-08-11 20:04:00 +00:00
}
}
2019-08-26 13:45:36 +00:00
},
}
2019-09-13 21:00:36 +00:00
var clientQueryAskCmd = &cli.Command{
2020-03-06 19:01:28 +00:00
Name: "query-ask",
2020-07-24 01:52:29 +00:00
Usage: "Find a miners ask",
ArgsUsage: "[minerAddress]",
2019-09-13 21:00:36 +00:00
Flags: []cli.Flag{
&cli.StringFlag{
Name: "peerid",
Usage: "specify peer ID of node to make query against",
},
2019-10-12 07:21:28 +00:00
&cli.Int64Flag{
Name: "size",
Usage: "data size in bytes",
},
&cli.Int64Flag{
Name: "duration",
Usage: "deal duration",
},
2019-09-13 21:00:36 +00:00
},
Action: func(cctx *cli.Context) error {
if cctx.NArg() != 1 {
fmt.Println("Usage: query-ask [minerAddress]")
2019-09-13 21:00:36 +00:00
return nil
}
maddr, err := address.NewFromString(cctx.Args().First())
if err != nil {
return err
}
2019-10-03 18:12:30 +00:00
api, closer, err := GetFullNodeAPI(cctx)
2019-09-13 21:00:36 +00:00
if err != nil {
return err
}
2019-10-03 18:12:30 +00:00
defer closer()
2019-09-13 21:00:36 +00:00
ctx := ReqContext(cctx)
var pid peer.ID
if pidstr := cctx.String("peerid"); pidstr != "" {
2020-03-03 04:55:25 +00:00
p, err := peer.Decode(pidstr)
2019-09-13 21:00:36 +00:00
if err != nil {
return err
}
pid = p
} else {
mi, err := api.StateMinerInfo(ctx, maddr, types.EmptyTSK)
2019-09-13 21:00:36 +00:00
if err != nil {
return xerrors.Errorf("failed to get peerID for miner: %w", err)
}
if *mi.PeerId == peer.ID("SETME") {
return fmt.Errorf("the miner hasn't initialized yet")
}
2019-09-13 21:00:36 +00:00
pid = *mi.PeerId
2019-09-13 21:00:36 +00:00
}
ask, err := api.ClientQueryAsk(ctx, pid, maddr)
if err != nil {
return err
}
fmt.Printf("Ask: %s\n", maddr)
2020-09-29 11:53:30 +00:00
fmt.Printf("Price per GiB: %s\n", types.FIL(ask.Price))
fmt.Printf("Verified Price per GiB: %s\n", types.FIL(ask.VerifiedPrice))
fmt.Printf("Max Piece size: %s\n", types.SizeStr(types.NewInt(uint64(ask.MaxPieceSize))))
2019-10-12 07:21:28 +00:00
size := cctx.Int64("size")
if size == 0 {
return nil
}
2020-09-29 11:53:30 +00:00
perEpoch := types.BigDiv(types.BigMul(ask.Price, types.NewInt(uint64(size))), types.NewInt(1<<30))
2019-10-29 12:02:13 +00:00
fmt.Printf("Price per Block: %s\n", types.FIL(perEpoch))
2019-10-12 07:21:28 +00:00
duration := cctx.Int64("duration")
if duration == 0 {
return nil
}
2019-10-29 12:02:13 +00:00
fmt.Printf("Total Price: %s\n", types.FIL(types.BigMul(perEpoch, types.NewInt(uint64(duration)))))
2019-10-12 07:21:28 +00:00
2019-09-13 21:00:36 +00:00
return nil
},
}
2019-11-08 01:45:45 +00:00
var clientListDeals = &cli.Command{
Name: "list-deals",
Usage: "List storage market deals",
2020-07-28 21:12:55 +00:00
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "print verbose deal details",
},
2020-07-28 23:10:57 +00:00
&cli.BoolFlag{
Name: "color",
Usage: "use color in display output",
Value: true,
},
&cli.BoolFlag{
Name: "show-failed",
Usage: "show failed/failing deals",
},
2020-08-27 18:32:51 +00:00
&cli.BoolFlag{
Name: "watch",
Usage: "watch deal updates in real-time, rather than a one time list",
},
2020-07-28 21:12:55 +00:00
},
2019-11-08 01:45:45 +00:00
Action: func(cctx *cli.Context) error {
api, closer, err := GetFullNodeAPI(cctx)
if err != nil {
return err
}
defer closer()
ctx := ReqContext(cctx)
2020-08-27 18:32:51 +00:00
verbose := cctx.Bool("verbose")
color := cctx.Bool("color")
watch := cctx.Bool("watch")
showFailed := cctx.Bool("show-failed")
2019-11-08 01:45:45 +00:00
localDeals, err := api.ClientListDeals(ctx)
if err != nil {
return err
}
2020-08-27 18:32:51 +00:00
if watch {
updates, err := api.ClientGetDealUpdates(ctx)
if err != nil {
return err
}
for {
tm.Clear()
tm.MoveCursor(1, 1)
2020-08-15 00:00:31 +00:00
err = outputStorageDeals(ctx, tm.Screen, api, localDeals, verbose, color, showFailed)
if err != nil {
2020-08-27 18:32:51 +00:00
return err
}
2020-08-27 18:32:51 +00:00
tm.Flush()
2020-07-28 23:10:57 +00:00
2020-08-27 18:32:51 +00:00
select {
case <-ctx.Done():
return nil
case updated := <-updates:
var found bool
for i, existing := range localDeals {
if existing.ProposalCid.Equals(updated.ProposalCid) {
localDeals[i] = updated
found = true
break
}
}
if !found {
localDeals = append(localDeals, updated)
}
2020-07-28 21:12:55 +00:00
}
2020-08-27 18:32:51 +00:00
}
}
2020-07-28 21:12:55 +00:00
return outputStorageDeals(ctx, os.Stdout, api, localDeals, cctx.Bool("verbose"), cctx.Bool("color"), showFailed)
2020-08-27 18:32:51 +00:00
},
}
2020-07-28 21:12:55 +00:00
2020-08-27 18:32:51 +00:00
func dealFromDealInfo(ctx context.Context, full api.FullNode, head *types.TipSet, v api.DealInfo) deal {
if v.DealID == 0 {
return deal{
2020-09-17 08:17:14 +00:00
LocalDeal: v,
OnChainDealState: *market.EmptyDealState(),
}
2020-08-27 18:32:51 +00:00
}
2020-08-27 18:32:51 +00:00
onChain, err := full.StateMarketStorageDeal(ctx, v.DealID, head.Key())
if err != nil {
return deal{LocalDeal: v}
}
2020-08-27 18:32:51 +00:00
return deal{
LocalDeal: v,
OnChainDealState: onChain.State,
}
}
func outputStorageDeals(ctx context.Context, out io.Writer, full lapi.FullNode, localDeals []lapi.DealInfo, verbose bool, color bool, showFailed bool) error {
2020-08-27 18:32:51 +00:00
sort.Slice(localDeals, func(i, j int) bool {
return localDeals[i].CreationTime.Before(localDeals[j].CreationTime)
})
head, err := full.ChainHead(ctx)
if err != nil {
return err
}
2020-08-27 18:32:51 +00:00
var deals []deal
for _, localDeal := range localDeals {
if showFailed || localDeal.State != storagemarket.StorageDealError {
deals = append(deals, dealFromDealInfo(ctx, full, head, localDeal))
}
2020-08-27 18:32:51 +00:00
}
if verbose {
w := tabwriter.NewWriter(out, 2, 4, 2, ' ', 0)
fmt.Fprintf(w, "Created\tDealCid\tDealId\tProvider\tState\tOn Chain?\tSlashed?\tPieceCID\tSize\tPrice\tDuration\tVerified\tMessage\n")
2020-08-27 18:32:51 +00:00
for _, d := range deals {
onChain := "N"
if d.OnChainDealState.SectorStartEpoch != -1 {
onChain = fmt.Sprintf("Y (epoch %d)", d.OnChainDealState.SectorStartEpoch)
}
2020-08-04 17:07:17 +00:00
slashed := "N"
if d.OnChainDealState.SlashEpoch != -1 {
slashed = fmt.Sprintf("Y (epoch %d)", d.OnChainDealState.SlashEpoch)
}
price := types.FIL(types.BigMul(d.LocalDeal.PricePerEpoch, types.NewInt(d.LocalDeal.Duration)))
fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%d\t%v\t%s\n", d.LocalDeal.CreationTime.Format(time.Stamp), d.LocalDeal.ProposalCid, d.LocalDeal.DealID, d.LocalDeal.Provider, dealStateString(color, d.LocalDeal.State), onChain, slashed, d.LocalDeal.PieceCID, types.SizeStr(types.NewInt(d.LocalDeal.Size)), price, d.LocalDeal.Duration, d.LocalDeal.Verified, d.LocalDeal.Message)
2019-11-08 01:45:45 +00:00
}
2020-08-27 18:32:51 +00:00
return w.Flush()
}
2020-08-27 18:32:51 +00:00
w := tablewriter.New(tablewriter.Col("DealCid"),
tablewriter.Col("DealId"),
tablewriter.Col("Provider"),
tablewriter.Col("State"),
tablewriter.Col("On Chain?"),
tablewriter.Col("Slashed?"),
tablewriter.Col("PieceCID"),
tablewriter.Col("Size"),
tablewriter.Col("Price"),
tablewriter.Col("Duration"),
tablewriter.Col("Verified"),
2020-08-27 18:32:51 +00:00
tablewriter.NewLineCol("Message"))
for _, d := range deals {
propcid := ellipsis(d.LocalDeal.ProposalCid.String(), 8)
onChain := "N"
if d.OnChainDealState.SectorStartEpoch != -1 {
onChain = fmt.Sprintf("Y (epoch %d)", d.OnChainDealState.SectorStartEpoch)
}
slashed := "N"
if d.OnChainDealState.SlashEpoch != -1 {
slashed = fmt.Sprintf("Y (epoch %d)", d.OnChainDealState.SlashEpoch)
}
piece := ellipsis(d.LocalDeal.PieceCID.String(), 8)
price := types.FIL(types.BigMul(d.LocalDeal.PricePerEpoch, types.NewInt(d.LocalDeal.Duration)))
w.Write(map[string]interface{}{
"DealCid": propcid,
"DealId": d.LocalDeal.DealID,
"Provider": d.LocalDeal.Provider,
"State": dealStateString(color, d.LocalDeal.State),
"On Chain?": onChain,
"Slashed?": slashed,
"PieceCID": piece,
"Size": types.SizeStr(types.NewInt(d.LocalDeal.Size)),
"Price": price,
"Verified": d.LocalDeal.Verified,
2020-08-27 18:32:51 +00:00
"Duration": d.LocalDeal.Duration,
"Message": d.LocalDeal.Message,
})
}
return w.Flush(out)
2019-11-08 01:45:45 +00:00
}
2020-07-28 23:10:57 +00:00
func dealStateString(c bool, state storagemarket.StorageDealStatus) string {
s := storagemarket.DealStates[state]
if !c {
return s
}
switch state {
case storagemarket.StorageDealError, storagemarket.StorageDealExpired:
return color.RedString(s)
case storagemarket.StorageDealActive:
return color.GreenString(s)
default:
return s
}
}
type deal struct {
LocalDeal lapi.DealInfo
OnChainDealState market.DealState
}
var clientGetDealCmd = &cli.Command{
Name: "get-deal",
Usage: "Print detailed deal information",
Action: func(cctx *cli.Context) error {
if !cctx.Args().Present() {
return cli.ShowCommandHelp(cctx, cctx.Command.Name)
}
api, closer, err := GetFullNodeAPI(cctx)
if err != nil {
return err
}
defer closer()
ctx := ReqContext(cctx)
propcid, err := cid.Decode(cctx.Args().First())
if err != nil {
return err
}
di, err := api.ClientGetDealInfo(ctx, propcid)
if err != nil {
return err
}
out := map[string]interface{}{
"DealInfo: ": di,
}
if di.DealID != 0 {
onChain, err := api.StateMarketStorageDeal(ctx, di.DealID, types.EmptyTSK)
if err != nil {
return err
}
out["OnChain"] = onChain
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
2020-07-28 21:12:55 +00:00
fmt.Println(string(b))
return nil
},
}
var clientInfoCmd = &cli.Command{
Name: "info",
Usage: "Print storage market client information",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "client",
Usage: "specify storage client address",
},
},
Action: func(cctx *cli.Context) error {
api, closer, err := GetFullNodeAPI(cctx)
if err != nil {
return err
}
defer closer()
ctx := ReqContext(cctx)
var addr address.Address
if clientFlag := cctx.String("client"); clientFlag != "" {
ca, err := address.NewFromString("client")
if err != nil {
return err
}
addr = ca
} else {
def, err := api.WalletDefaultAddress(ctx)
if err != nil {
return err
}
addr = def
}
balance, err := api.StateMarketBalance(ctx, addr, types.EmptyTSK)
if err != nil {
return err
}
fmt.Printf("Client Market Info:\n")
fmt.Printf("Locked Funds:\t%s\n", types.FIL(balance.Locked))
fmt.Printf("Escrowed Funds:\t%s\n", types.FIL(balance.Escrow))
return nil
},
}
var clientRestartTransfer = &cli.Command{
Name: "restart-transfer",
Usage: "Force restart a stalled data transfer",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "peerid",
Usage: "narrow to transfer with specific peer",
},
&cli.BoolFlag{
Name: "initiator",
Usage: "specify only transfers where peer is/is not initiator",
Value: true,
},
},
Action: func(cctx *cli.Context) error {
if !cctx.Args().Present() {
return cli.ShowCommandHelp(cctx, cctx.Command.Name)
}
api, closer, err := GetFullNodeAPI(cctx)
if err != nil {
return err
}
defer closer()
ctx := ReqContext(cctx)
transferUint, err := strconv.ParseUint(cctx.Args().First(), 10, 64)
if err != nil {
return fmt.Errorf("Error reading transfer ID: %w", err)
}
transferID := datatransfer.TransferID(transferUint)
initiator := cctx.Bool("initiator")
var other peer.ID
if pidstr := cctx.String("peerid"); pidstr != "" {
p, err := peer.Decode(pidstr)
if err != nil {
return err
}
other = p
} else {
channels, err := api.ClientListDataTransfers(ctx)
if err != nil {
return err
}
found := false
for _, channel := range channels {
if channel.IsInitiator == initiator && channel.TransferID == transferID {
other = channel.OtherPeer
found = true
break
}
}
if !found {
return errors.New("unable to find matching data transfer")
}
}
return api.ClientRestartDataTransfer(ctx, transferID, other, initiator)
},
}
var clientListTransfers = &cli.Command{
Name: "list-transfers",
Usage: "List ongoing data transfers for deals",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "color",
Usage: "use color in display output",
Value: true,
},
&cli.BoolFlag{
Name: "completed",
Usage: "show completed data transfers",
},
&cli.BoolFlag{
Name: "watch",
Usage: "watch deal updates in real-time, rather than a one time list",
},
},
Action: func(cctx *cli.Context) error {
api, closer, err := GetFullNodeAPI(cctx)
if err != nil {
return err
}
defer closer()
ctx := ReqContext(cctx)
channels, err := api.ClientListDataTransfers(ctx)
if err != nil {
return err
}
completed := cctx.Bool("completed")
color := cctx.Bool("color")
watch := cctx.Bool("watch")
if watch {
channelUpdates, err := api.ClientDataTransferUpdates(ctx)
if err != nil {
return err
}
for {
tm.Clear() // Clear current screen
tm.MoveCursor(1, 1)
OutputDataTransferChannels(tm.Screen, channels, completed, color)
tm.Flush()
select {
case <-ctx.Done():
return nil
case channelUpdate := <-channelUpdates:
var found bool
for i, existing := range channels {
if existing.TransferID == channelUpdate.TransferID &&
existing.OtherPeer == channelUpdate.OtherPeer &&
existing.IsSender == channelUpdate.IsSender &&
existing.IsInitiator == channelUpdate.IsInitiator {
channels[i] = channelUpdate
found = true
break
}
}
if !found {
channels = append(channels, channelUpdate)
}
}
}
}
OutputDataTransferChannels(os.Stdout, channels, completed, color)
return nil
},
}
// OutputDataTransferChannels generates table output for a list of channels
func OutputDataTransferChannels(out io.Writer, channels []lapi.DataTransferChannel, completed bool, color bool) {
sort.Slice(channels, func(i, j int) bool {
return channels[i].TransferID < channels[j].TransferID
})
var receivingChannels, sendingChannels []lapi.DataTransferChannel
for _, channel := range channels {
if !completed && channel.Status == datatransfer.Completed {
continue
}
if channel.IsSender {
sendingChannels = append(sendingChannels, channel)
} else {
receivingChannels = append(receivingChannels, channel)
}
}
fmt.Fprintf(out, "Sending Channels\n\n")
w := tablewriter.New(tablewriter.Col("ID"),
tablewriter.Col("Status"),
tablewriter.Col("Sending To"),
tablewriter.Col("Root Cid"),
tablewriter.Col("Initiated?"),
tablewriter.Col("Transferred"),
tablewriter.Col("Voucher"),
tablewriter.NewLineCol("Message"))
for _, channel := range sendingChannels {
w.Write(toChannelOutput(color, "Sending To", channel))
}
w.Flush(out) //nolint:errcheck
fmt.Fprintf(out, "\nReceiving Channels\n\n")
w = tablewriter.New(tablewriter.Col("ID"),
tablewriter.Col("Status"),
tablewriter.Col("Receiving From"),
tablewriter.Col("Root Cid"),
tablewriter.Col("Initiated?"),
tablewriter.Col("Transferred"),
tablewriter.Col("Voucher"),
tablewriter.NewLineCol("Message"))
for _, channel := range receivingChannels {
w.Write(toChannelOutput(color, "Receiving From", channel))
}
w.Flush(out) //nolint:errcheck
}
func channelStatusString(useColor bool, status datatransfer.Status) string {
s := datatransfer.Statuses[status]
if !useColor {
return s
}
switch status {
case datatransfer.Failed, datatransfer.Cancelled:
return color.RedString(s)
case datatransfer.Completed:
return color.GreenString(s)
default:
return s
}
}
func toChannelOutput(useColor bool, otherPartyColumn string, channel lapi.DataTransferChannel) map[string]interface{} {
rootCid := ellipsis(channel.BaseCID.String(), 8)
otherParty := ellipsis(channel.OtherPeer.String(), 8)
initiated := "N"
if channel.IsInitiator {
initiated = "Y"
}
voucher := channel.Voucher
if len(voucher) > 40 {
voucher = ellipsis(voucher, 37)
}
return map[string]interface{}{
"ID": channel.TransferID,
"Status": channelStatusString(useColor, channel.Status),
otherPartyColumn: otherParty,
"Root Cid": rootCid,
"Initiated?": initiated,
"Transferred": units.BytesSize(float64(channel.Transferred)),
"Voucher": voucher,
"Message": channel.Message,
}
}
func ellipsis(s string, length int) string {
if length > 0 && len(s) > length {
return "..." + s[len(s)-length:]
}
return s
}