Generate fee addresses in dcrvsp, not dcrwallet (#59)

* Remove unnecessary error handling.

* Generate fee addresses in dcrvsp, not dcrwallet

* Break loop if multiple invalid children are generated.

* Use Mutex instead of RWMutex
This commit is contained in:
Jamie Holdstock 2020-05-26 14:51:05 +01:00 committed by GitHub
parent ac488464c0
commit 9151f4f221
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 183 additions and 100 deletions

View File

@ -49,9 +49,9 @@ ticket details + fee to a VSP, and the VSP will take the fee and vote in return.
## Architecture ## Architecture
- Single server running dcrvsp, dcrwallet and dcrd. dcrd requires txindex so - Single server running dcrvsp and dcrd. dcrd requires txindex so
`getrawtransaction` can be used. `getrawtransaction` can be used.
- Multiple remote "Voting servers", each running dcrwallet and dcrd. dcrwallet - Multiple remote voting servers, each running dcrwallet and dcrd. dcrwallet
on these servers should be constantly unlocked and have voting enabled. on these servers should be constantly unlocked and have voting enabled.
## MVP Features ## MVP Features
@ -63,9 +63,8 @@ ticket details + fee to a VSP, and the VSP will take the fee and vote in return.
- Every client request which references a ticket should include a HTTP header - Every client request which references a ticket should include a HTTP header
`VSP-Client-Signature`. The value of this header must be a signature of the `VSP-Client-Signature`. The value of this header must be a signature of the
request body, signed with the commitment address of the referenced ticket. request body, signed with the commitment address of the referenced ticket.
- An xpub key is provided to dcrvsp via config. The first time dcrvsp starts, it - An xpub key is provided to dcrvsp via config. dcrvsp will use this key to
imports this xpub to create a new wallet account. This account is used to derive addresses for fee payments. A new address is generated for each fee.
derive addresses for fee payments.
- VSP API as described in [dcrstakepool #574](https://github.com/decred/dcrstakepool/issues/574) - VSP API as described in [dcrstakepool #574](https://github.com/decred/dcrstakepool/issues/574)
- Request fee amount (`GET /fee`) - Request fee amount (`GET /fee`)
- Request fee address (`POST /feeaddress`) - Request fee address (`POST /feeaddress`)
@ -82,14 +81,11 @@ ticket details + fee to a VSP, and the VSP will take the fee and vote in return.
- Write database backups to disk periodically. - Write database backups to disk periodically.
- Backup over http. - Backup over http.
- Status check API call as described in [dcrstakepool #628](https://github.com/decred/dcrstakepool/issues/628). - Status check API call as described in [dcrstakepool #628](https://github.com/decred/dcrstakepool/issues/628).
- Accountability for both client and server changes to voting preferences.
- Consistency checking across connected wallets. - Consistency checking across connected wallets.
## Backup and Recovery ## Backup
- Regular backups of bbolt database. - Regular backups of bbolt database and feexpub.
- Restore requires manual repair of fee wallet. Import xpub into account "fees",
and rescan with a very large gap limit.
## Issue Tracker ## Issue Tracker

39
database/addressindex.go Normal file
View File

@ -0,0 +1,39 @@
package database
import (
"encoding/binary"
bolt "go.etcd.io/bbolt"
)
func (vdb *VspDatabase) GetLastAddressIndex() (uint32, error) {
var idx uint32
err := vdb.db.View(func(tx *bolt.Tx) error {
vspBkt := tx.Bucket(vspBktK)
idxBytes := vspBkt.Get(lastAddressIndexK)
if idxBytes == nil {
return nil
}
idx = binary.LittleEndian.Uint32(idxBytes)
return nil
})
return idx, err
}
func (vdb *VspDatabase) SetLastAddressIndex(idx uint32) error {
err := vdb.db.Update(func(tx *bolt.Tx) error {
vspBkt := tx.Bucket(vspBktK)
idxBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(idxBytes, idx)
return vspBkt.Put(lastAddressIndexK, idxBytes)
})
return err
}

View File

@ -27,8 +27,10 @@ var (
ticketBktK = []byte("ticketbkt") ticketBktK = []byte("ticketbkt")
// version is the current database version. // version is the current database version.
versionK = []byte("version") versionK = []byte("version")
// privateKeyK is the private key. // privatekey is the private key.
privateKeyK = []byte("privatekey") privateKeyK = []byte("privatekey")
// lastaddressindex is the index of the last address used for fees.
lastAddressIndexK = []byte("lastaddressindex")
) )
// Open initializes and returns an open database. If no database file is found // Open initializes and returns an open database. If no database file is found

View File

@ -17,6 +17,7 @@ func exampleTicket() Ticket {
return Ticket{ return Ticket{
Hash: "Hash", Hash: "Hash",
CommitmentAddress: "Address", CommitmentAddress: "Address",
FeeAddressIndex: 12345,
FeeAddress: "FeeAddress", FeeAddress: "FeeAddress",
SDiff: 1, SDiff: 1,
BlockHeight: 2, BlockHeight: 2,
@ -105,6 +106,7 @@ func testGetTicketByHash(t *testing.T) {
// Check ticket fields match expected. // Check ticket fields match expected.
if retrieved.Hash != ticket.Hash || if retrieved.Hash != ticket.Hash ||
retrieved.CommitmentAddress != ticket.CommitmentAddress || retrieved.CommitmentAddress != ticket.CommitmentAddress ||
retrieved.FeeAddressIndex != ticket.FeeAddressIndex ||
retrieved.FeeAddress != ticket.FeeAddress || retrieved.FeeAddress != ticket.FeeAddress ||
retrieved.SDiff != ticket.SDiff || retrieved.SDiff != ticket.SDiff ||
retrieved.BlockHeight != ticket.BlockHeight || retrieved.BlockHeight != ticket.BlockHeight ||

View File

@ -12,6 +12,7 @@ import (
type Ticket struct { type Ticket struct {
Hash string `json:"hash"` Hash string `json:"hash"`
CommitmentAddress string `json:"commitmentaddress"` CommitmentAddress string `json:"commitmentaddress"`
FeeAddressIndex uint32 `json:"feeaddressindex"`
FeeAddress string `json:"feeaddress"` FeeAddress string `json:"feeaddress"`
SDiff float64 `json:"sdiff"` SDiff float64 `json:"sdiff"`
BlockHeight int64 `json:"blockheight"` BlockHeight int64 `json:"blockheight"`
@ -179,9 +180,5 @@ func (vdb *VspDatabase) CountTickets() (int, int, error) {
}) })
}) })
if err != nil { return total, feePaid, err
return 0, 0, err
}
return total, feePaid, nil
} }

