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 }