gitlab.com/SkynetLabs/skyd@v1.6.9/cmd/skyc/accountingcmd.go (about)

     1  package main
     2  
     3  import (
     4  	"encoding/csv"
     5  	"fmt"
     6  	"io"
     7  	"math"
     8  	"math/big"
     9  	"os"
    10  	"strconv"
    11  	"strings"
    12  	"time"
    13  
    14  	"github.com/spf13/cobra"
    15  	"gitlab.com/NebulousLabs/errors"
    16  	"gitlab.com/SkynetLabs/skyd/node/api/client"
    17  	"gitlab.com/SkynetLabs/skyd/skymodules"
    18  	"go.sia.tech/siad/types"
    19  )
    20  
    21  var (
    22  	// accountingHeaders are the headers for the csv file generated for the
    23  	// accounting information
    24  	accountingHeaders = []string{"TimeStamp", "Siacoin Balance", "Siafund Balance", "Unspent Unallocated", "Withheld Funds"}
    25  
    26  	// transactionHeaders are the headers for the csv file generated for the
    27  	// transaction information
    28  	transactionHeaders = []string{"Date", "Transaction Amount (SC)", "Reason", "Price Average (USD/SC)", "Transaction Amount (USD)"}
    29  
    30  	// The following are file names for the various accounting commands
    31  	accountingCSV   = "accounting.csv"
    32  	transactionsCSV = "transactions.csv"
    33  )
    34  
    35  var (
    36  	accountingCmd = &cobra.Command{
    37  		Use:   "accounting",
    38  		Short: "Generate a csv file of the accounting information for the node.",
    39  		Long: `Generate a csv file of the accounting information for the node.
    40  Information will be written to accounting.csv`,
    41  		Run: wrap(accountingcmd),
    42  	}
    43  
    44  	accountingTxnsCmd = &cobra.Command{
    45  		Use:   "txns",
    46  		Short: "Generate a csv file of the wallet's transactions for the node.",
    47  		Long: `Generate a csv file of the wallet's transactions for the node.
    48  The default is to pull the year to date information. You can use the available flags to define
    49  some preset time periods you would like the information from. See --help
    50  It will include daily pricing information pulled from coin market cap.
    51  Information will be written to transactions.csv
    52  
    53  NOTE: Due to the nature of block times being ~10min, there could be small gaps of time that are missed
    54  in the final minutes of the last day of the reported period.`,
    55  		Run: wrap(accountingtxnscmd),
    56  	}
    57  )
    58  
    59  // accountingcmd is the handler for the command `skyc accounting`.
    60  // It generates a csv file of the accounting information for the node.
    61  func accountingcmd() {
    62  	// Create csv file
    63  	f, err := os.Create(accountingCSV)
    64  	if err != nil {
    65  		die(err)
    66  	}
    67  	defer func() {
    68  		err = f.Close()
    69  		if err != nil {
    70  			die(err)
    71  		}
    72  	}()
    73  
    74  	// Grab the accounting information
    75  	ais, err := httpClient.AccountingGet(accountingRangeStartTime, accountingRangeEndTime)
    76  	if err != nil {
    77  		die("Unable to get accounting information: ", err)
    78  	}
    79  
    80  	// Write the information to the csv file.
    81  	err = writeAccountingCSV(ais, f)
    82  	if err != nil {
    83  		die("Unable to write accounting information to csv file: ", err)
    84  	}
    85  
    86  	fmt.Println("CSV Successfully generated!")
    87  }
    88  
    89  // accountingtxnscmd is the handler for the command `skyc accounting txns`.
    90  // It generates a csv file of the wallet's transactions for the node including
    91  // daily pricing information from coin market cap.
    92  func accountingtxnscmd() {
    93  	// Validate only one option is used
    94  	if (accountingYear != 0 && accountingQuarter != 0) ||
    95  		(accountingYear != 0 && accountingMonth != 0) ||
    96  		(accountingQuarter != 0 && accountingMonth != 0) {
    97  		die("only one flag can be set, year, quarter, or month")
    98  	}
    99  
   100  	// Create csv file
   101  	f, err := os.Create(transactionsCSV)
   102  	if err != nil {
   103  		die(err)
   104  	}
   105  	defer func() {
   106  		err = f.Close()
   107  		if err != nil {
   108  			die(err)
   109  		}
   110  	}()
   111  
   112  	// Load current time
   113  	today := time.Now()
   114  
   115  	// Grab the current block height
   116  	cg, err := httpClient.ConsensusGet()
   117  	if err != nil {
   118  		die(err)
   119  	}
   120  
   121  	// Grab the startBlock, endBlock, startTime, and endTime from the year
   122  	var startBlock, endBlock types.BlockHeight
   123  	var startTime, endTime int64
   124  	if accountingYear != 0 {
   125  		fmt.Println("Generating Accounting for", accountingYear)
   126  		currentYear := today.Year()
   127  		if accountingYear > currentYear {
   128  			die("invalid year, can't be in the future", accountingYear)
   129  		}
   130  		yearsInThePast := accountingYear - currentYear
   131  		today = today.AddDate(yearsInThePast, 0, 0)
   132  		startBlock, endBlock, startTime, endTime = startAndEndBlocksAndTimes(cg.Height, today, time.January, time.December)
   133  	}
   134  	if accountingQuarter != 0 {
   135  		fmt.Printf("Generating Accounting for Q%v\n", accountingQuarter)
   136  		var startMonth, endMonth time.Month
   137  		switch accountingQuarter {
   138  		case 1:
   139  			startMonth = time.January
   140  			endMonth = time.March
   141  		case 2:
   142  			startMonth = time.April
   143  			endMonth = time.June
   144  		case 3:
   145  			startMonth = time.July
   146  			endMonth = time.September
   147  		case 4:
   148  			startMonth = time.October
   149  			endMonth = time.December
   150  		default:
   151  			die("invalid quarter, should be 1 - 4")
   152  		}
   153  		startBlock, endBlock, startTime, endTime = startAndEndBlocksAndTimes(cg.Height, today, startMonth, endMonth)
   154  	}
   155  	if accountingMonth != 0 {
   156  		if accountingMonth < 0 || accountingMonth > 12 {
   157  			die("invalid month, should be 1 - 12")
   158  		}
   159  		fmt.Println("Generating Accounting for", time.Month(accountingMonth).String())
   160  		startBlock, endBlock, startTime, endTime = startAndEndBlocksAndTimes(cg.Height, today, time.Month(accountingMonth), time.Month(accountingMonth))
   161  	}
   162  	if accountingYear == 0 && accountingQuarter == 0 && accountingMonth == 0 {
   163  		fmt.Println("Generating Accounting for YTD")
   164  		// Default case, year to date
   165  		//
   166  		// End is the current block height
   167  		endBlock = cg.Height
   168  		endTime = today.Unix()
   169  
   170  		// Set start height
   171  		startBlock, startTime = blockAndTime(today, time.January, endBlock, true)
   172  	}
   173  
   174  	// Get Historical SCUSD Price Data
   175  	scPriceInfo, err := scUSDPrices(startTime, endTime)
   176  	if err != nil {
   177  		die("Could not get SC Price info: ", err)
   178  	}
   179  
   180  	// Get full wallet records
   181  	records, err := accountingWalletTxnRecords(scPriceInfo, startBlock, endBlock, startTime)
   182  	if err != nil {
   183  		die(err)
   184  	}
   185  
   186  	// Write the information to the csv file.
   187  	err = writeCSV(transactionHeaders, records, f)
   188  	if err != nil {
   189  		die("Unable to write transaction information to csv file: ", err)
   190  	}
   191  
   192  	fmt.Println("CSV Successfully generated!")
   193  }
   194  
   195  // accountingWalletTxnRecords generates the wallet txns records to be written to
   196  // a CSV file.
   197  func accountingWalletTxnRecords(scPrices []SCPrice, start, end types.BlockHeight, startTime int64) ([][]string, error) {
   198  	vts, err := walletValuedTransactions(start, end)
   199  	if err != nil {
   200  		return nil, errors.AddContext(err, "could not compute valued transaction")
   201  	}
   202  
   203  	// Get full wallet records
   204  	fullRecords := walletTransactionsRecords(vts, startTime)
   205  
   206  	// Parse out updated SC records
   207  	var records [][]string
   208  	for _, fullRecord := range fullRecords {
   209  		// Pull out TimeStamp
   210  		timestampStr := fullRecord[0]
   211  		if timestampStr == "unconfirmed" {
   212  			continue
   213  		}
   214  		timestamp, err := time.Parse(walletTxnTimestampFormat, timestampStr)
   215  		if err != nil {
   216  			return nil, err
   217  		}
   218  
   219  		// Pull out SC
   220  		scStr := fullRecord[3]
   221  
   222  		// Check for negative currency
   223  		isNegativeValue := strings.HasPrefix(scStr, "-")
   224  		hastings, err := types.ParseCurrency(strings.TrimPrefix(scStr, "-"))
   225  		if err != nil {
   226  			return nil, err
   227  		}
   228  		var funds types.Currency
   229  		_, err = fmt.Sscan(hastings, &funds)
   230  		if err != nil {
   231  			return nil, err
   232  		}
   233  
   234  		// Convert to USD Price
   235  		scAveragePrice, usdAmount := averageSCPriceAndTxnValue(scPrices, funds, timestamp)
   236  
   237  		// Adjust for negative values
   238  		if isNegativeValue {
   239  			usdAmount *= -1
   240  		}
   241  		records = append(records, []string{timestampStr, scStr, "", scAveragePrice, fmt.Sprintf("%.2f", usdAmount)})
   242  	}
   243  
   244  	return records, nil
   245  }
   246  
   247  // averageSCPriceAndTxnValue pulls out the average SC Price for the timestamp
   248  // and returns it, and converts the hastings to a currency value (ie $10.00)
   249  // based on the price and returns it.
   250  func averageSCPriceAndTxnValue(scPrices []SCPrice, hastings types.Currency, timestamp time.Time) (string, float64) {
   251  	// If hastings is zero, then the conversion is 0
   252  	if hastings.IsZero() {
   253  		return "0", 0
   254  	}
   255  	for _, scp := range scPrices {
   256  		if timestamp.Before(scp.StartTime) || timestamp.After(scp.EndTime) {
   257  			continue
   258  		}
   259  		// Convert hastings to a BigRat
   260  		hastingsRat := new(big.Rat).SetInt(hastings.Big())
   261  		siacoinPrecisionRat := new(big.Rat).SetInt(types.SiacoinPrecision.Big())
   262  		siacoinRat := new(big.Rat).Quo(hastingsRat, siacoinPrecisionRat)
   263  
   264  		// Convert to currency
   265  		scpAverageRat := new(big.Rat).SetFloat64(scp.Average)
   266  		avgPriceRat := new(big.Rat).Mul(siacoinRat, scpAverageRat)
   267  		avgPrice, exact := avgPriceRat.Float64()
   268  		if !exact && math.IsInf(avgPrice, 0) {
   269  			die("error getting average price, infinite float")
   270  		}
   271  		return fmt.Sprint(scp.Average), avgPrice
   272  	}
   273  	return "", 0
   274  }
   275  
   276  // startAndEndBlocksAndTimes returns the start time, end time, start block, and
   277  // end block based on the start and end month.
   278  func startAndEndBlocksAndTimes(currentHeight types.BlockHeight, today time.Time, startMonth, endMonth time.Month) (startBlock, endBlock types.BlockHeight, startTime, endTime int64) {
   279  	// Determine the start of the month
   280  	startBlock, startTime = blockAndTime(today, startMonth, currentHeight, true)
   281  
   282  	// Determine the end of the month
   283  	endBlock, endTime = blockAndTime(today, endMonth, currentHeight, false)
   284  	return
   285  }
   286  
   287  // blockAndTime returns the blockHeight and the unix time for either the start
   288  // or end of a given month.
   289  func blockAndTime(today time.Time, month time.Month, refHeight types.BlockHeight, start bool) (types.BlockHeight, int64) {
   290  	blockHeight, dateTime64 := blockAndTimeEstimate(today, month, refHeight, start)
   291  
   292  	return blockAndTimeExact(httpClient, blockHeight, dateTime64, start)
   293  }
   294  
   295  // blockAndTimeExact returns the exact blockHeight and the unix time for either
   296  // the start or end of a given month.
   297  func blockAndTimeExact(c client.Client, blockHeight types.BlockHeight, dateTime64 int64, start bool) (types.BlockHeight, int64) {
   298  	// Get the unix timestamp
   299  	dateTime := types.Timestamp(dateTime64)
   300  
   301  	// Check initial block height to see if it is actually in the
   302  	// range
   303  	cbg, err := c.ConsensusBlocksHeightGet(blockHeight)
   304  	if err != nil {
   305  		die(err)
   306  	}
   307  
   308  	// Define helpers
   309  	prev := blockHeight
   310  	next := blockHeight
   311  
   312  	if cbg.Timestamp < dateTime {
   313  		// If the timestamp of the block is further in the past than the
   314  		// dateTime, we need to increment the block height until we find
   315  		// the desired block height.
   316  		//
   317  		// For start times, this means finding the lowest block height
   318  		// that is greater than or equal to the dateTime.
   319  		//
   320  		// For end times, this means find the highest block height that
   321  		// is less than or equal to the dateTime.
   322  		for cbg.Timestamp < dateTime {
   323  			prev = next
   324  			next = next + 1
   325  			cbg, err = c.ConsensusBlocksHeightGet(next)
   326  			if err != nil {
   327  				die(err)
   328  			}
   329  		}
   330  		if start || (!start && cbg.Timestamp == dateTime) {
   331  			// For start times we return the next block since its
   332  			// timestamp is >= to the dateTime.
   333  			//
   334  			// We also return the next block for end times if the
   335  			// current timestamp is equal to the dateTime.
   336  			blockHeight = next
   337  		} else {
   338  			// If the timestamps are not equal, we return the prev
   339  			// block for the end times.
   340  			blockHeight = prev
   341  		}
   342  	} else {
   343  		// If the timestamp of the block is further in the future than
   344  		// the dateTime, we need to decrement the block height until we
   345  		// find the desired block height.
   346  		//
   347  		// For start times, this means finding the lowest block height
   348  		// that is greater than or equal to the dateTime.
   349  		//
   350  		// For end times, this means find the highest block height that
   351  		// is less than or equal to the dateTime.
   352  		for cbg.Timestamp > dateTime {
   353  			prev = next
   354  			next = next - 1
   355  			cbg, err = c.ConsensusBlocksHeightGet(next)
   356  			if err != nil {
   357  				die(err)
   358  			}
   359  		}
   360  		if !start || (start && cbg.Timestamp == dateTime) {
   361  			// For end times we return the next block since its
   362  			// timestamp is <= to the dateTime.
   363  			//
   364  			// We also return the next block for start times if the
   365  			// current timestamp is equal to the dateTime.
   366  			blockHeight = next
   367  		} else {
   368  			// If the timestamps are not equal, we return the prev
   369  			// block for the start times.
   370  			blockHeight = prev
   371  		}
   372  	}
   373  
   374  	return blockHeight, dateTime64
   375  }
   376  
   377  // blockAndTimeEstimate returns the estimated blockHeight and the unix time for
   378  // either the start or end of a given month. The unix time is accurate but the
   379  // block height is an estimated calculation.
   380  func blockAndTimeEstimate(today time.Time, month time.Month, refHeight types.BlockHeight, start bool) (types.BlockHeight, int64) {
   381  	// Grab the year
   382  	year := today.Year()
   383  
   384  	// Determine either the first of the month or last of the month
   385  	var date time.Time
   386  	if start {
   387  		date = time.Date(year, month, 1, 0, 0, 0, 0, today.Location())
   388  	} else {
   389  		day := skymodules.DaysInMonth(month, year)
   390  		date = time.Date(year, month, day, 23, 59, 59, 0, today.Location())
   391  	}
   392  
   393  	// Calculate an initial estimate of the block height
   394  	secondsSinceDate := uint64(today.Sub(date).Seconds())
   395  	blocksSinceStart := types.BlockHeight(secondsSinceDate) / types.BlockFrequency
   396  	blockHeight := refHeight - blocksSinceStart
   397  
   398  	return blockHeight, date.Unix()
   399  }
   400  
   401  // writeAccountingCSV is a helper to write the accounting information to a csv
   402  // file.
   403  func writeAccountingCSV(ais []skymodules.AccountingInfo, w io.Writer) error {
   404  	// Build the records. Write Wallet info first, then Renter info.
   405  	var records [][]string
   406  	for _, ai := range ais {
   407  		timeStr := strconv.FormatInt(ai.Timestamp, 10)
   408  		scStr := ai.Wallet.ConfirmedSiacoinBalance.String()
   409  		sfStr := ai.Wallet.ConfirmedSiafundBalance.String()
   410  		usStr := ai.Renter.UnspentUnallocated.String()
   411  		whStr := ai.Renter.WithheldFunds.String()
   412  		record := []string{timeStr, scStr, sfStr, usStr, whStr}
   413  		records = append(records, record)
   414  	}
   415  	return writeCSV(accountingHeaders, records, w)
   416  }
   417  
   418  // writeCSV writes the headers and records to a csv file
   419  func writeCSV(headers []string, records [][]string, w io.Writer) error {
   420  	// Create csv writer
   421  	csvW := csv.NewWriter(w)
   422  
   423  	// Convert to csv format
   424  	//
   425  	// Write Headers for Reference
   426  	err := csvW.Write(headers)
   427  	if err != nil {
   428  		return errors.AddContext(err, "unable to write header")
   429  	}
   430  
   431  	// Write output to file
   432  	err = csvW.WriteAll(records)
   433  	if err != nil {
   434  		return errors.AddContext(err, "unable to write records")
   435  	}
   436  
   437  	// Flush the writer and check for an error
   438  	csvW.Flush()
   439  	if err := csvW.Error(); err != nil {
   440  		die("Error when flushing csv writer: ", err)
   441  	}
   442  	return nil
   443  }