@@ -2,8 +2,13 @@ package main
22
33import (
44 "bytes"
5+ "encoding/csv"
56 "fmt"
7+ "io"
8+ "os"
69 "sort"
10+ "strconv"
11+ "strings"
712
813 cbor "github.com/ipfs/go-ipld-cbor"
914 "github.com/urfave/cli/v2"
@@ -392,8 +397,8 @@ var minerFeesCmd = &cli.Command{
392397
393398var minerFeesInspect = & cli.Command {
394399 Name : "fees-inspect" ,
395- UsageText : "lotus-shed miner fees-inspect [--tipset <tipset >] [--count <count >]" ,
396- Description : "Inspect miner fees in the given tipset and its parents . The output is a CSV with the following columns:\n " +
400+ UsageText : "lotus-shed miner fees-inspect [--tipset <start-epoch >] [--count <epochs>] [--output <file >]" ,
401+ Description : "Inspect miner fees starting from the given tipset and going forward . The output is a CSV with the following columns:\n " +
397402 "Epoch, Burn, Fees, Penalties, Expected, Miners ...\n " +
398403 "Where:\n " +
399404 " - Epoch: the epoch of the tipset\n " +
@@ -403,17 +408,32 @@ var minerFeesInspect = &cli.Command{
403408 " - Expected: whether the sum of fees and penalties equals the burn amount (✓ or ✗)\n " +
404409 " A discrepancy here likely results from burnt precommit deposits or miners who can't pay fees,\n " +
405410 " neither of which are currently calculated by this tool\n " +
406- " - Miners: the list of miners that burned or were expected to burn in this tipset" ,
411+ " - Miners: the list of miners that burned or were expected to burn in this tipset\n \n " +
412+ "Output Options:\n " +
413+ " --output <file> : Write CSV to file and track inspection progress. If file exists, inspection will resume from last epoch.\n \n " +
414+ "Examples:\n " +
415+ " # Inspect 100 epochs starting from epoch 5100000\n " +
416+ " lotus-shed miner fees-inspect --tipset @5100000 --count 100\n \n " +
417+ " # Inspect from epoch 5100000 to current head\n " +
418+ " lotus-shed miner fees-inspect --tipset @5100000\n \n " +
419+ " # Save results to file, inspect 1000 epochs from epoch 5000000\n " +
420+ " lotus-shed miner fees-inspect --tipset @5000000 --count 1000 --output fees.csv\n \n " +
421+ " # Resume from last epoch in existing file (no --tipset needed)\n " +
422+ " lotus-shed miner fees-inspect --output fees.csv --count 100\n \n " +
423+ " # Resume and go to current head\n " +
424+ " lotus-shed miner fees-inspect --output fees.csv" ,
407425 Flags : []cli.Flag {
408426 & cli.StringFlag {
409427 Name : "tipset" ,
410- Usage : "tipset or height (@X or @head for latest)" ,
411- Value : "@head" ,
428+ Usage : "starting tipset or height (@X to specify an epoch) - required unless resuming from file" ,
412429 },
413430 & cli.IntFlag {
414431 Name : "count" ,
415- Usage : "number of tipsets to inspect, working backwards from the --tipset" ,
416- Value : 1 ,
432+ Usage : "number of epochs to inspect forward from --tipset (default: to current head)" ,
433+ },
434+ & cli.StringFlag {
435+ Name : "output" ,
436+ Usage : "CSV file to save or resume results" ,
417437 },
418438 },
419439 Action : func (cctx * cli.Context ) error {
@@ -433,6 +453,75 @@ var minerFeesInspect = &cli.Command{
433453 bstore := blockstore .NewAPIBlockstore (api )
434454 adtStore := adt .WrapStore (ctx , cbor .NewCborStore (bstore ))
435455
456+ outputFile := cctx .String ("output" )
457+ count := cctx .Int ("count" )
458+
459+ // Handle output options
460+ var csvWriter * csv.Writer
461+ var lastEpoch abi.ChainEpoch = 0
462+
463+ if outputFile != "" {
464+ // Check if file exists to resume from it
465+ if _ , err := os .Stat (outputFile ); err == nil {
466+ // Read the last epoch from existing file
467+ f , err := os .Open (outputFile )
468+ if err != nil {
469+ return xerrors .Errorf ("opening output file: %w" , err )
470+ }
471+ reader := csv .NewReader (f )
472+ var lastLine []string
473+ for {
474+ line , err := reader .Read ()
475+ if err == io .EOF {
476+ break
477+ }
478+ if err != nil {
479+ _ = f .Close ()
480+ return xerrors .Errorf ("reading output file: %w" , err )
481+ }
482+ if line [0 ] != "Epoch" { // Skip header
483+ lastLine = line
484+ }
485+ }
486+ _ = f .Close ()
487+
488+ if len (lastLine ) > 0 {
489+ epochStr := strings .TrimSpace (lastLine [0 ])
490+ epoch , err := strconv .ParseInt (epochStr , 10 , 64 )
491+ if err != nil {
492+ return xerrors .Errorf ("parsing last epoch from output file: %w" , err )
493+ }
494+ lastEpoch = abi .ChainEpoch (epoch )
495+ _ , _ = fmt .Fprintf (cctx .App .ErrWriter , "Last processed epoch: %d\n " , lastEpoch )
496+ }
497+ }
498+
499+ // Open file for appending or create new one
500+ var f * os.File
501+ if lastEpoch > 0 {
502+ f , err = os .OpenFile (outputFile , os .O_APPEND | os .O_WRONLY , 0644 )
503+ if err != nil {
504+ return xerrors .Errorf ("opening output file for append: %w" , err )
505+ }
506+ } else {
507+ f , err = os .Create (outputFile )
508+ if err != nil {
509+ return xerrors .Errorf ("creating output file: %w" , err )
510+ }
511+ }
512+ defer func () { _ = f .Close () }()
513+
514+ csvWriter = csv .NewWriter (f )
515+ defer csvWriter .Flush ()
516+
517+ // Write header if new file
518+ if lastEpoch == 0 {
519+ if err := csvWriter .Write ([]string {"Epoch" , "Burn" , "Fees" , "Penalties" , "Expected" , "Miners" }); err != nil {
520+ return xerrors .Errorf ("writing output header: %w" , err )
521+ }
522+ }
523+ }
524+
436525 inspectTipset := func (ts * types.TipSet ) error {
437526 compute , err := api .StateCompute (ctx , ts .Height (), nil , ts .Key ())
438527 if err != nil {
@@ -625,32 +714,129 @@ var minerFeesInspect = &cli.Command{
625714 // calculate that for each miner.
626715 expected = "✗"
627716 }
628- _ , _ = fmt .Fprintf (cctx .App .Writer , "%d, %v, %v, %v, %s" , ts .Height (), totalBurn , totalFees , totalPenalties , expected )
717+ // Format output - unified CSV handling for both file and stdout
718+ row := []string {
719+ fmt .Sprintf ("%d" , ts .Height ()),
720+ totalBurn .String (),
721+ totalFees .String (),
722+ totalPenalties .String (),
723+ expected ,
724+ }
725+
726+ // Add miners - consistent ordering
629727 sort .Slice (miners , func (i , j int ) bool {
630728 return must .One (address .IDFromAddress (miners [i ])) < must .One (address .IDFromAddress (miners [j ]))
631729 })
632- for _ , maddr := range miners {
633- _ , _ = fmt .Fprintf (cctx .App .Writer , ", %v" , maddr )
730+ minersStr := ""
731+ for i , maddr := range miners {
732+ if i > 0 {
733+ minersStr += " "
734+ }
735+ minersStr += maddr .String ()
736+ }
737+ row = append (row , minersStr )
738+
739+ if err := csvWriter .Write (row ); err != nil {
740+ return xerrors .Errorf ("writing output: %w" , err )
634741 }
635- _ , _ = fmt . Fprintf ( cctx . App . Writer , " \n " )
742+ csvWriter . Flush ( )
636743
637744 return nil
638745 }
639746
640- ts , err := lcli .LoadTipSet (ctx , cctx , api )
641- if err != nil {
642- return err
747+ // Determine starting point
748+ var startTs * types.TipSet
749+ if lastEpoch > 0 && outputFile != "" {
750+ // Resume from last epoch in file
751+ // Check if we're already at the head
752+ head , err := api .ChainHead (ctx )
753+ if err != nil {
754+ return xerrors .Errorf ("getting chain head: %w" , err )
755+ }
756+
757+ if lastEpoch >= head .Height () {
758+ _ , _ = fmt .Fprintf (cctx .App .ErrWriter , "File is up to date (last epoch %d, current head %d)\n " , lastEpoch , head .Height ())
759+ return nil
760+ }
761+
762+ startTs , err = api .ChainGetTipSetByHeight (ctx , lastEpoch + 1 , types .EmptyTSK )
763+ if err != nil {
764+ return xerrors .Errorf ("getting tipset at height %d: %w" , lastEpoch + 1 , err )
765+ }
766+ _ , _ = fmt .Fprintf (cctx .App .ErrWriter , "Resuming from epoch %d\n " , lastEpoch + 1 )
767+ } else {
768+ // Start from specified tipset
769+ if ! cctx .IsSet ("tipset" ) {
770+ return xerrors .Errorf ("--tipset is required when not resuming from an existing file" )
771+ }
772+ startTs , err = lcli .LoadTipSet (ctx , cctx , api )
773+ if err != nil {
774+ return err
775+ }
643776 }
644777
645- count := cctx .Int ("count" )
646- _ , _ = fmt .Fprintln (cctx .App .Writer , "Epoch, Burn, Fees, Penalties, Expected, Miners ..." )
647- for i := 0 ; i < count ; i ++ {
648- if err := inspectTipset (ts ); err != nil {
649- return xerrors .Errorf ("inspecting tipset %d: %w" , ts .Height (), err )
778+ // Determine end point
779+ var endHeight abi.ChainEpoch
780+ if count > 0 {
781+ // Use specified count
782+ endHeight = startTs .Height () + abi .ChainEpoch (count ) - 1
783+ } else {
784+ // Go to current head
785+ head , err := api .ChainHead (ctx )
786+ if err != nil {
787+ return xerrors .Errorf ("getting chain head: %w" , err )
650788 }
651- if ts , err = api .ChainGetTipSet (ctx , ts .Parents ()); err != nil {
652- return xerrors .Errorf ("getting parent tipset: %w" , err )
789+ endHeight = head .Height ()
790+ }
791+
792+ // Calculate actual count for the loop
793+ actualCount := int (endHeight - startTs .Height () + 1 )
794+ if actualCount <= 0 {
795+ return xerrors .Errorf ("invalid range: start epoch %d >= end epoch %d" , startTs .Height (), endHeight )
796+ }
797+
798+ // Setup CSV writer for stdout if no output file
799+ if outputFile == "" {
800+ csvWriter = csv .NewWriter (cctx .App .Writer )
801+ defer csvWriter .Flush ()
802+ // Write header for stdout
803+ if err := csvWriter .Write ([]string {"Epoch" , "Burn" , "Fees" , "Penalties" , "Expected" , "Miners" }); err != nil {
804+ return xerrors .Errorf ("writing output header: %w" , err )
805+ }
806+ }
807+
808+ // Process epochs forward from start to end
809+ currentTs := startTs
810+
811+ // Print starting info if outputting to file
812+ if outputFile != "" {
813+ _ , _ = fmt .Fprintf (cctx .App .ErrWriter , "Processing %d epochs from %d to %d\n " , actualCount , startTs .Height (), endHeight )
814+ }
815+
816+ for i := 0 ; i < actualCount ; i ++ {
817+ // Show progress if outputting to file
818+ if outputFile != "" {
819+ _ , _ = fmt .Fprintf (cctx .App .ErrWriter , "\r Processing epoch %d [%d/%d] (%d%%)..." , currentTs .Height (), i + 1 , actualCount , (i + 1 )* 100 / actualCount )
820+ }
821+
822+ if err := inspectTipset (currentTs ); err != nil {
823+ return xerrors .Errorf ("inspecting tipset %d: %w" , currentTs .Height (), err )
653824 }
825+
826+ // Move to next epoch
827+ if i < actualCount - 1 {
828+ nextHeight := currentTs .Height () + 1
829+ currentTs , err = api .ChainGetTipSetByHeight (ctx , nextHeight , types .EmptyTSK )
830+ if err != nil {
831+ return xerrors .Errorf ("getting tipset at height %d: %w" , nextHeight , err )
832+ }
833+ }
834+ }
835+
836+ // Clear progress line if outputting to file
837+ if outputFile != "" {
838+ _ , _ = fmt .Fprintf (cctx .App .ErrWriter , "\r \r " )
839+ _ , _ = fmt .Fprintf (cctx .App .ErrWriter , "Completed processing %d epochs\n " , actualCount )
654840 }
655841 return nil
656842 },
0 commit comments