diff --git a/README.md b/README.md index 9e85833..af6a5c6 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,12 @@ ticket details + fee to a VSP, and the VSP will take the fee and vote in return. ### For Administrators -- bbolt db. +- bbolt db - no database admin required. +- Database is not used outside of dcrvsp server. - No stakepoold. - Client accountability. +- No need to use the same wallet seed on each voting wallet. +- Fees can change regularly - previously cached by wallet. ### For Users diff --git a/database/database_test.go b/database/database_test.go index 9f8d505..d57a9ba 100644 --- a/database/database_test.go +++ b/database/database_test.go @@ -35,11 +35,11 @@ func TestDatabase(t *testing.T) { // All sub-tests to run. tests := map[string]func(*testing.T){ - "testInsertFeeAddress": testInsertFeeAddress, - "testGetTicketByHash": testGetTicketByHash, - "testInsertFeeAddressVotingKey": testInsertFeeAddressVotingKey, - "testUpdateExpireAndFee": testUpdateExpireAndFee, - "testUpdateVoteChoices": testUpdateVoteChoices, + "testInsertTicket": testInsertTicket, + "testGetTicketByHash": testGetTicketByHash, + "testSetTicketVotingKey": testSetTicketVotingKey, + "testUpdateExpireAndFee": testUpdateExpireAndFee, + "testUpdateVoteChoices": testUpdateVoteChoices, } for testName, test := range tests { @@ -63,23 +63,23 @@ func TestDatabase(t *testing.T) { } } -func testInsertFeeAddress(t *testing.T) { +func testInsertTicket(t *testing.T) { // Insert a ticket into the database. ticket := exampleTicket() - err := db.InsertFeeAddress(ticket) + err := db.InsertTicket(ticket) if err != nil { t.Fatalf("error storing ticket in database: %v", err) } // Inserting a ticket with the same hash should fail. - err = db.InsertFeeAddress(ticket) + err = db.InsertTicket(ticket) if err == nil { t.Fatal("expected an error inserting ticket with duplicate hash") } // Inserting a ticket with empty hash should fail. ticket.Hash = "" - err = db.InsertFeeAddress(ticket) + err = db.InsertTicket(ticket) if err == nil { t.Fatal("expected an error inserting ticket with no hash") } @@ -88,7 +88,7 @@ func testInsertFeeAddress(t *testing.T) { func testGetTicketByHash(t *testing.T) { ticket := exampleTicket() // Insert a ticket into the database. - err := db.InsertFeeAddress(ticket) + err := db.InsertTicket(ticket) if err != nil { t.Fatalf("error storing ticket in database: %v", err) } @@ -120,10 +120,10 @@ func testGetTicketByHash(t *testing.T) { } } -func testInsertFeeAddressVotingKey(t *testing.T) { +func testSetTicketVotingKey(t *testing.T) { // Insert a ticket into the database. ticket := exampleTicket() - err := db.InsertFeeAddress(ticket) + err := db.InsertTicket(ticket) if err != nil { t.Fatalf("error storing ticket in database: %v", err) } @@ -132,7 +132,7 @@ func testInsertFeeAddressVotingKey(t *testing.T) { newVotingKey := ticket.VotingKey + "2" newVoteChoices := ticket.VoteChoices newVoteChoices["AgendaID"] = "Different choice" - err = db.InsertFeeAddressVotingKey(ticket.CommitmentAddress, newVotingKey, newVoteChoices) + err = db.SetTicketVotingKey(ticket.Hash, newVotingKey, newVoteChoices) if err != nil { t.Fatalf("error updating votingkey and votechoices: %v", err) } @@ -153,7 +153,7 @@ func testInsertFeeAddressVotingKey(t *testing.T) { func testUpdateExpireAndFee(t *testing.T) { // Insert a ticket into the database. ticket := exampleTicket() - err := db.InsertFeeAddress(ticket) + err := db.InsertTicket(ticket) if err != nil { t.Fatalf("error storing ticket in database: %v", err) } @@ -181,7 +181,7 @@ func testUpdateExpireAndFee(t *testing.T) { func testUpdateVoteChoices(t *testing.T) { // Insert a ticket into the database. ticket := exampleTicket() - err := db.InsertFeeAddress(ticket) + err := db.InsertTicket(ticket) if err != nil { t.Fatalf("error storing ticket in database: %v", err) } diff --git a/database/ticket.go b/database/ticket.go index 5040ca8..5e74be8 100644 --- a/database/ticket.go +++ b/database/ticket.go @@ -25,7 +25,7 @@ var ( ErrNoTicketFound = errors.New("no ticket found") ) -func (vdb *VspDatabase) InsertFeeAddress(ticket Ticket) error { +func (vdb *VspDatabase) InsertTicket(ticket Ticket) error { hashBytes := []byte(ticket.Hash) return vdb.db.Update(func(tx *bolt.Tx) error { ticketBkt := tx.Bucket(vspBktK).Bucket(ticketBktK) @@ -43,42 +43,40 @@ func (vdb *VspDatabase) InsertFeeAddress(ticket Ticket) error { }) } -func (vdb *VspDatabase) InsertFeeAddressVotingKey(address, votingKey string, voteChoices map[string]string) error { +func (vdb *VspDatabase) SetTicketVotingKey(ticketHash, votingKey string, voteChoices map[string]string) error { return vdb.db.Update(func(tx *bolt.Tx) error { ticketBkt := tx.Bucket(vspBktK).Bucket(ticketBktK) - c := ticketBkt.Cursor() - for k, v := c.First(); k != nil; k, v = c.Next() { - var ticket Ticket - err := json.Unmarshal(v, &ticket) - if err != nil { - return fmt.Errorf("could not unmarshal ticket: %v", err) - } + hashBytes := []byte(ticketHash) - if ticket.CommitmentAddress == address { - ticket.VotingKey = votingKey - ticket.VoteChoices = voteChoices - ticketBytes, err := json.Marshal(ticket) - if err != nil { - return err - } - err = ticketBkt.Put(k, ticketBytes) - if err != nil { - return err - } - } + ticketBytes := ticketBkt.Get(hashBytes) + if ticketBytes == nil { + return ErrNoTicketFound } - return nil + var ticket Ticket + err := json.Unmarshal(ticketBytes, &ticket) + if err != nil { + return fmt.Errorf("could not unmarshal ticket: %v", err) + } + + ticket.VotingKey = votingKey + ticket.VoteChoices = voteChoices + ticketBytes, err = json.Marshal(ticket) + if err != nil { + return fmt.Errorf("could not marshal ticket: %v", err) + } + + return ticketBkt.Put(hashBytes, ticketBytes) }) } -func (vdb *VspDatabase) GetTicketByHash(hash string) (Ticket, error) { +func (vdb *VspDatabase) GetTicketByHash(ticketHash string) (Ticket, error) { var ticket Ticket err := vdb.db.View(func(tx *bolt.Tx) error { ticketBkt := tx.Bucket(vspBktK).Bucket(ticketBktK) - ticketBytes := ticketBkt.Get([]byte(hash)) + ticketBytes := ticketBkt.Get([]byte(ticketHash)) if ticketBytes == nil { return ErrNoTicketFound } @@ -94,12 +92,12 @@ func (vdb *VspDatabase) GetTicketByHash(hash string) (Ticket, error) { return ticket, err } -func (vdb *VspDatabase) UpdateVoteChoices(hash string, voteChoices map[string]string) error { +func (vdb *VspDatabase) UpdateVoteChoices(ticketHash string, voteChoices map[string]string) error { return vdb.db.Update(func(tx *bolt.Tx) error { ticketBkt := tx.Bucket(vspBktK).Bucket(ticketBktK) - key := []byte(hash) + hashBytes := []byte(ticketHash) - ticketBytes := ticketBkt.Get(key) + ticketBytes := ticketBkt.Get(hashBytes) if ticketBytes == nil { return ErrNoTicketFound } @@ -116,16 +114,16 @@ func (vdb *VspDatabase) UpdateVoteChoices(hash string, voteChoices map[string]st return fmt.Errorf("could not marshal ticket: %v", err) } - return ticketBkt.Put(key, ticketBytes) + return ticketBkt.Put(hashBytes, ticketBytes) }) } -func (vdb *VspDatabase) UpdateExpireAndFee(hash string, expiration int64, vspFee float64) error { +func (vdb *VspDatabase) UpdateExpireAndFee(ticketHash string, expiration int64, vspFee float64) error { return vdb.db.Update(func(tx *bolt.Tx) error { ticketBkt := tx.Bucket(vspBktK).Bucket(ticketBktK) - key := []byte(hash) + hashBytes := []byte(ticketHash) - ticketBytes := ticketBkt.Get(key) + ticketBytes := ticketBkt.Get(hashBytes) if ticketBytes == nil { return ErrNoTicketFound } @@ -143,6 +141,6 @@ func (vdb *VspDatabase) UpdateExpireAndFee(hash string, expiration int64, vspFee return fmt.Errorf("could not marshal ticket: %v", err) } - return ticketBkt.Put(key, ticketBytes) + return ticketBkt.Put(hashBytes, ticketBytes) }) } diff --git a/rpc/feewallet.go b/rpc/feewallet.go index a7745c6..b49de43 100644 --- a/rpc/feewallet.go +++ b/rpc/feewallet.go @@ -5,6 +5,7 @@ import ( "fmt" wallettypes "decred.org/dcrwallet/rpc/jsonrpc/types" + "github.com/decred/dcrd/dcrutil/v3" dcrdtypes "github.com/decred/dcrd/rpc/jsonrpc/types/v2" ) @@ -112,3 +113,19 @@ func (c *FeeWalletRPC) SendRawTransaction(txHex string) (string, error) { } return txHash, nil } + +func (c *FeeWalletRPC) GetWalletFee() (dcrutil.Amount, error) { + var amount dcrutil.Amount + var feeF float64 + err := c.Call(c.ctx, "getwalletfee", &feeF) + if err != nil { + return amount, err + } + + amount, err = dcrutil.NewAmount(feeF) + if err != nil { + return amount, err + } + + return amount, nil +} diff --git a/webapi/getfeeaddress.go b/webapi/getfeeaddress.go index 62d4823..5aee50e 100644 --- a/webapi/getfeeaddress.go +++ b/webapi/getfeeaddress.go @@ -17,7 +17,7 @@ import ( "github.com/jholdstock/dcrvsp/rpc" ) -// feeAddress is the handler for "POST /feeaddress" +// feeAddress is the handler for "POST /feeaddress". func feeAddress(c *gin.Context) { var feeAddressRequest FeeAddressRequest if err := c.ShouldBindJSON(&feeAddressRequest); err != nil { @@ -26,7 +26,7 @@ func feeAddress(c *gin.Context) { return } - // ticketHash + // Validate TicketHash. ticketHashStr := feeAddressRequest.TicketHash txHash, err := chainhash.NewHashFromStr(ticketHashStr) if err != nil { @@ -35,7 +35,7 @@ func feeAddress(c *gin.Context) { return } - // signature - sanity check signature is in base64 encoding + // Validate Signature - sanity check signature is in base64 encoding. signature := feeAddressRequest.Signature if _, err = base64.StdEncoding.DecodeString(signature); err != nil { log.Warnf("Invalid signature from %s: %v", c.ClientIP(), err) @@ -187,9 +187,9 @@ func feeAddress(c *gin.Context) { // VotingKey and VoteChoices: set during payfee } - err = db.InsertFeeAddress(dbTicket) + err = db.InsertTicket(dbTicket) if err != nil { - log.Errorf("InsertFeeAddress error: %v", err) + log.Errorf("InsertTicket error: %v", err) sendErrorResponse("database error", http.StatusInternalServerError, c) return } diff --git a/webapi/payfee.go b/webapi/payfee.go index ed222e6..2308e2f 100644 --- a/webapi/payfee.go +++ b/webapi/payfee.go @@ -7,7 +7,6 @@ import ( "time" "decred.org/dcrwallet/wallet/txrules" - "github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/dcrec" "github.com/decred/dcrd/dcrutil/v3" "github.com/decred/dcrd/txscript/v3" @@ -16,7 +15,7 @@ import ( "github.com/jholdstock/dcrvsp/rpc" ) -// payFee is the handler for "POST /payfee" +// payFee is the handler for "POST /payfee". func payFee(c *gin.Context) { var payFeeRequest PayFeeRequest if err := c.ShouldBindJSON(&payFeeRequest); err != nil { @@ -25,6 +24,10 @@ func payFee(c *gin.Context) { return } + // TODO: Respond early if the fee tx has already been broadcast for this + // ticket. Maybe indicate status - mempool/awaiting confs/confirmed. + + // Validate VotingKey. votingKey := payFeeRequest.VotingKey votingWIF, err := dcrutil.DecodeWIF(votingKey, cfg.NetParams.PrivateKeyID) if err != nil { @@ -33,6 +36,7 @@ func payFee(c *gin.Context) { return } + // Validate VoteChoices. voteChoices := payFeeRequest.VoteChoices err = isValidVoteChoices(cfg.NetParams, currentVoteVersion(cfg.NetParams), voteChoices) if err != nil { @@ -41,6 +45,7 @@ func payFee(c *gin.Context) { return } + // Validate FeeTx. feeTxBytes, err := hex.DecodeString(payFeeRequest.FeeTx) if err != nil { log.Warnf("Failed to decode tx: %v", err) @@ -56,6 +61,15 @@ func payFee(c *gin.Context) { return } + feeTxBuf := new(bytes.Buffer) + feeTxBuf.Grow(feeTx.SerializeSize()) + err = feeTx.Serialize(feeTxBuf) + if err != nil { + log.Errorf("Serialize tx failed: %v", err) + sendErrorResponse("serialize tx error", http.StatusInternalServerError, c) + return + } + // TODO: DB - check expiration given during fee address request ticket, err := db.GetTicketByHash(payFeeRequest.TicketHash) @@ -64,6 +78,9 @@ func payFee(c *gin.Context) { sendErrorResponse("invalid ticket", http.StatusBadRequest, c) return } + + // 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. var feeAmount dcrutil.Amount const scriptVersion = 0 @@ -90,12 +107,6 @@ findAddress: return } - voteAddr, err := dcrutil.DecodeAddress(ticket.CommitmentAddress, cfg.NetParams) - if err != nil { - log.Errorf("DecodeAddress: %v", err) - sendErrorResponse("database error", http.StatusInternalServerError, c) - return - } _, err = dcrutil.NewAddressPubKeyHash(dcrutil.Hash160(votingWIF.PubKey()), cfg.NetParams, dcrec.STEcdsaSecp256k1) if err != nil { @@ -108,29 +119,6 @@ findAddress: sDiff := dcrutil.Amount(ticket.SDiff) - // TODO - RPC - get relayfee from wallet - relayFee, err := dcrutil.NewAmount(0.0001) - if err != nil { - log.Errorf("NewAmount failed: %v", err) - sendErrorResponse("failed to create new amount", http.StatusInternalServerError, c) - return - } - - minFee := txrules.StakePoolTicketFee(sDiff, relayFee, int32(ticket.BlockHeight), cfg.VSPFee, cfg.NetParams) - if feeAmount < minFee { - log.Errorf("Fee too small: was %v, expected %v", feeAmount, minFee) - sendErrorResponse("fee too small", http.StatusInternalServerError, c) - return - } - - // Get vote tx to give to wallet - ticketHash, err := chainhash.NewHashFromStr(ticket.Hash) - if err != nil { - log.Errorf("NewHashFromStr failed: %v", err) - sendErrorResponse("failed to create hash", http.StatusInternalServerError, c) - return - } - fWalletConn, err := feeWalletConnect() if err != nil { log.Errorf("Fee wallet connection error: %v", err) @@ -146,9 +134,45 @@ findAddress: return } - rawTicket, err := fWalletClient.GetRawTransaction(ticketHash.String()) + relayFee, err := fWalletClient.GetWalletFee() if err != nil { - log.Warnf("Could not retrieve tx %s for %s: %v", ticketHash.String(), c.ClientIP(), err) + log.Errorf("GetWalletFee failed: %v", err) + sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) + return + } + + minFee := txrules.StakePoolTicketFee(sDiff, relayFee, int32(ticket.BlockHeight), cfg.VSPFee, cfg.NetParams) + if feeAmount < minFee { + log.Errorf("Fee too small: was %v, expected %v", feeAmount, minFee) + sendErrorResponse("fee too small", http.StatusInternalServerError, c) + return + } + + // At this point we are satisfied that the request is valid and the FeeTx + // pays sufficient fees to the expected address. + // Proceed to update the database and broadcast the transaction. + + err = db.SetTicketVotingKey(ticket.Hash, votingWIF.String(), voteChoices) + if err != nil { + log.Errorf("SetTicketVotingKey failed: %v", err) + sendErrorResponse("database error", http.StatusInternalServerError, c) + return + } + + sendTxHash, err := fWalletClient.SendRawTransaction(hex.EncodeToString(feeTxBuf.Bytes())) + if err != nil { + log.Errorf("SendRawTransaction failed: %v", err) + sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) + return + } + + // TODO: Should return a response here. We don't want to add the ticket to + // the voting wallets until the fee tx has been confirmed. + + // Add ticket to voting wallets. + rawTicket, err := fWalletClient.GetRawTransaction(ticket.Hash) + if err != nil { + log.Warnf("Could not retrieve tx %s for %s: %v", ticket.Hash, c.ClientIP(), err) sendErrorResponse("unknown transaction", http.StatusBadRequest, c) return } @@ -190,29 +214,6 @@ findAddress: } } - feeTxBuf := new(bytes.Buffer) - feeTxBuf.Grow(feeTx.SerializeSize()) - err = feeTx.Serialize(feeTxBuf) - if err != nil { - log.Errorf("Serialize tx failed: %v", err) - sendErrorResponse("serialize tx error", http.StatusInternalServerError, c) - return - } - - sendTxHash, err := fWalletClient.SendRawTransaction(hex.EncodeToString(feeTxBuf.Bytes())) - if err != nil { - log.Errorf("SendRawTransaction failed: %v", err) - sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) - return - } - - err = db.InsertFeeAddressVotingKey(voteAddr.Address(), votingWIF.String(), voteChoices) - if err != nil { - log.Errorf("InsertFeeAddressVotingKey failed: %v", err) - sendErrorResponse("database error", http.StatusInternalServerError, c) - return - } - sendJSONResponse(payFeeResponse{ Timestamp: time.Now().Unix(), TxHash: sendTxHash, diff --git a/webapi/setvotechoices.go b/webapi/setvotechoices.go index 360098d..43f6d88 100644 --- a/webapi/setvotechoices.go +++ b/webapi/setvotechoices.go @@ -12,7 +12,7 @@ import ( "github.com/jholdstock/dcrvsp/rpc" ) -// setVoteChoices is the handler for "POST /setvotechoices" +// setVoteChoices is the handler for "POST /setvotechoices". func setVoteChoices(c *gin.Context) { var setVoteChoicesRequest SetVoteChoicesRequest if err := c.ShouldBindJSON(&setVoteChoicesRequest); err != nil { @@ -21,7 +21,7 @@ func setVoteChoices(c *gin.Context) { return } - // ticketHash + // Validate TicketHash. ticketHashStr := setVoteChoicesRequest.TicketHash txHash, err := chainhash.NewHashFromStr(ticketHashStr) if err != nil { @@ -30,7 +30,7 @@ func setVoteChoices(c *gin.Context) { return } - // signature - sanity check signature is in base64 encoding + // Validate Signature - sanity check signature is in base64 encoding. signature := setVoteChoicesRequest.Signature if _, err = base64.StdEncoding.DecodeString(signature); err != nil { log.Warnf("Invalid signature from %s: %v", c.ClientIP(), err) diff --git a/webapi/status.go b/webapi/status.go index a35cc4e..84071fe 100644 --- a/webapi/status.go +++ b/webapi/status.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" ) -// pubKey is the handler for "GET /pubkey" +// pubKey is the handler for "GET /pubkey". func pubKey(c *gin.Context) { sendJSONResponse(pubKeyResponse{ Timestamp: time.Now().Unix(), @@ -14,7 +14,7 @@ func pubKey(c *gin.Context) { }, c) } -// fee is the handler for "GET /fee" +// fee is the handler for "GET /fee". func fee(c *gin.Context) { sendJSONResponse(feeResponse{ Timestamp: time.Now().Unix(), diff --git a/webapi/ticketstatus.go b/webapi/ticketstatus.go index d059d74..07fcf74 100644 --- a/webapi/ticketstatus.go +++ b/webapi/ticketstatus.go @@ -11,7 +11,7 @@ import ( "github.com/gin-gonic/gin" ) -// ticketStatus is the handler for "GET /ticketstatus" +// ticketStatus is the handler for "GET /ticketstatus". func ticketStatus(c *gin.Context) { var ticketStatusRequest TicketStatusRequest if err := c.ShouldBindJSON(&ticketStatusRequest); err != nil { @@ -20,7 +20,7 @@ func ticketStatus(c *gin.Context) { return } - // ticketHash + // Validate TicketHash. ticketHashStr := ticketStatusRequest.TicketHash _, err := chainhash.NewHashFromStr(ticketHashStr) if err != nil { @@ -29,7 +29,7 @@ func ticketStatus(c *gin.Context) { return } - // signature - sanity check signature is in base64 encoding + // Validate Signature - sanity check signature is in base64 encoding. signature := ticketStatusRequest.Signature if _, err = base64.StdEncoding.DecodeString(signature); err != nil { log.Warnf("Invalid signature from %s: %v", c.ClientIP(), err)