diff --git a/cmd/dcrdata/internal/explorer/explorer.go b/cmd/dcrdata/internal/explorer/explorer.go index 823862378..f87c86b41 100644 --- a/cmd/dcrdata/internal/explorer/explorer.go +++ b/cmd/dcrdata/internal/explorer/explorer.go @@ -366,7 +366,8 @@ func New(cfg *ExplorerConfig) *explorerUI { "rawtx", "status", "parameters", "agenda", "agendas", "charts", "sidechains", "disapproved", "ticketpool", "visualblocks", "statistics", "windows", "timelisting", "addresstable", "proposals", "proposal", - "market", "insight_root", "attackcost", "treasury", "treasurytable", "verify_message"} + "market", "insight_root", "attackcost", "treasury", "treasurytable", + "verify_message", "stake_reward"} for _, name := range tmpls { if err := exp.templates.addTemplate(name); err != nil { @@ -569,9 +570,9 @@ func (exp *explorerUI) Store(blockData *blockdata.BlockData, msgBlock *wire.MsgB // Simulate the annual staking rate. go func(height int64, sdiff float64, supply int64) { - ASR, _ := exp.simulateASR(1000, false, stakePerc, + ASR := exp.simulateStakeReturn(1000, false, stakePerc, dcrutil.Amount(supply).ToCoin(), - float64(height), sdiff) + float64(height), sdiff, 365 /* for a year */) p.Lock() p.HomeInfo.ASR = ASR p.Unlock() @@ -673,32 +674,30 @@ func (exp *explorerUI) addRoutes() { exp.Mux.Get("/stats", redirect("statistics")) } -// Simulate ticket purchase and re-investment over a full year for a given -// starting amount of DCR and calculation parameters. Generate a TEXT table of -// the simulation results that can optionally be used for future expansion of -// dcrdata functionality. -func (exp *explorerUI) simulateASR(StartingDCRBalance float64, IntegerTicketQty bool, - CurrentStakePercent float64, ActualCoinbase float64, CurrentBlockNum float64, - ActualTicketPrice float64) (ASR float64, ReturnTable string) { +// simulateStakeReturn simulates ticket purchase and re-investment over the +// specified durationInDays for a given starting amount of DCR and calculation +// parameters. +func (exp *explorerUI) simulateStakeReturn(startingDCRBalance float64, integerTicketQty bool, + currentStakePercent float64, actualCoinbase float64, currentBlockNum float64, + actualTicketPrice float64, durationInDays float64) float64 { // Calculations are only useful on mainnet. Short circuit calculations if // on any other version of chain params. if exp.ChainParams.Name != "mainnet" { - return 0, "" + return 0 } - BlocksPerDay := 86400 / exp.ChainParams.TargetTimePerBlock.Seconds() - BlocksPerYear := 365 * BlocksPerDay - TicketsPurchased := float64(0) + blocksPerDay := 86400 / exp.ChainParams.TargetTimePerBlock.Seconds() + totalBlocksInDuration := durationInDays * blocksPerDay votesPerBlock := exp.ChainParams.VotesPerBlock() - StakeRewardAtBlock := func(blocknum float64) float64 { - Subsidy := exp.dataSource.BlockSubsidy(int64(blocknum), votesPerBlock) - return dcrutil.Amount(Subsidy.PoS / int64(votesPerBlock)).ToCoin() + stakeRewardAtBlock := func(blocknum float64) float64 { + subsidy := exp.dataSource.BlockSubsidy(int64(blocknum), votesPerBlock) + return dcrutil.Amount(subsidy.PoS / int64(votesPerBlock)).ToCoin() } - MaxCoinSupplyAtBlock := func(blocknum float64) float64 { + maxCoinSupplyAtBlock := func(blocknum float64) float64 { // 4th order poly best fit curve to Decred mainnet emissions plot. // Curve fit was done with 0 Y intercept and Pre-Mine added after. @@ -709,73 +708,54 @@ func (exp *explorerUI) simulateASR(StartingDCRBalance float64, IntegerTicketQty 1680000) // Premine 1.68M } - CoinAdjustmentFactor := ActualCoinbase / MaxCoinSupplyAtBlock(CurrentBlockNum) + coinAdjustmentFactor := actualCoinbase / maxCoinSupplyAtBlock(currentBlockNum) - TheoreticalTicketPrice := func(blocknum float64) float64 { - ProjectedCoinsCirculating := MaxCoinSupplyAtBlock(blocknum) * CoinAdjustmentFactor * CurrentStakePercent - TicketPoolSize := (float64(exp.MeanVotingBlocks) + float64(exp.ChainParams.TicketMaturity) + + theoreticalTicketPrice := func(blocknum float64) float64 { + projectedCoinsCirculating := maxCoinSupplyAtBlock(blocknum) * coinAdjustmentFactor * currentStakePercent + ticketPoolSize := (float64(exp.MeanVotingBlocks) + float64(exp.ChainParams.TicketMaturity) + float64(exp.ChainParams.CoinbaseMaturity)) * float64(exp.ChainParams.TicketsPerBlock) - return ProjectedCoinsCirculating / TicketPoolSize + return projectedCoinsCirculating / ticketPoolSize } - TicketAdjustmentFactor := ActualTicketPrice / TheoreticalTicketPrice(CurrentBlockNum) + ticketAdjustmentFactor := actualTicketPrice / theoreticalTicketPrice(currentBlockNum) // Prepare for simulation - simblock := CurrentBlockNum - TicketPrice := ActualTicketPrice - DCRBalance := StartingDCRBalance + simblock := currentBlockNum + dcrBalance := startingDCRBalance - ReturnTable += "\n\nBLOCKNUM DCR TICKETS TKT_PRICE TKT_REWRD ACTION\n" - ReturnTable += fmt.Sprintf("%8d %9.2f %8.1f %9.2f %9.2f INIT\n", - int64(simblock), DCRBalance, TicketsPurchased, - TicketPrice, StakeRewardAtBlock(simblock)) - - for simblock < (BlocksPerYear + CurrentBlockNum) { + for simblock < (totalBlocksInDuration + currentBlockNum) { // Simulate a Purchase on simblock - TicketPrice = TheoreticalTicketPrice(simblock) * TicketAdjustmentFactor + var ticketsPurchased float64 + ticketPrice := theoreticalTicketPrice(simblock) * ticketAdjustmentFactor - if IntegerTicketQty { + if integerTicketQty { // Use this to simulate integer qtys of tickets up to max funds - TicketsPurchased = math.Floor(DCRBalance / TicketPrice) + ticketsPurchased = math.Floor(dcrBalance / ticketPrice) } else { // Use this to simulate ALL funds used to buy tickets - even fractional tickets // which is actually not possible - TicketsPurchased = (DCRBalance / TicketPrice) + ticketsPurchased = (dcrBalance / ticketPrice) } - DCRBalance -= (TicketPrice * TicketsPurchased) - ReturnTable += fmt.Sprintf("%8d %9.2f %8.1f %9.2f %9.2f BUY\n", - int64(simblock), DCRBalance, TicketsPurchased, - TicketPrice, StakeRewardAtBlock(simblock)) + dcrBalance -= (ticketPrice * ticketsPurchased) // Move forward to average vote simblock += (float64(exp.ChainParams.TicketMaturity) + float64(exp.MeanVotingBlocks)) - ReturnTable += fmt.Sprintf("%8d %9.2f %8.1f %9.2f %9.2f VOTE\n", - int64(simblock), DCRBalance, TicketsPurchased, - (TheoreticalTicketPrice(simblock) * TicketAdjustmentFactor), StakeRewardAtBlock(simblock)) // Simulate return of funds - DCRBalance += (TicketPrice * TicketsPurchased) + dcrBalance += (ticketPrice * ticketsPurchased) // Simulate reward - DCRBalance += (StakeRewardAtBlock(simblock) * TicketsPurchased) - TicketsPurchased = 0 + dcrBalance += (stakeRewardAtBlock(simblock) * ticketsPurchased) // Move forward to coinbase maturity simblock += float64(exp.ChainParams.CoinbaseMaturity) - ReturnTable += fmt.Sprintf("%8d %9.2f %8.1f %9.2f %9.2f REWARD\n", - int64(simblock), DCRBalance, TicketsPurchased, - (TheoreticalTicketPrice(simblock) * TicketAdjustmentFactor), StakeRewardAtBlock(simblock)) - // Need to receive funds before we can use them again so add 1 block simblock++ } - // Scale down to exactly 365 days - SimulationReward := ((DCRBalance - StartingDCRBalance) / StartingDCRBalance) * 100 - ASR = (BlocksPerYear / (simblock - CurrentBlockNum)) * SimulationReward - ReturnTable += fmt.Sprintf("ASR over 365 Days is %.2f.\n", ASR) - return + simulationReward := ((dcrBalance - startingDCRBalance) / startingDCRBalance) * 100 + return (totalBlocksInDuration / (simblock - currentBlockNum)) * simulationReward } func (exp *explorerUI) watchExchanges() { diff --git a/cmd/dcrdata/internal/explorer/explorerroutes.go b/cmd/dcrdata/internal/explorer/explorerroutes.go index ea58fe109..9812747f5 100644 --- a/cmd/dcrdata/internal/explorer/explorerroutes.go +++ b/cmd/dcrdata/internal/explorer/explorerroutes.go @@ -2854,3 +2854,153 @@ func (exp *explorerUI) VerifyMessageHandler(w http.ResponseWriter, r *http.Reque } displayPage("", true) } + +type stakeReward struct { + Reward float64 + RewardInDcr float64 + RewardDurationInDays float64 + TotalTicketsCost float64 + Amount string + StartDate string + EndDate string + Error string +} + +// StakeReward is the page handler for the "GET /stake-reward" path. +func (exp *explorerUI) StakeReward(w http.ResponseWriter, r *http.Request) { + voteReward := exp.pageData.HomeInfo.NBlockSubsidy.PoS / int64(exp.ChainParams.VotesPerBlock()) + str, err := exp.templates.exec("stake_reward", struct { + *CommonPageData + VoteReward float64 + CurrentTicketPrice float64 + TicketReward float64 + MinimumRewardPeriod string + ExchangeRate *types.Conversion + StakeReward *stakeReward + }{ + VoteReward: toFloat64Amount(voteReward), + CurrentTicketPrice: exp.pageData.HomeInfo.StakeDiff, + TicketReward: exp.pageData.HomeInfo.TicketReward, + MinimumRewardPeriod: exp.pageData.HomeInfo.RewardPeriod, + ExchangeRate: exp.pageData.HomeInfo.ExchangeRate, + CommonPageData: exp.commonData(r), + }) + if err != nil { + log.Errorf("Template execute failure: %v", err) + exp.StatusPage(w, defaultErrorCode, defaultErrorMessage, "", ExpStatusError) + return + } + + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + io.WriteString(w, str) +} + +// CalculateStakeReward is the handler for "POST /stake-reward" path. +func (exp *explorerUI) CalculateStakeReward(w http.ResponseWriter, r *http.Request) { + startDateStr := r.PostFormValue("startDate") + endDateStr := r.PostFormValue("endDate") + amountStr := r.PostFormValue("amount") + + var reward, rewardInDcr, totalTicketCost float64 + var durationInDays float64 + var err error + exchangeRate := exp.pageData.HomeInfo.ExchangeRate + currentTicketPrice := exp.pageData.HomeInfo.StakeDiff + homeInfo := exp.pageData.HomeInfo + voteReward := exp.pageData.HomeInfo.NBlockSubsidy.PoS / int64(exp.ChainParams.VotesPerBlock()) + + displayPage := func(errMsg string) { + str, err := exp.templates.exec("stake_reward", struct { + *CommonPageData + VoteReward float64 + CurrentTicketPrice float64 + TicketReward float64 + MinimumRewardPeriod string + ExchangeRate *types.Conversion + StakeReward *stakeReward + }{ + CommonPageData: exp.commonData(r), + VoteReward: toFloat64Amount(voteReward), + CurrentTicketPrice: currentTicketPrice, + TicketReward: homeInfo.TicketReward, + MinimumRewardPeriod: homeInfo.RewardPeriod, + ExchangeRate: exchangeRate, + StakeReward: &stakeReward{ + Reward: reward, + Amount: amountStr, + RewardDurationInDays: durationInDays, + TotalTicketsCost: totalTicketCost, + StartDate: startDateStr, + RewardInDcr: rewardInDcr, + EndDate: endDateStr, + Error: errMsg, + }, + }) + + if err != nil { + log.Errorf("Template execute failure: %v", err) + exp.StatusPage(w, defaultErrorCode, defaultErrorMessage, "", ExpStatusError) + return + } + + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + io.WriteString(w, str) + } + + startDate, err := time.Parse("2006-01-02", startDateStr) + if err != nil { + displayPage("invalid start date") + return + } + + now := time.Now() + if startDate.Before(now) { + displayPage("staking start date must be in the future") + return + } + + endDate, err := time.Parse("2006-01-02", endDateStr) + if err != nil { + displayPage("invalid end date") + return + } + + if endDate.Before(now) { + displayPage("staking end date must be in the future") + return + } + + if startDate.After(endDate) { + displayPage("staking start date cannot be before specified end date") + return + } + + // Parse and ensure amount provided for tickets is sane. + amount, err := strconv.ParseInt(amountStr, 10, 64) + if err != nil { + displayPage(err.Error()) + return + } + + amountInDCR := float64(amount) / exchangeRate.Value + durationInDays = endDate.Sub(startDate).Hours() / 24 + minimumRewardDurationInDays := ((float64(exp.ChainParams.TicketMaturity) + + float64(exp.MeanVotingBlocks) + + float64(exp.ChainParams.CoinbaseMaturity)) * exp.ChainParams.TargetTimePerBlock.Hours()) / 24 + + if durationInDays < minimumRewardDurationInDays { + displayPage(fmt.Sprintf("minimum stake reward duration is %.2f days", minimumRewardDurationInDays)) + return + } + + reward = exp.simulateStakeReturn(amountInDCR, false, homeInfo.PoolInfo.Percentage/100, + dcrutil.Amount(homeInfo.CoinSupply).ToCoin(), float64(exp.pageData.BlockInfo.Height), + currentTicketPrice, durationInDays) + + rewardInDcr = amountInDCR * reward / 100 + totalTicketCost = math.Floor(amountInDCR/currentTicketPrice) * currentTicketPrice + + displayPage("") +} diff --git a/cmd/dcrdata/internal/explorer/templates.go b/cmd/dcrdata/internal/explorer/templates.go index 562a919aa..67a0b336f 100644 --- a/cmd/dcrdata/internal/explorer/templates.go +++ b/cmd/dcrdata/internal/explorer/templates.go @@ -325,6 +325,10 @@ func formattedDuration(duration time.Duration, str *periodMap) string { return i(durationsec) + pl(str.s, durationsec) } +func toFloat64Amount(intAmount int64) float64 { + return dcrutil.Amount(intAmount).ToCoin() +} + func makeTemplateFuncMap(params *chaincfg.Params) template.FuncMap { netTheme := "theme-" + strings.ToLower(netName(params)) @@ -363,6 +367,9 @@ func makeTemplateFuncMap(params *chaincfg.Params) template.FuncMap { "divideFloat": func(n, d float64) float64 { return n / d }, + "float64Multiply": func(x, y float64) float64 { + return x * y + }, "multiply": func(a, b int64) int64 { return a * b }, @@ -404,9 +411,7 @@ func makeTemplateFuncMap(params *chaincfg.Params) template.FuncMap { "amountAsDecimalParts": func(v int64, useCommas bool) []string { return float64Formatting(dcrutil.Amount(v).ToCoin(), 8, useCommas) }, - "toFloat64Amount": func(intAmount int64) float64 { - return dcrutil.Amount(intAmount).ToCoin() - }, + "toFloat64Amount": toFloat64Amount, "dcrPerKbToAtomsPerByte": func(amt dcrutil.Amount) int64 { return int64(math.Round(float64(amt) / 1e3)) }, diff --git a/cmd/dcrdata/main.go b/cmd/dcrdata/main.go index ae53a6827..efbc6c74e 100644 --- a/cmd/dcrdata/main.go +++ b/cmd/dcrdata/main.go @@ -786,6 +786,8 @@ func _main(ctx context.Context) error { r.Get("/attack-cost", explore.AttackCost) r.Get("/verify-message", explore.VerifyMessagePage) r.With(mw.Tollbooth(limiter)).Post("/verify-message", explore.VerifyMessageHandler) + r.Get("/stake-reward", explore.StakeReward) + r.With(mw.Tollbooth(limiter)).Post("/stake-reward", explore.CalculateStakeReward) }) // Configure a page for the bare "/insight" path. This mounts the static diff --git a/cmd/dcrdata/views/extras.tmpl b/cmd/dcrdata/views/extras.tmpl index e8e20a655..3663129b5 100644 --- a/cmd/dcrdata/views/extras.tmpl +++ b/cmd/dcrdata/views/extras.tmpl @@ -98,7 +98,7 @@ -