Calculate fee from percentage. (#69)

* Calculate fee from percentage.

- Reverted config to accept a fee percentage, not absolute value.
- The fee amount to be paid is now included in the `getfeeaddress` response. The current best block is used to calculate the fee percentage, and new blocks may be mined before the fee is paid, so the fee expiry period is shortened from 24 hours to 1 hour to mitigate this.
- Rename ticket db field to FeeAmount so it is more representative of the data it holds.
- API fields renamed to "FeePercentage" and "FeeAmount"
- Relay fee is still hard coded.

* Use getbestblockhash
This commit is contained in:
Jamie Holdstock 2020-05-27 14:44:40 +01:00 committed by GitHub
parent 87500c3fef
commit ccafd8dec4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 87 additions and 29 deletions

View File

@ -18,9 +18,9 @@ type NotificationHandler struct {
dcrdClient *rpc.DcrdRPC dcrdClient *rpc.DcrdRPC
} }
// The number of confirmations required to consider a ticket purchase or a fee
// transaction to be final.
const ( const (
// requiredConfs is the number of confirmations required to consider a
// ticket purchase or a fee transaction to be final.
requiredConfs = 6 requiredConfs = 6
) )

View File

@ -19,7 +19,7 @@ import (
var ( var (
defaultListen = ":3000" defaultListen = ":3000"
defaultLogLevel = "debug" defaultLogLevel = "debug"
defaultVSPFee = 0.001 defaultVSPFee = 0.05
defaultNetwork = "testnet" defaultNetwork = "testnet"
defaultHomeDir = dcrutil.AppDataDir("dcrvsp", false) defaultHomeDir = dcrutil.AppDataDir("dcrvsp", false)
defaultConfigFilename = "dcrvsp.conf" defaultConfigFilename = "dcrvsp.conf"
@ -35,7 +35,7 @@ type config struct {
LogLevel string `long:"loglevel" ini-name:"loglevel" description:"Logging level." choice:"trace" choice:"debug" choice:"info" choice:"warn" choice:"error" choice:"critical"` LogLevel string `long:"loglevel" ini-name:"loglevel" description:"Logging level." choice:"trace" choice:"debug" choice:"info" choice:"warn" choice:"error" choice:"critical"`
Network string `long:"network" ini-name:"network" description:"Decred network to use." choice:"testnet" choice:"mainnet" choice:"simnet"` Network string `long:"network" ini-name:"network" description:"Decred network to use." choice:"testnet" choice:"mainnet" choice:"simnet"`
FeeXPub string `long:"feexpub" ini-name:"feexpub" description:"Cold wallet xpub used for collecting fees."` FeeXPub string `long:"feexpub" ini-name:"feexpub" description:"Cold wallet xpub used for collecting fees."`
VSPFee float64 `long:"vspfee" ini-name:"vspfee" description:"Fee charged for VSP use. Absolute value - eg. 0.01 = 0.01 DCR."` VSPFee float64 `long:"vspfee" ini-name:"vspfee" description:"Fee percentage charged for VSP use. eg. 0.01 (1%), 0.05 (5%)."`
HomeDir string `long:"homedir" ini-name:"homedir" no-ini:"true" description:"Path to application home directory. Used for storing VSP database and logs."` HomeDir string `long:"homedir" ini-name:"homedir" no-ini:"true" description:"Path to application home directory. Used for storing VSP database and logs."`
ConfigFile string `long:"configfile" ini-name:"configfile" no-ini:"true" description:"Path to configuration file."` ConfigFile string `long:"configfile" ini-name:"configfile" no-ini:"true" description:"Path to configuration file."`
DcrdHost string `long:"dcrdhost" ini-name:"dcrdhost" description:"The ip:port to establish a JSON-RPC connection with dcrd. Should be the same host where dcrvsp is running."` DcrdHost string `long:"dcrdhost" ini-name:"dcrdhost" description:"The ip:port to establish a JSON-RPC connection with dcrd. Should be the same host where dcrvsp is running."`

View File

@ -19,7 +19,7 @@ func exampleTicket() Ticket {
CommitmentAddress: "Address", CommitmentAddress: "Address",
FeeAddressIndex: 12345, FeeAddressIndex: 12345,
FeeAddress: "FeeAddress", FeeAddress: "FeeAddress",
VSPFee: 0.1, FeeAmount: 0.1,
FeeExpiration: 4, FeeExpiration: 4,
Confirmed: false, Confirmed: false,
VoteChoices: map[string]string{"AgendaID": "Choice"}, VoteChoices: map[string]string{"AgendaID": "Choice"},
@ -106,7 +106,7 @@ func testGetTicketByHash(t *testing.T) {
retrieved.CommitmentAddress != ticket.CommitmentAddress || retrieved.CommitmentAddress != ticket.CommitmentAddress ||
retrieved.FeeAddressIndex != ticket.FeeAddressIndex || retrieved.FeeAddressIndex != ticket.FeeAddressIndex ||
retrieved.FeeAddress != ticket.FeeAddress || retrieved.FeeAddress != ticket.FeeAddress ||
retrieved.VSPFee != ticket.VSPFee || retrieved.FeeAmount != ticket.FeeAmount ||
retrieved.FeeExpiration != ticket.FeeExpiration || retrieved.FeeExpiration != ticket.FeeExpiration ||
retrieved.Confirmed != ticket.Confirmed || retrieved.Confirmed != ticket.Confirmed ||
!reflect.DeepEqual(retrieved.VoteChoices, ticket.VoteChoices) || !reflect.DeepEqual(retrieved.VoteChoices, ticket.VoteChoices) ||

View File

@ -17,7 +17,7 @@ type Ticket struct {
CommitmentAddress string `json:"commitmentaddress"` CommitmentAddress string `json:"commitmentaddress"`
FeeAddressIndex uint32 `json:"feeaddressindex"` FeeAddressIndex uint32 `json:"feeaddressindex"`
FeeAddress string `json:"feeaddress"` FeeAddress string `json:"feeaddress"`
VSPFee float64 `json:"vspfee"` FeeAmount float64 `json:"feeamount"`
FeeExpiration int64 `json:"feeexpiration"` FeeExpiration int64 `json:"feeexpiration"`
// Confirmed will be set when the ticket has 6+ confirmations. // Confirmed will be set when the ticket has 6+ confirmations.

View File

@ -15,7 +15,7 @@ import (
) )
const ( const (
defaultFeeAddressExpiration = 24 * time.Hour defaultFeeAddressExpiration = 1 * time.Hour
) )
func main() { func main() {

View File

@ -101,3 +101,19 @@ func (c *DcrdRPC) GetTicketCommitmentAddress(ticketHash string, netParams *chain
func (c *DcrdRPC) NotifyBlocks() error { func (c *DcrdRPC) NotifyBlocks() error {
return c.Call(c.ctx, "notifyblocks", nil) return c.Call(c.ctx, "notifyblocks", nil)
} }
func (c *DcrdRPC) GetBestBlockHeader() (*dcrdtypes.GetBlockHeaderVerboseResult, error) {
var bestBlockHash string
err := c.Call(c.ctx, "getbestblockhash", &bestBlockHash)
if err != nil {
return nil, err
}
verbose := true
var blockHeader dcrdtypes.GetBlockHeaderVerboseResult
err = c.Call(c.ctx, "getblockheader", &blockHeader, bestBlockHash, verbose)
if err != nil {
return nil, err
}
return &blockHeader, nil
}

View File

@ -5,6 +5,8 @@ import (
"sync" "sync"
"time" "time"
"decred.org/dcrwallet/wallet/txrules"
"github.com/decred/dcrd/dcrutil/v3"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding" "github.com/gin-gonic/gin/binding"
"github.com/jholdstock/dcrvsp/database" "github.com/jholdstock/dcrvsp/database"
@ -35,6 +37,28 @@ func getNewFeeAddress(db *database.VspDatabase, addrGen *addressGenerator) (stri
return addr, idx, nil return addr, idx, nil
} }
func getCurrentFee(dcrdClient *rpc.DcrdRPC) (float64, error) {
bestBlock, err := dcrdClient.GetBestBlockHeader()
if err != nil {
return 0, err
}
sDiff, err := dcrutil.NewAmount(bestBlock.SBits)
if err != nil {
return 0, err
}
relayFee, err := dcrutil.NewAmount(relayFee)
if err != nil {
return 0, err
}
fee := txrules.StakePoolTicketFee(sDiff, relayFee, int32(bestBlock.Height),
cfg.VSPFee, cfg.NetParams)
if err != nil {
return 0, err
}
return fee.ToCoin(), nil
}
// feeAddress is the handler for "POST /feeaddress". // feeAddress is the handler for "POST /feeaddress".
func feeAddress(c *gin.Context) { func feeAddress(c *gin.Context) {
@ -57,21 +81,29 @@ func feeAddress(c *gin.Context) {
// If the expiry period has passed we need to issue a new fee. // If the expiry period has passed we need to issue a new fee.
now := time.Now() now := time.Now()
if ticket.FeeExpired() { if ticket.FeeExpired() {
newFee, err := getCurrentFee(dcrdClient)
if err != nil {
log.Errorf("getCurrentFee error: %v", err)
sendErrorResponse("fee error", http.StatusInternalServerError, c)
return
}
ticket.FeeExpiration = now.Add(cfg.FeeAddressExpiration).Unix() ticket.FeeExpiration = now.Add(cfg.FeeAddressExpiration).Unix()
ticket.VSPFee = cfg.VSPFee ticket.FeeAmount = newFee
err := db.UpdateTicket(ticket) err = db.UpdateTicket(ticket)
if err != nil { if err != nil {
log.Errorf("UpdateTicket error: %v", err) log.Errorf("UpdateTicket error: %v", err)
sendErrorResponse("database error", http.StatusInternalServerError, c) sendErrorResponse("database error", http.StatusInternalServerError, c)
return return
} }
log.Debugf("Expired fee updated for ticket: newFeeAmt=%f, ticketHash=%s",
newFee, ticket.Hash)
} }
sendJSONResponse(feeAddressResponse{ sendJSONResponse(feeAddressResponse{
Timestamp: now.Unix(), Timestamp: now.Unix(),
Request: feeAddressRequest, Request: feeAddressRequest,
FeeAddress: ticket.FeeAddress, FeeAddress: ticket.FeeAddress,
Fee: ticket.VSPFee, FeeAmount: ticket.FeeAmount,
Expiration: ticket.FeeExpiration, Expiration: ticket.FeeExpiration,
}, c) }, c)
@ -104,6 +136,13 @@ func feeAddress(c *gin.Context) {
confirmed = true confirmed = true
} }
fee, err := getCurrentFee(dcrdClient)
if err != nil {
log.Errorf("getCurrentFee error: %v", err)
sendErrorResponse("fee error", http.StatusInternalServerError, c)
return
}
newAddress, newAddressIdx, err := getNewFeeAddress(db, addrGen) newAddress, newAddressIdx, err := getNewFeeAddress(db, addrGen)
if err != nil { if err != nil {
log.Errorf("getNewFeeAddress error: %v", err) log.Errorf("getNewFeeAddress error: %v", err)
@ -118,7 +157,7 @@ func feeAddress(c *gin.Context) {
FeeAddressIndex: newAddressIdx, FeeAddressIndex: newAddressIdx,
FeeAddress: newAddress, FeeAddress: newAddress,
Confirmed: confirmed, Confirmed: confirmed,
VSPFee: cfg.VSPFee, FeeAmount: fee,
FeeExpiration: expire, FeeExpiration: expire,
// VotingKey and VoteChoices: set during payfee // VotingKey and VoteChoices: set during payfee
} }
@ -131,13 +170,13 @@ func feeAddress(c *gin.Context) {
} }
log.Debugf("Fee address created for new ticket: tktConfirmed=%t, feeAddrIdx=%d, "+ log.Debugf("Fee address created for new ticket: tktConfirmed=%t, feeAddrIdx=%d, "+
"feeAddr=%s, ticketHash=%s", confirmed, newAddressIdx, newAddress, ticketHash) "feeAddr=%s, feeAmt=%f, ticketHash=%s", confirmed, newAddressIdx, newAddress, fee, ticketHash)
sendJSONResponse(feeAddressResponse{ sendJSONResponse(feeAddressResponse{
Timestamp: now.Unix(), Timestamp: now.Unix(),
Request: feeAddressRequest, Request: feeAddressRequest,
FeeAddress: newAddress, FeeAddress: newAddress,
Fee: cfg.VSPFee, FeeAmount: fee,
Expiration: expire, Expiration: expire,
}, c) }, c)
} }

View File

@ -89,7 +89,7 @@ func payFee(c *gin.Context) {
// Loop through transaction outputs until we find one which pays to the // Loop through transaction outputs until we find one which pays to the
// expected fee address. Record how much is being paid to the fee address. // expected fee address. Record how much is being paid to the fee address.
var feeAmount dcrutil.Amount var feePaid dcrutil.Amount
const scriptVersion = 0 const scriptVersion = 0
findAddress: findAddress:
@ -103,13 +103,13 @@ findAddress:
} }
for _, addr := range addresses { for _, addr := range addresses {
if addr.Address() == ticket.FeeAddress { if addr.Address() == ticket.FeeAddress {
feeAmount = dcrutil.Amount(txOut.Value) feePaid = dcrutil.Amount(txOut.Value)
break findAddress break findAddress
} }
} }
} }
if feeAmount == 0 { if feePaid == 0 {
log.Warnf("FeeTx for ticket %s did not include any payments for address %s", ticket.Hash, ticket.FeeAddress) log.Warnf("FeeTx for ticket %s did not include any payments for address %s", ticket.Hash, ticket.FeeAddress)
sendErrorResponse("feetx did not include any payments for fee address", http.StatusBadRequest, c) sendErrorResponse("feetx did not include any payments for fee address", http.StatusBadRequest, c)
return return
@ -125,15 +125,15 @@ findAddress:
// TODO: DB - validate votingkey against ticket submission address // TODO: DB - validate votingkey against ticket submission address
minFee, err := dcrutil.NewAmount(cfg.VSPFee) minFee, err := dcrutil.NewAmount(ticket.FeeAmount)
if err != nil { if err != nil {
log.Errorf("dcrutil.NewAmount: %v", err) log.Errorf("dcrutil.NewAmount: %v", err)
sendErrorResponse("fee error", http.StatusInternalServerError, c) sendErrorResponse("fee error", http.StatusInternalServerError, c)
return return
} }
if feeAmount < minFee { if feePaid < minFee {
log.Warnf("Fee too small: was %v, expected %v", feeAmount, minFee) log.Warnf("Fee too small from %s: was %v, expected %v", c.ClientIP(), feePaid, minFee)
sendErrorResponse("fee too small", http.StatusInternalServerError, c) sendErrorResponse("fee too small", http.StatusInternalServerError, c)
return return
} }
@ -153,7 +153,8 @@ findAddress:
return return
} }
log.Debugf("Fee tx received for ticket: ticketHash=%s", ticket.Hash) log.Debugf("Fee tx received for ticket: minExpectedFee=%v, feePaid=%v, "+
"ticketHash=%s", minFee, feePaid, ticket.Hash)
if ticket.Confirmed { if ticket.Confirmed {
feeTxHash, err := dcrdClient.SendRawTransaction(payFeeRequest.FeeTx) feeTxHash, err := dcrdClient.SendRawTransaction(payFeeRequest.FeeTx)

View File

@ -18,6 +18,6 @@ func pubKey(c *gin.Context) {
func fee(c *gin.Context) { func fee(c *gin.Context) {
sendJSONResponse(feeResponse{ sendJSONResponse(feeResponse{
Timestamp: time.Now().Unix(), Timestamp: time.Now().Unix(),
Fee: cfg.VSPFee, FeePercentage: cfg.VSPFee,
}, c) }, c)
} }

View File

@ -14,7 +14,7 @@
<table> <table>
<tr><td>Total tickets:</td><td>{{ .TotalTickets }}</td></tr> <tr><td>Total tickets:</td><td>{{ .TotalTickets }}</td></tr>
<tr><td>FeePaid tickets:</td><td>{{ .FeePaidTickets }}</td></tr> <tr><td>FeePaid tickets:</td><td>{{ .FeePaidTickets }}</td></tr>
<tr><td>VSP Fee:</td><td>{{ .VSPFee }} DCR per ticket</td></tr> <tr><td>VSP Fee:</td><td>{{ .VSPFee }}</td></tr>
<tr><td>Network:</td><td>{{ .Network }}</td></tr> <tr><td>Network:</td><td>{{ .Network }}</td></tr>
</table> </table>
<p>Last updated: {{.UpdateTime}}</p> <p>Last updated: {{.UpdateTime}}</p>

View File

@ -7,7 +7,7 @@ type pubKeyResponse struct {
type feeResponse struct { type feeResponse struct {
Timestamp int64 `json:"timestamp" binding:"required"` Timestamp int64 `json:"timestamp" binding:"required"`
Fee float64 `json:"fee" binding:"required"` FeePercentage float64 `json:"feepercentage" binding:"required"`
} }
type FeeAddressRequest struct { type FeeAddressRequest struct {
@ -18,7 +18,7 @@ type FeeAddressRequest struct {
type feeAddressResponse struct { type feeAddressResponse struct {
Timestamp int64 `json:"timestamp" binding:"required"` Timestamp int64 `json:"timestamp" binding:"required"`
FeeAddress string `json:"feeaddress" binding:"required"` FeeAddress string `json:"feeaddress" binding:"required"`
Fee float64 `json:"fee" binding:"required"` FeeAmount float64 `json:"feeamount" binding:"required"`
Expiration int64 `json:"expiration" binding:"required"` Expiration int64 `json:"expiration" binding:"required"`
Request FeeAddressRequest `json:"request" binding:"required"` Request FeeAddressRequest `json:"request" binding:"required"`
} }

View File

@ -27,10 +27,12 @@ type Config struct {
FeeAddressExpiration time.Duration FeeAddressExpiration time.Duration
} }
// The number of confirmations required to consider a ticket purchase or a fee
// transaction to be final.
const ( const (
// requiredConfs is the number of confirmations required to consider a
// ticket purchase or a fee transaction to be final.
requiredConfs = 6 requiredConfs = 6
// TODO: Make this configurable or get it from RPC.
relayFee = 0.0001
) )
var homepageData *gin.H var homepageData *gin.H