diff --git a/cmd/dcrdata/internal/api/apirouter.go b/cmd/dcrdata/internal/api/apirouter.go index 4d35bf479..1b7ebbcca 100644 --- a/cmd/dcrdata/internal/api/apirouter.go +++ b/cmd/dcrdata/internal/api/apirouter.go @@ -239,6 +239,10 @@ func NewAPIRouter(app *appContext, JSONIndent string, useRealIP, compressLarge b r.With(m.ChartTypeCtx).Get("/{charttype}", app.ChartTypeData) }) + mux.Route("/stakingcalc", func(r chi.Router) { + r.Get("/get-future-reward", app.getStakeRewardCalc) + }) + mux.Route("/ticketpool", func(r chi.Router) { r.Get("/", app.getTicketPoolByDate) r.With(m.TicketPoolCtx).Get("/bydate/{tp}", app.getTicketPoolByDate) diff --git a/cmd/dcrdata/internal/api/apiroutes.go b/cmd/dcrdata/internal/api/apiroutes.go index 5dd518871..36b1fead6 100644 --- a/cmd/dcrdata/internal/api/apiroutes.go +++ b/cmd/dcrdata/internal/api/apiroutes.go @@ -14,6 +14,7 @@ import ( "fmt" "html" "io" + "math" "net/http" "reflect" "sort" @@ -141,6 +142,17 @@ type AppContextConfig struct { AppVer string } +type simulationRow struct { + SimBlock float64 `json:"height"` + SimDay int `json:"day"` + TicketPrice float64 `json:"ticket_price"` + MatrueTickets float64 `json:"matured_tickets"` + DCRBalance float64 `json:"dcr_balance"` + TicketsPurchased float64 `json:"tickets_purchased"` + Reward float64 `json:"reward"` + ReturnedFund float64 `json:"returned_fund"` +} + // NewContext constructs a new appContext from the RPC client and database, and // JSON indentation string. func NewContext(cfg *AppContextConfig) *appContext { @@ -680,6 +692,78 @@ func (c *appContext) getTransactionHex(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, hex) } +func (c *appContext) getStakeRewardCalc(w http.ResponseWriter, r *http.Request) { + // Get parameters. Contain Amount, StartDate and EndDate + startingBalance, err := strconv.ParseFloat(r.URL.Query().Get("startingBalance"), 64) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), + http.StatusInternalServerError) + return + } + startDateUnix, err := strconv.ParseInt(r.URL.Query().Get("startDate"), 10, 64) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), + http.StatusInternalServerError) + return + } + endDateUnix, err := strconv.ParseInt(r.URL.Query().Get("endDate"), 10, 64) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), + http.StatusInternalServerError) + return + } + + //Get ticket price (Stake Difficulty) + ticketPriceInput := c.DataSource.GetStakeDiffEstimates().CurrentStakeDifficulty + //Ticket Pool Info + ticketPoolValue := c.DataSource.GetPoolInfo(int(c.DataSource.Height())).Value + coinSupply := c.DataSource.CurrentCoinSupply() + + if coinSupply == nil { + apiLog.Error("Unable to get coin supply.") + http.Error(w, http.StatusText(422), 422) + return + } + + //Get coin supply value + var coinSupplyTmp = dcrutil.Amount(coinSupply.Mined).ToCoin() + + startDate := time.Unix(startDateUnix/1000, 0).UTC().Truncate(24 * time.Hour) + endDate := time.Unix(endDateUnix/1000, 0).UTC().Truncate(24 * time.Hour) + today := time.Now().UTC().Truncate(24 * time.Hour) + + var startingHeight = c.DataSource.Height() + + if startDate != today { + duration := startDate.Sub(today) + minutes := duration.Minutes() + duration.Hours()*60 + minutesPerBlock := c.Params.TargetTimePerBlock.Minutes() + c.Params.TargetTimePerBlock.Hours()*60 + blockDiff := minutes / minutesPerBlock + blockDiff = math.Abs(blockDiff) + if startDate.Before(today) { + startingHeight = c.DataSource.Height() - int64(blockDiff) + } else { + startingHeight = c.DataSource.Height() + int64(blockDiff) + } + } + + // accumulated staking reward + var stakePerc = ticketPoolValue / coinSupplyTmp + asr, ticketPrice, simulationTable := c.simulateStakingReward((endDate.Sub(startDate)).Hours()/24, startingBalance, true, + stakePerc, coinSupplyTmp, float64(startingHeight), ticketPriceInput, float64(c.DataSource.Height())) + writeJSON(w, struct { + Height int64 `json:"height"` + Reward float64 `json:"reward"` + TicketPrice float64 `json:"ticketPrice"` + SimulationTable []simulationRow `json:"simulation_table"` + }{ + Height: startingHeight, + Reward: asr, + TicketPrice: ticketPrice, + SimulationTable: simulationTable, + }, m.GetIndentCtx(r)) +} + func (c *appContext) getDecodedTx(w http.ResponseWriter, r *http.Request) { // Look up any spending transactions for each output of this transaction // when the client requests spends with the URL query ?spends=true. @@ -2088,3 +2172,123 @@ func (c *appContext) getBlockHashCtx(r *http.Request) (string, error) { } return hash, nil } + +// 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 (c *appContext) simulateStakingReward(numberOfDays float64, startingDCRBalance float64, integerTicketQty bool, + currentStakePercent float64, actualCoinbase float64, startingBlockHeight float64, + actualTicketPrice float64, height float64) (stakingReward, ticketPrice float64, simulationTable []simulationRow) { + + blocksPerDay := 86400 / c.Params.TargetTimePerBlock.Seconds() + numberOfBlocks := numberOfDays * blocksPerDay + ticketsPurchased := float64(0) + StakeRewardAtBlock := func(blocknum float64) float64 { + // Option 1: RPC Call + + Subsidy, _ := c.nodeClient.GetBlockSubsidy(context.TODO(), int64(blocknum), 1) + return dcrutil.Amount(Subsidy.PoS).ToCoin() + + // Option 2: Calculation + // epoch := math.Floor(blocknum / float64(exp.ChainParams.SubsidyReductionInterval)) + // RewardProportionPerVote := float64(exp.ChainParams.StakeRewardProportion) / (10 * float64(exp.ChainParams.TicketsPerBlock)) + // return RewardProportionPerVote * dcrutil.Amount(exp.ChainParams.BaseSubsidy).ToCoin() * + // math.Pow(float64(exp.ChainParams.MulSubsidy)/float64(exp.ChainParams.DivSubsidy), epoch) + } + + 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. + + return (-9e-19*math.Pow(blocknum, 4) + + 7e-12*math.Pow(blocknum, 3) - + 2e-05*math.Pow(blocknum, 2) + + 29.757*blocknum + 76963 + + 1680000) // Premine 1.68M + } + + CoinAdjustmentFactor := actualCoinbase / MaxCoinSupplyAtBlock(startingBlockHeight) + var meanVotingBlock = txhelpers.CalcMeanVotingBlocks(c.Params) + TheoreticalTicketPrice := func(blocknum float64) float64 { + ProjectedCoinsCirculating := MaxCoinSupplyAtBlock(blocknum) * CoinAdjustmentFactor * currentStakePercent + TicketPoolSize := (float64(meanVotingBlock) + float64(c.Params.TicketMaturity) + + float64(c.Params.CoinbaseMaturity)) * float64(c.Params.TicketsPerBlock) + return ProjectedCoinsCirculating / TicketPoolSize + } + ticketPrice = TheoreticalTicketPrice(startingBlockHeight) + TicketAdjustmentFactor := actualTicketPrice / TheoreticalTicketPrice(height) + // Prepare for simulation + simblock := startingBlockHeight + var TicketPrice float64 + DCRBalance := startingDCRBalance + + simulationTable = append(simulationTable, simulationRow{ + SimBlock: simblock, + SimDay: 0, + DCRBalance: DCRBalance, + TicketPrice: ticketPrice, + }) + + for simblock < (numberOfBlocks + startingBlockHeight) { + // Simulate a Purchase on simblock + TicketPrice = TheoreticalTicketPrice(simblock) * TicketAdjustmentFactor + if integerTicketQty { + // Use this to simulate integer qtys of tickets up to max funds + 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) + } + + simulationTable[len(simulationTable)-1].TicketPrice = TicketPrice + simulationTable[len(simulationTable)-1].TicketsPurchased = ticketsPurchased + + DCRBalance -= (TicketPrice * ticketsPurchased) + + // Move forward to average vote + simblock += (float64(c.Params.TicketMaturity) + float64(meanVotingBlock)) + + // Simulate return of funds + DCRBalance += (TicketPrice * ticketsPurchased) + + // Simulate reward + DCRBalance += (StakeRewardAtBlock(simblock) * ticketsPurchased) + + blocksPassed := simblock - simulationTable[len(simulationTable)-1].SimBlock + daysPassed := blocksPassed / blocksPerDay + day := simulationTable[len(simulationTable)-1].SimDay + int(daysPassed) + simulationTable = append(simulationTable, simulationRow{ + SimBlock: simblock, + SimDay: day, + DCRBalance: DCRBalance, + Reward: (StakeRewardAtBlock(simblock) * ticketsPurchased), + ReturnedFund: (TicketPrice * ticketsPurchased), + TicketPrice: TheoreticalTicketPrice(simblock) * TicketAdjustmentFactor, + }) + + // Move forward to coinbase maturity + simblock += float64(c.Params.CoinbaseMaturity) + // Need to receive funds before we can use them again so add 1 block + simblock++ + } + + // Scale down to exactly numberOfDays days + SimulationReward := ((DCRBalance - startingDCRBalance) / startingDCRBalance) * 100 + excessBlocks := (simblock - startingBlockHeight) + stakingReward = (numberOfBlocks / excessBlocks) * SimulationReward + overflow := startingDCRBalance * (SimulationReward - stakingReward) / 100 + simulationTable[len(simulationTable)-1].DCRBalance -= overflow + simulationTable[len(simulationTable)-1].SimDay -= int(excessBlocks / blocksPerDay) + simulationTable[len(simulationTable)-1].Reward -= overflow + // remove nagative rewards from the table + for i := len(simulationTable) - 1; i > 0; i-- { + if simulationTable[i].Reward >= 0 { + break + } + simulationTable[i-1].Reward += simulationTable[i].Reward + simulationTable[i].Reward = 0 + } + return +} diff --git a/cmd/dcrdata/internal/explorer/explorer.go b/cmd/dcrdata/internal/explorer/explorer.go index bfbbaf9e8..ef7e079a0 100644 --- a/cmd/dcrdata/internal/explorer/explorer.go +++ b/cmd/dcrdata/internal/explorer/explorer.go @@ -368,7 +368,7 @@ 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", "stakingreward"} for _, name := range tmpls { if err := exp.templates.addTemplate(name); err != nil { diff --git a/cmd/dcrdata/internal/explorer/explorerroutes.go b/cmd/dcrdata/internal/explorer/explorerroutes.go index 2cf68169c..ae2848783 100644 --- a/cmd/dcrdata/internal/explorer/explorerroutes.go +++ b/cmd/dcrdata/internal/explorer/explorerroutes.go @@ -2779,6 +2779,45 @@ func (exp *explorerUI) AttackCost(w http.ResponseWriter, r *http.Request) { io.WriteString(w, str) } +// StakeRewardCalcPage is the page handler for the "/stakingcalc" path. +func (exp *explorerUI) StakeRewardCalcPage(w http.ResponseWriter, r *http.Request) { + price := 24.42 + if exp.xcBot != nil { + if rate := exp.xcBot.Conversion(1.0); rate != nil { + price = rate.Value + } + } + + exp.pageData.RLock() + + ticketReward := exp.pageData.HomeInfo.TicketReward + rewardPeriod := exp.pageData.HomeInfo.RewardPeriod + + exp.pageData.RUnlock() + + str, err := exp.templates.execTemplateToString("stakingreward", struct { + *CommonPageData + RewardPeriod string + TicketReward float64 + DCRPrice float64 + }{ + CommonPageData: exp.commonData(r), + RewardPeriod: rewardPeriod, + TicketReward: ticketReward, + DCRPrice: price, + }) + + 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) +} + type verifyMessageResult struct { Address string Signature string diff --git a/cmd/dcrdata/main.go b/cmd/dcrdata/main.go index bfc68f06a..26ddc2ebc 100644 --- a/cmd/dcrdata/main.go +++ b/cmd/dcrdata/main.go @@ -789,6 +789,7 @@ func _main(ctx context.Context) error { r.With(explorer.MenuFormParser).Post("/set", explore.Home) r.Get("/attack-cost", explore.AttackCost) r.Get("/verify-message", explore.VerifyMessagePage) + r.Get("/stakingcalc", explore.StakeRewardCalcPage) r.With(mw.Tollbooth(limiter)).Post("/verify-message", explore.VerifyMessageHandler) }) diff --git a/cmd/dcrdata/public/js/controllers/stakingreward_controller.js b/cmd/dcrdata/public/js/controllers/stakingreward_controller.js new file mode 100644 index 000000000..826fcdef3 --- /dev/null +++ b/cmd/dcrdata/public/js/controllers/stakingreward_controller.js @@ -0,0 +1,244 @@ +import { Controller } from '@hotwired/stimulus' +import TurboQuery from '../helpers/turbolinks_helper' +import { requestJSON } from '../helpers/http' + +const responseCache = {} +let requestCounter = 0 + +function hasCache (k) { + if (!responseCache[k]) return false + const expiration = new Date(responseCache[k].expiration) + return expiration > new Date() +} + +export default class extends Controller { + static get targets () { + return [ + 'blockHeight', + 'startDate', 'endDate', + 'priceDCR', 'dayText', 'amount', 'days', 'daysText', + 'amountRoi', 'percentageRoi', + 'table', 'tableBody', 'rowTemplate', 'amountError', 'startDateErr' + ] + } + + async initialize () { + this.rewardPeriod = parseInt(this.data.get('rewardPeriod')) + // default, startDate is 3 month ago + this.last3Months = new Date() + this.last3Months.setMonth(this.last3Months.getMonth() - 3) + this.startDateTarget.value = this.formatDateToString(this.last3Months) + this.endDateTarget.value = this.formatDateToString(new Date()) + this.amountTarget.value = 1000 + + this.query = new TurboQuery() + this.settings = TurboQuery.nullTemplate([ + 'amount', 'start', 'end' + ]) + + this.defaultSettings = { + amount: 1000, + start: this.startDateTarget.value, + end: this.endDateTarget.value + } + + this.query.update(this.settings) + if (this.settings.amount) { + this.amountTarget.value = this.settings.amount + } + if (this.settings.start) { + this.startDateTarget.value = this.settings.start + } + if (this.settings.end) { + this.endDateTarget.value = this.settings.end + } + + this.calculate() + } + + // Amount input event + amountKeypress (e) { + if (e.keyCode === 13) { + this.amountChanged() + } + } + + // convert date to strin display (YYYY-MM-DD) + formatDateToString (date) { + return [ + date.getFullYear(), + ('0' + (date.getMonth() + 1)).slice(-2), + ('0' + date.getDate()).slice(-2) + ].join('-') + } + + updateQueryString () { + const [query, settings, defaults] = [{}, this.settings, this.defaultSettings] + for (const k in settings) { + if (!settings[k] || settings[k].toString() === defaults[k].toString()) continue + query[k] = settings[k] + } + this.query.replace(query) + } + + // When amount was changed + amountChanged () { + this.settings.amount = parseInt(this.amountTarget.value) + this.calculate() + } + + // StartDate type event + startDateKeypress (e) { + if (e.keyCode !== 13) { + return + } + if (!this.validateDate()) { + return + } + this.startDateChanged() + } + + // When startDate was changed + startDateChanged () { + if (!this.validateDate()) { + return + } + this.settings.start = this.startDateTarget.value + this.calculate() + } + + // EndDate type event + endDateKeypress (e) { + if (e.keyCode !== 13) { + return + } + if (!this.validateDate()) { + return + } + this.endDateChanged() + } + + // When EndDate was changed + endDateChanged () { + if (!this.validateDate()) { + return + } + this.settings.end = this.endDateTarget.value + this.calculate() + } + + // Validate Date range + validateDate () { + const startDate = new Date(this.startDateTarget.value) + const endDate = new Date(this.endDateTarget.value) + + if (startDate > endDate) { + console.log('Invalid date range') + this.startDateErrTarget.textContent = 'Invalid date range' + return + } + const days = this.getDaysOfRange(startDate, endDate) + if (days < this.rewardPeriod) { + console.log(`You must stake for more than ${this.rewardPeriod} days`) + this.startDateErrTarget.textContent = `You must stake for more than ${this.rewardPeriod} days` + return false + } + + this.startDateErrTarget.textContent = '' + return true + } + + hideAll (targets) { + targets.classList.add('d-none') + } + + showAll (targets) { + targets.classList.remove('d-none') + } + + // Get days between startDate and endDate + getDaysOfRange (startDate, endDate) { + const differenceInTime = endDate.getTime() - startDate.getTime() + return differenceInTime / (1000 * 3600 * 24) + } + + // Get Date from Days start from startDay + getDateFromDays (startDate, days) { + const tmpDate = new Date() + tmpDate.setFullYear(startDate.getFullYear()) + tmpDate.setMonth(startDate.getMonth()) + tmpDate.setDate(startDate.getDate() + Number(days)) + return this.formatDateToString(tmpDate) + } + + // Calculate and response + async calculate () { + const _this = this + requestCounter++ + const thisRequest = requestCounter + const amount = parseFloat(this.amountTarget.value) + if (!(amount > 0)) { + console.log('Amount must be greater than 0') + _this.amountErrorTarget.textContent = 'Amount must be greater than 0' + return + } + _this.amountErrorTarget.textContent = '' + + const startDate = new Date(this.startDateTarget.value) + const endDate = new Date(this.endDateTarget.value) + const days = this.getDaysOfRange(startDate, endDate) + + if (days < this.rewardPeriod) { + console.log(`You must stake for more than ${this.rewardPeriod} days`) + _this.startDateErrTarget.textContent = `You must stake for more than ${this.rewardPeriod} days` + return + } + _this.startDateErrTarget.textContent = '' + this.updateQueryString() + const startDateUnix = startDate.getTime() + const endDateUnix = endDate.getTime() + const url = `/api/stakingcalc/get-future-reward?startDate=${startDateUnix}&endDate=${endDateUnix}&startingBalance=${amount}` + let response + if (hasCache(url)) { + response = responseCache[url] + } else { + // response = await axios.get(url) + response = await requestJSON(url) + responseCache[url] = response + if (thisRequest !== requestCounter) { + // new request was issued while waiting. + this.startDateTarget.classList.remove('loading') + return + } + } + _this.daysTextTarget.textContent = parseInt(days) + + // number of periods + const totalPercentage = response.reward + const totalAmount = totalPercentage * amount * 1 / 100 + _this.percentageRoiTarget.textContent = totalPercentage.toFixed(2) + _this.amountRoiTarget.textContent = totalAmount.toFixed(2) + + if (!response.simulation_table || response.simulation_table.length === 0) { + this.hideAll(_this.tableTarget) + } else { + this.showAll(_this.tableTarget) + } + + _this.tableBodyTarget.innerHTML = '' + response.simulation_table.forEach(item => { + const exRow = document.importNode(_this.rowTemplateTarget.content, true) + const fields = exRow.querySelectorAll('td') + + fields[0].innerText = this.getDateFromDays(startDate, item.day) + fields[1].innerText = item.height + fields[2].innerText = item.ticket_price.toFixed(2) + fields[3].innerText = item.returned_fund.toFixed(2) + fields[4].innerText = item.reward.toFixed(2) + fields[5].innerText = item.dcr_balance.toFixed(2) + fields[6].innerText = (100 * (item.dcr_balance - amount) / amount).toFixed(2) + fields[7].innerText = item.tickets_purchased + _this.tableBodyTarget.appendChild(exRow) + }) + } +} diff --git a/cmd/dcrdata/views/extras.tmpl b/cmd/dcrdata/views/extras.tmpl index 2e43e0d45..8e1a758fa 100644 --- a/cmd/dcrdata/views/extras.tmpl +++ b/cmd/dcrdata/views/extras.tmpl @@ -113,6 +113,7 @@ Parameters Treasury Decode/Broadcast Tx + Staking Reward Calculator Verify Message {{- if eq .NetName "Mainnet"}} Switch To Testnet diff --git a/cmd/dcrdata/views/stakingreward.tmpl b/cmd/dcrdata/views/stakingreward.tmpl new file mode 100644 index 000000000..dc586a0ef --- /dev/null +++ b/cmd/dcrdata/views/stakingreward.tmpl @@ -0,0 +1,116 @@ +