vspd/client/autoclient.go
2023-05-30 11:50:23 +01:00

448 lines
14 KiB
Go

// Copyright (c) 2022-2023 The Decred developers
// Use of this source code is governed by an ISC
// license that can be found in the LICENSE file.
package client
import (
"context"
"encoding/base64"
"fmt"
"net"
"net/http"
"net/url"
"sync"
"decred.org/dcrwallet/v3/errors"
"decred.org/dcrwallet/v3/wallet"
"decred.org/dcrwallet/v3/wallet/udb"
"github.com/decred/dcrd/chaincfg/chainhash"
"github.com/decred/dcrd/chaincfg/v3"
"github.com/decred/dcrd/dcrutil/v4"
"github.com/decred/dcrd/txscript/v4"
"github.com/decred/dcrd/txscript/v4/stdaddr"
"github.com/decred/dcrd/wire"
"github.com/decred/slog"
)
type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
type Policy struct {
MaxFee dcrutil.Amount
ChangeAcct uint32 // to derive fee addresses
FeeAcct uint32 // to pay fees from, if inputs are not provided to Process
}
// Ensure dcrwallet satisfies the Wallet interface.
var _ Wallet = (*wallet.Wallet)(nil)
type Wallet interface {
Spender(ctx context.Context, out *wire.OutPoint) (*wire.MsgTx, uint32, error)
MainChainTip(ctx context.Context) (hash chainhash.Hash, height int32)
ChainParams() *chaincfg.Params
TxBlock(ctx context.Context, hash *chainhash.Hash) (chainhash.Hash, int32, error)
DumpWIFPrivateKey(ctx context.Context, addr stdaddr.Address) (string, error)
VSPFeeHashForTicket(ctx context.Context, ticketHash *chainhash.Hash) (chainhash.Hash, error)
UpdateVspTicketFeeToStarted(ctx context.Context, ticketHash, feeHash *chainhash.Hash, host string, pubkey []byte) error
GetTransactionsByHashes(ctx context.Context, txHashes []*chainhash.Hash) (txs []*wire.MsgTx, notFound []*wire.InvVect, err error)
ReserveOutputsForAmount(ctx context.Context, account uint32, amount dcrutil.Amount, minconf int32) ([]wallet.Input, error)
UnlockOutpoint(txHash *chainhash.Hash, index uint32)
NewChangeAddress(ctx context.Context, account uint32) (stdaddr.Address, error)
RelayFee() dcrutil.Amount
SignTransaction(ctx context.Context, tx *wire.MsgTx, hashType txscript.SigHashType, additionalPrevScripts map[wire.OutPoint][]byte,
additionalKeysByAddress map[string]*dcrutil.WIF, p2shRedeemScriptsByAddress map[string][]byte) ([]wallet.SignatureError, error)
SetPublished(ctx context.Context, hash *chainhash.Hash, published bool) error
AddTransaction(ctx context.Context, tx *wire.MsgTx, blockHash *chainhash.Hash) error
UpdateVspTicketFeeToPaid(ctx context.Context, ticketHash, feeHash *chainhash.Hash, host string, pubkey []byte) error
UpdateVspTicketFeeToErrored(ctx context.Context, ticketHash *chainhash.Hash, host string, pubkey []byte) error
AgendaChoices(ctx context.Context, ticketHash *chainhash.Hash) (choices wallet.AgendaChoices, voteBits uint16, err error)
TSpendPolicyForTicket(ticketHash *chainhash.Hash) map[string]string
TreasuryKeyPolicyForTicket(ticketHash *chainhash.Hash) map[string]string
AbandonTransaction(ctx context.Context, hash *chainhash.Hash) error
TxConfirms(ctx context.Context, hash *chainhash.Hash) (int32, error)
ForUnspentUnexpiredTickets(ctx context.Context, f func(hash *chainhash.Hash) error) error
IsVSPTicketConfirmed(ctx context.Context, ticketHash *chainhash.Hash) (bool, error)
UpdateVspTicketFeeToConfirmed(ctx context.Context, ticketHash, feeHash *chainhash.Hash, host string, pubkey []byte) error
VSPTicketInfo(ctx context.Context, ticketHash *chainhash.Hash) (*wallet.VSPTicket, error)
SignMessage(ctx context.Context, msg string, addr stdaddr.Address) (sig []byte, err error)
}
type AutoClient struct {
wallet Wallet
policy *Policy
*Client
mu sync.Mutex
jobs map[chainhash.Hash]*feePayment
log slog.Logger
}
type Config struct {
// URL specifies the base URL of the VSP
URL string
// PubKey specifies the VSP's base64 encoded public key
PubKey string
// Dialer specifies an optional dialer when connecting to the VSP.
Dialer DialFunc
// Wallet specifies a loaded wallet.
Wallet Wallet
// Default policy for fee payments unless another is provided by the
// caller.
Policy *Policy
}
func New(cfg Config, log slog.Logger) (*AutoClient, error) {
u, err := url.Parse(cfg.URL)
if err != nil {
return nil, err
}
pubKey, err := base64.StdEncoding.DecodeString(cfg.PubKey)
if err != nil {
return nil, err
}
if cfg.Wallet == nil {
return nil, fmt.Errorf("wallet option not set")
}
client := &Client{
URL: u.String(),
PubKey: pubKey,
Sign: cfg.Wallet.SignMessage,
Log: log,
}
client.Transport = &http.Transport{
DialContext: cfg.Dialer,
}
v := &AutoClient{
wallet: cfg.Wallet,
policy: cfg.Policy,
Client: client,
jobs: make(map[chainhash.Hash]*feePayment),
log: log,
}
return v, nil
}
func (c *AutoClient) FeePercentage(ctx context.Context) (float64, error) {
resp, err := c.Client.VspInfo(ctx)
if err != nil {
return -1, err
}
return resp.FeePercentage, nil
}
// ProcessUnprocessedTickets processes all tickets that don't currently have
// any association with a VSP.
func (c *AutoClient) ProcessUnprocessedTickets(ctx context.Context) {
var wg sync.WaitGroup
_ = c.wallet.ForUnspentUnexpiredTickets(ctx, func(hash *chainhash.Hash) error {
// Skip tickets which have a fee tx already associated with
// them; they are already processed by some vsp.
_, err := c.wallet.VSPFeeHashForTicket(ctx, hash)
if err == nil {
return nil
}
confirmed, err := c.wallet.IsVSPTicketConfirmed(ctx, hash)
if err != nil && !errors.Is(err, errors.NotExist) {
c.log.Error(err)
return nil
}
if confirmed {
return nil
}
c.mu.Lock()
fp := c.jobs[*hash]
c.mu.Unlock()
if fp != nil {
// Already processing this ticket with the VSP.
return nil
}
// Start processing in the background.
wg.Add(1)
go func() {
defer wg.Done()
err := c.Process(ctx, hash, nil)
if err != nil {
c.log.Error(err)
}
}()
return nil
})
wg.Wait()
}
// ProcessTicket attempts to process a given ticket based on the hash provided.
func (c *AutoClient) ProcessTicket(ctx context.Context, hash *chainhash.Hash) error {
err := c.Process(ctx, hash, nil)
if err != nil {
return err
}
return nil
}
// ProcessManagedTickets discovers tickets which were previously registered with
// a VSP and begins syncing them in the background. This is used to recover VSP
// tracking after seed restores, and is only performed on unspent and unexpired
// tickets.
func (c *AutoClient) ProcessManagedTickets(ctx context.Context) error {
err := c.wallet.ForUnspentUnexpiredTickets(ctx, func(hash *chainhash.Hash) error {
// We only want to process tickets that haven't been confirmed yet.
confirmed, err := c.wallet.IsVSPTicketConfirmed(ctx, hash)
if err != nil && !errors.Is(err, errors.NotExist) {
c.log.Error(err)
return nil
}
if confirmed {
return nil
}
c.mu.Lock()
_, ok := c.jobs[*hash]
c.mu.Unlock()
if ok {
// Already processing this ticket with the VSP.
return nil
}
// Make ticketstatus api call and only continue if ticket is
// found managed by this vsp. The rest is the same codepath as
// for processing a new ticket.
status, err := c.status(ctx, hash)
if err != nil {
if errors.Is(err, errors.Locked) {
return err
}
return nil
}
if status.FeeTxStatus == "confirmed" {
feeHash, err := chainhash.NewHashFromStr(status.FeeTxHash)
if err != nil {
return err
}
err = c.wallet.UpdateVspTicketFeeToConfirmed(ctx, hash, feeHash, c.Client.URL, c.Client.PubKey)
if err != nil {
return err
}
return nil
} else if status.FeeTxHash != "" {
feeHash, err := chainhash.NewHashFromStr(status.FeeTxHash)
if err != nil {
return err
}
err = c.wallet.UpdateVspTicketFeeToPaid(ctx, hash, feeHash, c.Client.URL, c.Client.PubKey)
if err != nil {
return err
}
_ = c.feePayment(ctx, hash, true)
} else {
// Fee hasn't been paid at the provided VSP, so this should do that if needed.
_ = c.feePayment(ctx, hash, false)
}
return nil
})
return err
}
// Process begins processing a VSP fee payment for a ticket. If feeTx contains
// inputs, is used to pay the VSP fee. Otherwise, new inputs are selected and
// locked to prevent double spending the fee.
//
// feeTx must not be nil, but may point to an empty transaction, and is modified
// with the inputs and the fee and change outputs before returning without an
// error. The fee transaction is also recorded as unpublised in the wallet, and
// the fee hash is associated with the ticket.
func (c *AutoClient) Process(ctx context.Context, ticketHash *chainhash.Hash, feeTx *wire.MsgTx) error {
vspTicket, err := c.wallet.VSPTicketInfo(ctx, ticketHash)
if err != nil && !errors.Is(err, errors.NotExist) {
return err
}
feeStatus := udb.VSPFeeProcessStarted // Will be used if the ticket isn't registered to the vsp yet.
if vspTicket != nil {
feeStatus = udb.FeeStatus(vspTicket.FeeTxStatus)
}
switch feeStatus {
case udb.VSPFeeProcessStarted, udb.VSPFeeProcessErrored:
// If VSPTicket has been started or errored then attempt to create a new fee
// transaction, submit it then confirm.
fp := c.feePayment(ctx, ticketHash, false)
if fp == nil {
err := c.wallet.UpdateVspTicketFeeToErrored(ctx, ticketHash, c.Client.URL, c.Client.PubKey)
if err != nil {
return err
}
return fmt.Errorf("fee payment cannot be processed")
}
fp.mu.Lock()
if fp.feeTx == nil {
fp.feeTx = feeTx
}
fp.mu.Unlock()
err := fp.receiveFeeAddress()
if err != nil {
err := c.wallet.UpdateVspTicketFeeToErrored(ctx, ticketHash, c.Client.URL, c.Client.PubKey)
if err != nil {
return err
}
// XXX, retry? (old Process retried)
// but this may not be necessary any longer as the parent of
// the ticket is always relayed to the vsp as well.
return err
}
err = fp.makeFeeTx(feeTx)
if err != nil {
err := c.wallet.UpdateVspTicketFeeToErrored(ctx, ticketHash, c.Client.URL, c.Client.PubKey)
if err != nil {
return err
}
return err
}
return fp.submitPayment()
case udb.VSPFeeProcessPaid:
// If a VSP ticket has been paid, but confirm payment.
if len(vspTicket.Host) > 0 && vspTicket.Host != c.Client.URL {
// Cannot confirm a paid ticket that is already with another VSP.
return fmt.Errorf("ticket already paid or confirmed with another vsp")
}
fp := c.feePayment(ctx, ticketHash, true)
if fp == nil {
// Don't update VSPStatus to Errored if it was already paid or
// confirmed.
return fmt.Errorf("fee payment cannot be processed")
}
return fp.confirmPayment()
case udb.VSPFeeProcessConfirmed:
// VSPTicket has already been confirmed, there is nothing to process.
return nil
}
return nil
}
// SetVoteChoice takes the provided consensus, tspend and treasury key voting
// preferences, and checks if they match the status of the specified ticket from
// the connected VSP. The status provides the current voting preferences so we
// can just update from there if need be.
func (c *AutoClient) SetVoteChoice(ctx context.Context, hash *chainhash.Hash,
choices map[string]string, tspendPolicy map[string]string, treasuryPolicy map[string]string) error {
// Retrieve current voting preferences from VSP.
status, err := c.status(ctx, hash)
if err != nil {
if errors.Is(err, errors.Locked) {
return err
}
c.log.Errorf("Could not check status of VSP ticket %s: %v", hash, err)
return nil
}
// Check for any mismatch between the provided voting preferences and the
// VSP preferences to determine if VSP needs to be updated.
update := false
// Check consensus vote choices.
for newAgenda, newChoice := range choices {
vspChoice, ok := status.VoteChoices[newAgenda]
if !ok {
update = true
break
}
if vspChoice != newChoice {
update = true
break
}
}
// Check tspend policies.
for newTSpend, newChoice := range tspendPolicy {
vspChoice, ok := status.TSpendPolicy[newTSpend]
if !ok {
update = true
break
}
if vspChoice != newChoice {
update = true
break
}
}
// Check treasury policies.
for newKey, newChoice := range treasuryPolicy {
vspChoice, ok := status.TSpendPolicy[newKey]
if !ok {
update = true
break
}
if vspChoice != newChoice {
update = true
break
}
}
if !update {
c.log.Debugf("VSP already has correct vote choices for ticket %s", hash)
return nil
}
c.log.Debugf("Updating vote choices on VSP for ticket %s", hash)
err = c.setVoteChoices(ctx, hash, choices, tspendPolicy, treasuryPolicy)
if err != nil {
return err
}
return nil
}
// TicketInfo stores per-ticket info tracked by a VSP Client instance.
type TicketInfo struct {
TicketHash chainhash.Hash
CommitmentAddr stdaddr.StakeAddress
VotingAddr stdaddr.StakeAddress
State State
Fee dcrutil.Amount
FeeHash chainhash.Hash
// TODO: include stuff returned by the status() call?
}
// TrackedTickets returns information about all outstanding tickets tracked by
// a vsp.Client instance.
//
// Currently this returns only info about tickets which fee hasn't been paid or
// confirmed at enough depth to be considered committed to.
func (c *AutoClient) TrackedTickets() []*TicketInfo {
// Collect all jobs first, to avoid working under two different locks.
c.mu.Lock()
jobs := make([]*feePayment, 0, len(c.jobs))
for _, job := range c.jobs {
jobs = append(jobs, job)
}
c.mu.Unlock()
tickets := make([]*TicketInfo, 0, len(jobs))
for _, job := range jobs {
job.mu.Lock()
tickets = append(tickets, &TicketInfo{
TicketHash: job.ticketHash,
CommitmentAddr: job.commitmentAddr,
VotingAddr: job.votingAddr,
State: job.state,
Fee: job.fee,
FeeHash: job.feeHash,
})
job.mu.Unlock()
}
return tickets
}