55
main.go
View File

@ -14,7 +14,6 @@ import (
) )
const ( const (
feeAccountName = "fees"
defaultFeeAddressExpiration = 24 * time.Hour defaultFeeAddressExpiration = 24 * time.Hour
) )
@ -57,8 +56,9 @@ func run(ctx context.Context) error {
return err return err
} }
// Create RPC client for local dcrwallet instance (used for generating fee // Create RPC client for local dcrwallet instance (used for broadcasting fee transactions).
// addresses and broadcasting fee transactions). // Dial once just to validate config.
// TODO: Replace with dcrd.
feeWalletConnect := rpc.Setup(ctx, &shutdownWg, cfg.FeeWalletUser, cfg.FeeWalletPass, cfg.FeeWalletHost, cfg.feeWalletCert) feeWalletConnect := rpc.Setup(ctx, &shutdownWg, cfg.FeeWalletUser, cfg.FeeWalletPass, cfg.FeeWalletHost, cfg.feeWalletCert)
feeWalletConn, err := feeWalletConnect() feeWalletConn, err := feeWalletConnect()
if err != nil { if err != nil {
@ -67,7 +67,7 @@ func run(ctx context.Context) error {
shutdownWg.Wait() shutdownWg.Wait()
return err return err
} }
feeWalletClient, err := rpc.FeeWalletClient(ctx, feeWalletConn) _, err = rpc.FeeWalletClient(ctx, feeWalletConn)
if err != nil { if err != nil {
log.Errorf("Fee wallet client error: %v", err) log.Errorf("Fee wallet client error: %v", err)
requestShutdown() requestShutdown()
@ -76,6 +76,7 @@ func run(ctx context.Context) error {
} }
// Create RPC client for remote dcrwallet instance (used for voting). // Create RPC client for remote dcrwallet instance (used for voting).
// Dial once just to validate config.
votingWalletConnect := rpc.Setup(ctx, &shutdownWg, cfg.VotingWalletUser, cfg.VotingWalletPass, cfg.VotingWalletHost, cfg.votingWalletCert) votingWalletConnect := rpc.Setup(ctx, &shutdownWg, cfg.VotingWalletUser, cfg.VotingWalletPass, cfg.VotingWalletHost, cfg.votingWalletCert)
votingWalletConn, err := votingWalletConnect() votingWalletConn, err := votingWalletConnect()
if err != nil { if err != nil {
@ -92,6 +93,7 @@ func run(ctx context.Context) error {
return err return err
} }
// TODO: This can move into webapi.Start()
signKey, pubKey, err := db.KeyPair() signKey, pubKey, err := db.KeyPair()
if err != nil { if err != nil {
log.Errorf("Failed to get keypair: %v", err) log.Errorf("Failed to get keypair: %v", err)
@ -100,25 +102,16 @@ func run(ctx context.Context) error {
return err return err
} }
// Ensure the wallet account for collecting fees exists and matches config.
err = setupFeeAccount(feeWalletClient, cfg.FeeXPub)
if err != nil {
log.Errorf("Fee account error: %v", err)
requestShutdown()
shutdownWg.Wait()
return err
}
// Create and start webapi server. // Create and start webapi server.
apiCfg := webapi.Config{ apiCfg := webapi.Config{
SignKey: signKey, SignKey: signKey,
PubKey: pubKey, PubKey: pubKey,
VSPFee: cfg.VSPFee, VSPFee: cfg.VSPFee,
NetParams: cfg.netParams.Params, NetParams: cfg.netParams.Params,
FeeAccountName: feeAccountName,
FeeAddressExpiration: defaultFeeAddressExpiration, FeeAddressExpiration: defaultFeeAddressExpiration,
} }
err = webapi.Start(ctx, shutdownRequestChannel, &shutdownWg, cfg.Listen, db, feeWalletConnect, votingWalletConnect, cfg.WebServerDebug, apiCfg) err = webapi.Start(ctx, shutdownRequestChannel, &shutdownWg, cfg.Listen, db,
feeWalletConnect, votingWalletConnect, cfg.WebServerDebug, cfg.FeeXPub, apiCfg)
if err != nil { if err != nil {
log.Errorf("Failed to initialize webapi: %v", err) log.Errorf("Failed to initialize webapi: %v", err)
requestShutdown() requestShutdown()
@ -131,35 +124,3 @@ func run(ctx context.Context) error {
return ctx.Err() return ctx.Err()
} }
func setupFeeAccount(walletClient *rpc.FeeWalletRPC, feeXpub string) error {
// Check if account for fee collection already exists.
accounts, err := walletClient.ListAccounts()
if err != nil {
return fmt.Errorf("ListAccounts error: %v", err)
}
if _, ok := accounts[feeAccountName]; ok {
// Account already exists. Check xpub matches xpub from config.
existingXPub, err := walletClient.GetMasterPubKey(feeAccountName)
if err != nil {
return fmt.Errorf("GetMasterPubKey error: %v", err)
}
if existingXPub != feeXpub {
return fmt.Errorf("existing account xpub differs from config: %s != %s", existingXPub, feeXpub)
}
log.Debugf("Using existing wallet account %q to collect fees", feeAccountName)
} else {
// Account does not exist. Create it using xpub from config.
if err = walletClient.ImportXPub(feeAccountName, feeXpub); err != nil {
log.Errorf("ImportXPub error: %v", err)
return err
}
log.Debugf("Created new wallet account %q to collect fees", feeAccountName)
}
return nil
}

View File

@ -57,37 +57,6 @@ func FeeWalletClient(ctx context.Context, c Caller) (*FeeWalletRPC, error) {
return &FeeWalletRPC{c, ctx}, nil return &FeeWalletRPC{c, ctx}, nil
} }
func (c *FeeWalletRPC) ImportXPub(account, xpub string) error {
return c.Call(c.ctx, "importxpub", nil, account, xpub)
}
func (c *FeeWalletRPC) GetMasterPubKey(account string) (string, error) {
var pubKey string
err := c.Call(c.ctx, "getmasterpubkey", &pubKey, account)
if err != nil {
return "", err
}
return pubKey, nil
}
func (c *FeeWalletRPC) ListAccounts() (map[string]float64, error) {
var accounts map[string]float64
err := c.Call(c.ctx, "listaccounts", &accounts)
if err != nil {
return nil, err
}
return accounts, nil
}
func (c *FeeWalletRPC) GetNewAddress(account string) (string, error) {
var newAddress string
err := c.Call(c.ctx, "getnewaddress", &newAddress, account)
if err != nil {
return "", err
}
return newAddress, nil
}
func (c *FeeWalletRPC) GetBlockHeader(blockHash string) (*dcrdtypes.GetBlockHeaderVerboseResult, error) { func (c *FeeWalletRPC) GetBlockHeader(blockHash string) (*dcrdtypes.GetBlockHeaderVerboseResult, error) {
verbose := true verbose := true
var blockHeader dcrdtypes.GetBlockHeaderVerboseResult var blockHeader dcrdtypes.GetBlockHeaderVerboseResult

View File

@ -0,0 +1,79 @@
package webapi
import (
"errors"
"github.com/decred/dcrd/chaincfg/v3"
"github.com/decred/dcrd/dcrec"
"github.com/decred/dcrd/dcrutil/v3"
"github.com/decred/dcrd/hdkeychain/v3"
)
type addressGenerator struct {
external *hdkeychain.ExtendedKey
netParams *chaincfg.Params
lastUsedIndex uint32
}
func newAddressGenerator(xPub string, netParams *chaincfg.Params, lastUsedIdx uint32) (*addressGenerator, error) {
xPubKey, err := hdkeychain.NewKeyFromString(xPub, netParams)
if err != nil {
return nil, err
}
if xPubKey.IsPrivate() {
return nil, errors.New("not a public key")
}
// Derive the extended key for the external chain.
external, err := xPubKey.Child(0)
if err != nil {
return nil, err
}
return &addressGenerator{
external: external,
netParams: netParams,
lastUsedIndex: lastUsedIdx,
}, nil
}
// NextAddress increments the last used address counter and returns a new
// address. It will skip any address index which causes an ErrInvalidChild.
// Not safe for concurrent access.
func (m *addressGenerator) NextAddress() (string, uint32, error) {
var key *hdkeychain.ExtendedKey
var err error
// There is a small chance that generating addresses for a given index can
// fail with ErrInvalidChild, so loop until we find an index which works.
// See the hdkeychain.ExtendedKey.Child docs for more info.
invalidChildren := 0
for {
m.lastUsedIndex++
key, err = m.external.Child(m.lastUsedIndex)
if err != nil {
if err == hdkeychain.ErrInvalidChild {
invalidChildren++
log.Warnf("Generating address for index %d failed: %v", m.lastUsedIndex, err)
// If this happens 3 times, something is seriously wrong, so
// return an error.
if invalidChildren > 2 {
return "", 0, errors.New("multiple invalid children generated for key")
}
continue
}
return "", 0, err
}
break
}
// Convert to a standard pay-to-pubkey-hash address.
pkHash := dcrutil.Hash160(key.SerializedPubKey())
addr, err := dcrutil.NewAddressPubKeyHash(pkHash, m.netParams, dcrec.STEcdsaSecp256k1)
if err != nil {
return "", 0, err
}
return addr.String(), m.lastUsedIndex, nil
}

View File

@ -3,6 +3,7 @@ package webapi
import ( import (
"encoding/hex" "encoding/hex"
"net/http" "net/http"
"sync"
"time" "time"
"github.com/decred/dcrd/blockchain/stake/v3" "github.com/decred/dcrd/blockchain/stake/v3"
@ -13,6 +14,30 @@ import (
"github.com/jholdstock/dcrvsp/rpc" "github.com/jholdstock/dcrvsp/rpc"
) )
// addrMtx protects getNewFeeAddress.
var addrMtx sync.Mutex
// getNewFeeAddress gets a new address from the address generator and stores the
// new address index in the database. In order to maintain consistency between
// the internal counter of address generator and the database, this function
// cannot be run concurrently.
func getNewFeeAddress(db *database.VspDatabase, addrGen *addressGenerator) (string, uint32, error) {
addrMtx.Lock()
defer addrMtx.Unlock()
addr, idx, err := addrGen.NextAddress()
if err != nil {
return "", 0, err
}
err = db.SetLastAddressIndex(idx)
if err != nil {
return "", 0, err
}
return addr, idx, 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) {
@ -115,12 +140,9 @@ func feeAddress(c *gin.Context) {
return return
} }
// TODO: Generate this within dcrvsp without an RPC call? newAddress, newAddressIdx, err := getNewFeeAddress(db, addrGen)
newAddress, err := fWalletClient.GetNewAddress(cfg.FeeAccountName)
if err != nil { if err != nil {
log.Errorf("GetNewAddress error: %v", err) log.Errorf("getNewFeeAddress error: %v", err)
sendErrorResponse("unable to generate fee address", http.StatusInternalServerError, c)
return
} }
now := time.Now() now := time.Now()
@ -129,6 +151,7 @@ func feeAddress(c *gin.Context) {
dbTicket := database.Ticket{ dbTicket := database.Ticket{
Hash: ticketHash, Hash: ticketHash,
CommitmentAddress: commitmentAddress, CommitmentAddress: commitmentAddress,
FeeAddressIndex: newAddressIdx,
FeeAddress: newAddress, FeeAddress: newAddress,
SDiff: blockHeader.SBits, SDiff: blockHeader.SBits,
BlockHeight: int64(blockHeader.Height), BlockHeight: int64(blockHeader.Height),
@ -144,6 +167,9 @@ func feeAddress(c *gin.Context) {
return return
} }
log.Debugf("Fee address created for new ticket: feeAddrIdx=%d, "+
"feeAddr=%s, ticketHash=%s", newAddressIdx, newAddress, ticketHash)
sendJSONResponse(feeAddressResponse{ sendJSONResponse(feeAddressResponse{
Timestamp: now.Unix(), Timestamp: now.Unix(),
Request: feeAddressRequest, Request: feeAddressRequest,

View File

@ -33,9 +33,10 @@ var cfg Config
var db *database.VspDatabase var db *database.VspDatabase
var feeWalletConnect rpc.Connect var feeWalletConnect rpc.Connect
var votingWalletConnect rpc.Connect var votingWalletConnect rpc.Connect
var addrGen *addressGenerator
func Start(ctx context.Context, requestShutdownChan chan struct{}, shutdownWg *sync.WaitGroup, func Start(ctx context.Context, requestShutdownChan chan struct{}, shutdownWg *sync.WaitGroup,
listen string, vdb *database.VspDatabase, fWalletConnect rpc.Connect, vWalletConnect rpc.Connect, debugMode bool, config Config) error { listen string, vdb *database.VspDatabase, fWalletConnect rpc.Connect, vWalletConnect rpc.Connect, debugMode bool, feeXPub string, config Config) error {
// Populate template data before starting webserver. // Populate template data before starting webserver.
var err error var err error
@ -44,6 +45,17 @@ func Start(ctx context.Context, requestShutdownChan chan struct{}, shutdownWg *s
return fmt.Errorf("could not initialize homepage data: %v", err) return fmt.Errorf("could not initialize homepage data: %v", err)
} }
// Get the last used address index from the database, and use it to
// initialize the address generator.
idx, err := vdb.GetLastAddressIndex()
if err != nil {
return fmt.Errorf("GetLastAddressIndex error: %v", err)
}
addrGen, err = newAddressGenerator(feeXPub, config.NetParams, idx)
if err != nil {
return fmt.Errorf("failed to initialize fee address generator: %v", err)
}
// Create TCP listener. // Create TCP listener.
var listenConfig net.ListenConfig var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(ctx, "tcp", listen) listener, err := listenConfig.Listen(ctx, "tcp", listen)