448 lines
14 KiB
Go
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
|
|
}
|