938 lines
24 KiB
Go
938 lines
24 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 (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"sync"
|
|
"time"
|
|
|
|
wallet_errs "decred.org/dcrwallet/v3/errors"
|
|
"decred.org/dcrwallet/v3/wallet"
|
|
"decred.org/dcrwallet/v3/wallet/txrules"
|
|
"decred.org/dcrwallet/v3/wallet/txsizes"
|
|
"github.com/decred/dcrd/blockchain/stake/v5"
|
|
"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/txscript/v4/stdscript"
|
|
"github.com/decred/dcrd/wire"
|
|
"github.com/decred/slog"
|
|
"github.com/decred/vspd/types/v2"
|
|
)
|
|
|
|
// randomInt64 returns a random int64 in [0,n).
|
|
func randomInt64(n int64) int64 {
|
|
i, err := rand.Int(rand.Reader, big.NewInt(n))
|
|
if err != nil {
|
|
// crypto/rand should never return an error if running on a supported platform.
|
|
panic(fmt.Sprintf("unhandled crypto/rand error: %v", err))
|
|
}
|
|
return i.Int64()
|
|
}
|
|
|
|
// randomDuration returns a random time.Duration in [0,d).
|
|
func randomDuration(d time.Duration) time.Duration {
|
|
return time.Duration(randomInt64(int64(d)))
|
|
}
|
|
|
|
// coinflip returns a random bool.
|
|
func coinflip() bool {
|
|
return randomInt64(2) == 0
|
|
}
|
|
|
|
var (
|
|
errStopped = errors.New("fee processing stopped")
|
|
errNotSolo = errors.New("not a solo ticket")
|
|
)
|
|
|
|
// A random amount of delay (between zero and these jitter constants) is added
|
|
// before performing some background action with the VSP. The delay is reduced
|
|
// when a ticket is currently live, as it may be called to vote any time.
|
|
const (
|
|
immatureJitter = time.Hour
|
|
liveJitter = 5 * time.Minute
|
|
unminedJitter = 2 * time.Minute
|
|
)
|
|
|
|
type feePayment struct {
|
|
client *AutoClient
|
|
ctx context.Context
|
|
|
|
// Set at feepayment creation and never changes
|
|
ticketHash chainhash.Hash
|
|
commitmentAddr stdaddr.StakeAddress
|
|
votingAddr stdaddr.StakeAddress
|
|
policy *Policy
|
|
|
|
// Requires locking for all access outside of Client.feePayment
|
|
mu sync.Mutex
|
|
votingKey string
|
|
ticketLive int32
|
|
ticketExpires int32
|
|
fee dcrutil.Amount
|
|
feeAddr stdaddr.Address
|
|
feeHash chainhash.Hash
|
|
feeTx *wire.MsgTx
|
|
state State
|
|
err error
|
|
|
|
timerMu sync.Mutex
|
|
timer *time.Timer
|
|
|
|
log slog.Logger
|
|
}
|
|
|
|
type State uint32
|
|
|
|
const (
|
|
_ State = iota
|
|
Unprocessed
|
|
FeePublished
|
|
_ // ...
|
|
TicketSpent
|
|
)
|
|
|
|
func parseTicket(ticket *wire.MsgTx, params *chaincfg.Params) (
|
|
votingAddr, commitmentAddr stdaddr.StakeAddress, err error) {
|
|
fail := func(err error) (_, _ stdaddr.StakeAddress, _ error) {
|
|
return nil, nil, err
|
|
}
|
|
if !stake.IsSStx(ticket) {
|
|
return fail(fmt.Errorf("%v is not a ticket", ticket))
|
|
}
|
|
_, addrs := stdscript.ExtractAddrs(ticket.TxOut[0].Version, ticket.TxOut[0].PkScript, params)
|
|
if len(addrs) != 1 {
|
|
return fail(fmt.Errorf("cannot parse voting addr"))
|
|
}
|
|
switch addr := addrs[0].(type) {
|
|
case stdaddr.StakeAddress:
|
|
votingAddr = addr
|
|
default:
|
|
return fail(fmt.Errorf("address cannot be used for voting rights: %v", err))
|
|
}
|
|
commitmentAddr, err = stake.AddrFromSStxPkScrCommitment(ticket.TxOut[1].PkScript, params)
|
|
if err != nil {
|
|
return fail(fmt.Errorf("cannot parse commitment address: %w", err))
|
|
}
|
|
return
|
|
}
|
|
|
|
func (fp *feePayment) ticketSpent() bool {
|
|
ctx := fp.ctx
|
|
ticketOut := wire.OutPoint{Hash: fp.ticketHash, Index: 0, Tree: 1}
|
|
_, _, err := fp.client.wallet.Spender(ctx, &ticketOut)
|
|
return err == nil
|
|
}
|
|
|
|
func (fp *feePayment) ticketExpired() bool {
|
|
ctx := fp.ctx
|
|
w := fp.client.wallet
|
|
_, tipHeight := w.MainChainTip(ctx)
|
|
|
|
fp.mu.Lock()
|
|
expires := fp.ticketExpires
|
|
fp.mu.Unlock()
|
|
|
|
return expires > 0 && tipHeight >= expires
|
|
}
|
|
|
|
func (fp *feePayment) removedExpiredOrSpent() bool {
|
|
var reason string
|
|
switch {
|
|
case fp.ticketExpired():
|
|
reason = "expired"
|
|
case fp.ticketSpent():
|
|
reason = "spent"
|
|
}
|
|
if reason != "" {
|
|
fp.remove(reason)
|
|
// nothing scheduled
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (fp *feePayment) remove(reason string) {
|
|
fp.stop()
|
|
fp.log.Infof("ticket %v is %s; removing from VSP client", &fp.ticketHash, reason)
|
|
fp.client.mu.Lock()
|
|
delete(fp.client.jobs, fp.ticketHash)
|
|
fp.client.mu.Unlock()
|
|
}
|
|
|
|
// feePayment returns an existing managed fee payment, or creates and begins
|
|
// processing a fee payment for a ticket.
|
|
func (c *AutoClient) feePayment(ctx context.Context, ticketHash *chainhash.Hash, paidConfirmed bool) (fp *feePayment) {
|
|
c.mu.Lock()
|
|
fp = c.jobs[*ticketHash]
|
|
c.mu.Unlock()
|
|
if fp != nil {
|
|
return fp
|
|
}
|
|
|
|
defer func() {
|
|
if fp == nil {
|
|
return
|
|
}
|
|
var schedule bool
|
|
c.mu.Lock()
|
|
fp2 := c.jobs[*ticketHash]
|
|
if fp2 != nil {
|
|
fp.stop()
|
|
fp = fp2
|
|
} else {
|
|
c.jobs[*ticketHash] = fp
|
|
schedule = true
|
|
}
|
|
c.mu.Unlock()
|
|
if schedule {
|
|
fp.schedule("reconcile payment", fp.reconcilePayment)
|
|
}
|
|
}()
|
|
|
|
w := c.wallet
|
|
params := w.ChainParams()
|
|
|
|
fp = &feePayment{
|
|
client: c,
|
|
ctx: ctx,
|
|
ticketHash: *ticketHash,
|
|
policy: c.policy,
|
|
log: c.log,
|
|
}
|
|
|
|
// No VSP interaction is required for spent tickets.
|
|
if fp.ticketSpent() {
|
|
fp.state = TicketSpent
|
|
return fp
|
|
}
|
|
|
|
ticket, err := c.tx(ctx, ticketHash)
|
|
if err != nil {
|
|
fp.log.Warnf("no ticket found for %v", ticketHash)
|
|
return nil
|
|
}
|
|
|
|
_, ticketHeight, err := w.TxBlock(ctx, ticketHash)
|
|
if err != nil {
|
|
// This is not expected to ever error, as the ticket was fetched
|
|
// from the wallet in the above call.
|
|
fp.log.Errorf("failed to query block which mines ticket: %v", err)
|
|
return nil
|
|
}
|
|
if ticketHeight >= 2 {
|
|
// Note the off-by-one; this is correct. Tickets become live
|
|
// one block after the params would indicate.
|
|
fp.ticketLive = ticketHeight + int32(params.TicketMaturity) + 1
|
|
fp.ticketExpires = fp.ticketLive + int32(params.TicketExpiry)
|
|
}
|
|
|
|
fp.votingAddr, fp.commitmentAddr, err = parseTicket(ticket, params)
|
|
if err != nil {
|
|
fp.log.Errorf("%v is not a ticket: %v", ticketHash, err)
|
|
return nil
|
|
}
|
|
// Try to access the voting key, ignore error unless the wallet is
|
|
// locked.
|
|
fp.votingKey, err = w.DumpWIFPrivateKey(ctx, fp.votingAddr)
|
|
if err != nil && !errors.Is(err, wallet_errs.Locked) {
|
|
fp.log.Errorf("no voting key for ticket %v: %v", ticketHash, err)
|
|
return nil
|
|
}
|
|
feeHash, err := w.VSPFeeHashForTicket(ctx, ticketHash)
|
|
if err != nil {
|
|
// caller must schedule next method, as paying the fee may
|
|
// require using provided transaction inputs.
|
|
return fp
|
|
}
|
|
|
|
fee, err := c.tx(ctx, &feeHash)
|
|
if err != nil {
|
|
// A fee hash is recorded for this ticket, but was not found in
|
|
// the wallet. This should not happen and may require manual
|
|
// intervention.
|
|
//
|
|
// XXX should check ticketinfo and see if fee is not paid. if
|
|
// possible, update it with a new fee.
|
|
fp.err = fmt.Errorf("fee transaction not found in wallet: %w", err)
|
|
return fp
|
|
}
|
|
|
|
fp.feeTx = fee
|
|
fp.feeHash = feeHash
|
|
|
|
// If database has been updated to paid or confirmed status, we can forgo
|
|
// this step.
|
|
if !paidConfirmed {
|
|
err = w.UpdateVspTicketFeeToStarted(ctx, ticketHash, &feeHash, c.Client.URL, c.Client.PubKey)
|
|
if err != nil {
|
|
return fp
|
|
}
|
|
|
|
fp.state = Unprocessed // XXX fee created, but perhaps not submitted with vsp.
|
|
fp.fee = -1 // XXX fee amount (not needed anymore?)
|
|
}
|
|
return fp
|
|
}
|
|
|
|
func (c *AutoClient) tx(ctx context.Context, hash *chainhash.Hash) (*wire.MsgTx, error) {
|
|
txs, _, err := c.wallet.GetTransactionsByHashes(ctx, []*chainhash.Hash{hash})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return txs[0], nil
|
|
}
|
|
|
|
// Schedule a method to be executed.
|
|
// Any currently-scheduled method is replaced.
|
|
func (fp *feePayment) schedule(name string, method func() error) {
|
|
var delay time.Duration
|
|
if method != nil {
|
|
delay = fp.next()
|
|
}
|
|
|
|
fp.timerMu.Lock()
|
|
defer fp.timerMu.Unlock()
|
|
if fp.timer != nil {
|
|
fp.timer.Stop()
|
|
fp.timer = nil
|
|
}
|
|
if method != nil {
|
|
fp.log.Debugf("scheduling %q for ticket %s in %v", name, &fp.ticketHash, delay)
|
|
fp.timer = time.AfterFunc(delay, fp.task(name, method))
|
|
}
|
|
}
|
|
|
|
func (fp *feePayment) next() time.Duration {
|
|
w := fp.client.wallet
|
|
params := w.ChainParams()
|
|
_, tipHeight := w.MainChainTip(fp.ctx)
|
|
|
|
fp.mu.Lock()
|
|
ticketLive := fp.ticketLive
|
|
ticketExpires := fp.ticketExpires
|
|
fp.mu.Unlock()
|
|
|
|
var jitter time.Duration
|
|
switch {
|
|
case tipHeight < ticketLive: // immature, mined ticket
|
|
blocksUntilLive := ticketExpires - tipHeight
|
|
jitter = params.TargetTimePerBlock * time.Duration(blocksUntilLive)
|
|
if jitter > immatureJitter {
|
|
jitter = immatureJitter
|
|
}
|
|
case tipHeight < ticketExpires: // live ticket
|
|
jitter = liveJitter
|
|
default: // unmined ticket
|
|
jitter = unminedJitter
|
|
}
|
|
|
|
return randomDuration(jitter)
|
|
}
|
|
|
|
// task returns a function running a feePayment method.
|
|
// If the method errors, the error is logged, and the payment is put
|
|
// in an errored state and may require manual processing.
|
|
func (fp *feePayment) task(name string, method func() error) func() {
|
|
return func() {
|
|
err := method()
|
|
fp.mu.Lock()
|
|
fp.err = err
|
|
fp.mu.Unlock()
|
|
if err != nil {
|
|
fp.log.Errorf("ticket %v: %v: %v", &fp.ticketHash, name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (fp *feePayment) stop() {
|
|
fp.schedule("", nil)
|
|
}
|
|
|
|
func (fp *feePayment) receiveFeeAddress() error {
|
|
ctx := fp.ctx
|
|
w := fp.client.wallet
|
|
params := w.ChainParams()
|
|
|
|
// stop processing if ticket is expired or spent
|
|
if fp.removedExpiredOrSpent() {
|
|
// nothing scheduled
|
|
return errStopped
|
|
}
|
|
|
|
// Fetch ticket and its parent transaction (typically, a split
|
|
// transaction).
|
|
ticket, err := fp.client.tx(ctx, &fp.ticketHash)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to retrieve ticket: %w", err)
|
|
}
|
|
parentHash := &ticket.TxIn[0].PreviousOutPoint.Hash
|
|
parent, err := fp.client.tx(ctx, parentHash)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to retrieve parent %v of ticket: %w",
|
|
parentHash, err)
|
|
}
|
|
|
|
ticketHex, err := marshalTx(ticket)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
parentHex, err := marshalTx(parent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req := types.FeeAddressRequest{
|
|
Timestamp: time.Now().Unix(),
|
|
TicketHash: fp.ticketHash.String(),
|
|
TicketHex: ticketHex,
|
|
ParentHex: parentHex,
|
|
}
|
|
|
|
resp, err := fp.client.FeeAddress(ctx, req, fp.commitmentAddr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
feeAmount := dcrutil.Amount(resp.FeeAmount)
|
|
feeAddr, err := stdaddr.DecodeAddress(resp.FeeAddress, params)
|
|
if err != nil {
|
|
return fmt.Errorf("server fee address invalid: %w", err)
|
|
}
|
|
|
|
fp.log.Infof("VSP requires fee %v", feeAmount)
|
|
if feeAmount > fp.policy.MaxFee {
|
|
return fmt.Errorf("server fee amount too high: %v > %v",
|
|
feeAmount, fp.policy.MaxFee)
|
|
}
|
|
|
|
// XXX validate server timestamp?
|
|
|
|
fp.mu.Lock()
|
|
fp.fee = feeAmount
|
|
fp.feeAddr = feeAddr
|
|
fp.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// makeFeeTx adds outputs to tx to pay a VSP fee, optionally adding inputs as
|
|
// well to fund the transaction if no input value is already provided in the
|
|
// transaction.
|
|
//
|
|
// If tx is nil, fp.feeTx may be assigned or modified, but the pointer will not
|
|
// be dereferenced.
|
|
func (fp *feePayment) makeFeeTx(tx *wire.MsgTx) error {
|
|
ctx := fp.ctx
|
|
w := fp.client.wallet
|
|
|
|
fp.mu.Lock()
|
|
fee := fp.fee
|
|
fpFeeTx := fp.feeTx
|
|
feeAddr := fp.feeAddr
|
|
fp.mu.Unlock()
|
|
|
|
// The rest of this function will operate on the tx pointer, with fp.feeTx
|
|
// assigned to the result on success.
|
|
// Update tx to use the partially created fpFeeTx if any has been started.
|
|
// The transaction pointed to by the caller will be dereferenced and modified
|
|
// when non-nil.
|
|
if fpFeeTx != nil {
|
|
if tx != nil {
|
|
*tx = *fpFeeTx
|
|
} else {
|
|
tx = fpFeeTx
|
|
}
|
|
}
|
|
// Fee transaction with outputs is already finished.
|
|
if fpFeeTx != nil && len(fpFeeTx.TxOut) != 0 {
|
|
return nil
|
|
}
|
|
// When both transactions are nil, create a new empty transaction.
|
|
if tx == nil {
|
|
tx = wire.NewMsgTx()
|
|
}
|
|
|
|
// XXX fp.fee == -1?
|
|
if fee == 0 {
|
|
err := fp.receiveFeeAddress()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fp.mu.Lock()
|
|
fee = fp.fee
|
|
feeAddr = fp.feeAddr
|
|
fp.mu.Unlock()
|
|
}
|
|
|
|
// Reserve new outputs to pay the fee if outputs have not already been
|
|
// reserved. This will be the case for fee payments that were begun on
|
|
// already purchased tickets, where the caller did not ensure that fee
|
|
// outputs would already be reserved.
|
|
if len(tx.TxIn) == 0 {
|
|
const minconf = 1
|
|
inputs, err := w.ReserveOutputsForAmount(ctx, fp.policy.FeeAcct, fee, minconf)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to reserve enough output value to "+
|
|
"pay VSP fee for ticket %v: %w", fp.ticketHash, err)
|
|
}
|
|
for _, in := range inputs {
|
|
tx.AddTxIn(wire.NewTxIn(&in.OutPoint, in.PrevOut.Value, nil))
|
|
}
|
|
// The transaction will be added to the wallet in an unpublished
|
|
// state, so there is no need to leave the outputs locked.
|
|
defer func() {
|
|
for _, in := range inputs {
|
|
w.UnlockOutpoint(&in.OutPoint.Hash, in.OutPoint.Index)
|
|
}
|
|
}()
|
|
}
|
|
|
|
var input int64
|
|
for _, in := range tx.TxIn {
|
|
input += in.ValueIn
|
|
}
|
|
if input < int64(fee) {
|
|
err := fmt.Errorf("not enough input value to pay fee: %v < %v",
|
|
dcrutil.Amount(input), fee)
|
|
return err
|
|
}
|
|
|
|
vers, feeScript := feeAddr.PaymentScript()
|
|
|
|
addr, err := w.NewChangeAddress(ctx, fp.policy.ChangeAcct)
|
|
if err != nil {
|
|
fp.log.Warnf("failed to get new change address: %v", err)
|
|
return err
|
|
}
|
|
var changeOut *wire.TxOut
|
|
switch addr := addr.(type) {
|
|
case wallet.Address:
|
|
vers, script := addr.PaymentScript()
|
|
changeOut = &wire.TxOut{PkScript: script, Version: vers}
|
|
default:
|
|
return fmt.Errorf("failed to convert '%T' to wallet.Address", addr)
|
|
}
|
|
|
|
tx.TxOut = append(tx.TxOut[:0], &wire.TxOut{
|
|
Value: int64(fee),
|
|
Version: vers,
|
|
PkScript: feeScript,
|
|
})
|
|
feeRate := w.RelayFee()
|
|
scriptSizes := make([]int, len(tx.TxIn))
|
|
for i := range scriptSizes {
|
|
scriptSizes[i] = txsizes.RedeemP2PKHSigScriptSize
|
|
}
|
|
est := txsizes.EstimateSerializeSize(scriptSizes, tx.TxOut, txsizes.P2PKHPkScriptSize)
|
|
change := input
|
|
change -= tx.TxOut[0].Value
|
|
change -= int64(txrules.FeeForSerializeSize(feeRate, est))
|
|
if !txrules.IsDustAmount(dcrutil.Amount(change), txsizes.P2PKHPkScriptSize, feeRate) {
|
|
changeOut.Value = change
|
|
tx.TxOut = append(tx.TxOut, changeOut)
|
|
// randomize position
|
|
if coinflip() {
|
|
tx.TxOut[0], tx.TxOut[1] = tx.TxOut[1], tx.TxOut[0]
|
|
}
|
|
}
|
|
|
|
feeHash := tx.TxHash()
|
|
|
|
// sign
|
|
sigErrs, err := w.SignTransaction(ctx, tx, txscript.SigHashAll, nil, nil, nil)
|
|
if err != nil || len(sigErrs) > 0 {
|
|
fp.log.Errorf("failed to sign transaction: %v", err)
|
|
sigErrStr := ""
|
|
for _, sigErr := range sigErrs {
|
|
fp.log.Errorf("\t%v", sigErr)
|
|
sigErrStr = fmt.Sprintf("\t%v", sigErr) + " "
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return fmt.Errorf(sigErrStr)
|
|
}
|
|
|
|
err = w.SetPublished(ctx, &feeHash, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = w.AddTransaction(ctx, tx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = w.UpdateVspTicketFeeToPaid(ctx, &fp.ticketHash, &feeHash, fp.client.URL, fp.client.PubKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fp.mu.Lock()
|
|
fp.feeTx = tx
|
|
fp.feeHash = feeHash
|
|
fp.mu.Unlock()
|
|
|
|
// nothing scheduled
|
|
return nil
|
|
}
|
|
|
|
func (c *AutoClient) status(ctx context.Context, ticketHash *chainhash.Hash) (*types.TicketStatusResponse, error) {
|
|
w := c.wallet
|
|
params := w.ChainParams()
|
|
|
|
ticketTx, err := c.tx(ctx, ticketHash)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve ticket %v: %w", ticketHash, err)
|
|
}
|
|
if len(ticketTx.TxOut) != 3 {
|
|
return nil, fmt.Errorf("ticket %v has multiple commitments: %w", ticketHash, errNotSolo)
|
|
}
|
|
|
|
if !stake.IsSStx(ticketTx) {
|
|
return nil, fmt.Errorf("%v is not a ticket", ticketHash)
|
|
}
|
|
commitmentAddr, err := stake.AddrFromSStxPkScrCommitment(ticketTx.TxOut[1].PkScript, params)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to extract commitment address from %v: %w",
|
|
ticketHash, err)
|
|
}
|
|
|
|
req := types.TicketStatusRequest{
|
|
TicketHash: ticketHash.String(),
|
|
}
|
|
|
|
resp, err := c.Client.TicketStatus(ctx, req, commitmentAddr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// XXX validate server timestamp?
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func (c *AutoClient) setVoteChoices(ctx context.Context, ticketHash *chainhash.Hash,
|
|
choices map[string]string, tspendPolicy map[string]string, treasuryPolicy map[string]string) error {
|
|
w := c.wallet
|
|
params := w.ChainParams()
|
|
|
|
ticketTx, err := c.tx(ctx, ticketHash)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to retrieve ticket %v: %w", ticketHash, err)
|
|
}
|
|
|
|
if !stake.IsSStx(ticketTx) {
|
|
return fmt.Errorf("%v is not a ticket", ticketHash)
|
|
}
|
|
if len(ticketTx.TxOut) != 3 {
|
|
return fmt.Errorf("ticket %v has multiple commitments: %w", ticketHash, errNotSolo)
|
|
}
|
|
|
|
commitmentAddr, err := stake.AddrFromSStxPkScrCommitment(ticketTx.TxOut[1].PkScript, params)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to extract commitment address from %v: %w",
|
|
ticketHash, err)
|
|
}
|
|
|
|
req := types.SetVoteChoicesRequest{
|
|
Timestamp: time.Now().Unix(),
|
|
TicketHash: ticketHash.String(),
|
|
VoteChoices: choices,
|
|
TSpendPolicy: tspendPolicy,
|
|
TreasuryPolicy: treasuryPolicy,
|
|
}
|
|
|
|
_, err = c.Client.SetVoteChoices(ctx, req, commitmentAddr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// XXX validate server timestamp?
|
|
|
|
return nil
|
|
}
|
|
|
|
func (fp *feePayment) reconcilePayment() error {
|
|
ctx := fp.ctx
|
|
w := fp.client.wallet
|
|
|
|
// stop processing if ticket is expired or spent
|
|
// XXX if ticket is no longer saved by wallet (because the tx expired,
|
|
// or was double spent, etc) remove the fee payment.
|
|
if fp.removedExpiredOrSpent() {
|
|
// nothing scheduled
|
|
return errStopped
|
|
}
|
|
|
|
// A fee amount and address must have been created by this point.
|
|
// Ensure that the fee transaction can be created, otherwise reschedule
|
|
// this method until it is. There is no need to check the wallet for a
|
|
// fee transaction matching a known hash; this is performed when
|
|
// creating the feePayment.
|
|
fp.mu.Lock()
|
|
feeTx := fp.feeTx
|
|
fp.mu.Unlock()
|
|
if feeTx == nil || len(feeTx.TxOut) == 0 {
|
|
err := fp.makeFeeTx(nil)
|
|
if err != nil {
|
|
var apiErr types.ErrorResponse
|
|
if errors.As(err, &apiErr) && apiErr.Code == types.ErrTicketCannotVote {
|
|
fp.remove("ticket cannot vote")
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
// A fee address has been obtained, and the fee transaction has been
|
|
// created, but it is unknown if the VSP has received the fee and will
|
|
// vote using the ticket.
|
|
//
|
|
// If the fee is mined, then check the status of the ticket and payment
|
|
// with the VSP, to ensure that it has marked the fee payment as paid.
|
|
//
|
|
// If the fee is not mined, an API call with the VSP is used so it may
|
|
// receive and publish the transaction. A follow up on the ticket
|
|
// status is scheduled for some time in the future.
|
|
|
|
err := fp.submitPayment()
|
|
fp.mu.Lock()
|
|
feeHash := fp.feeHash
|
|
fp.mu.Unlock()
|
|
var apiErr types.ErrorResponse
|
|
if errors.As(err, &apiErr) {
|
|
switch apiErr.Code {
|
|
case types.ErrFeeAlreadyReceived:
|
|
err = w.SetPublished(ctx, &feeHash, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = w.UpdateVspTicketFeeToPaid(ctx, &fp.ticketHash, &feeHash, fp.client.URL, fp.client.PubKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = nil
|
|
case types.ErrInvalidFeeTx, types.ErrCannotBroadcastFee:
|
|
err := w.UpdateVspTicketFeeToErrored(ctx, &fp.ticketHash, fp.client.URL, fp.client.PubKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Attempt to create a new fee transaction
|
|
fp.mu.Lock()
|
|
fp.feeHash = chainhash.Hash{}
|
|
fp.feeTx = nil
|
|
fp.mu.Unlock()
|
|
// err not nilled, so reconcile payment is rescheduled.
|
|
default:
|
|
// do nothing.
|
|
}
|
|
}
|
|
if err != nil {
|
|
// Nothing left to try except trying again.
|
|
fp.schedule("reconcile payment", fp.reconcilePayment)
|
|
return err
|
|
}
|
|
|
|
err = w.UpdateVspTicketFeeToPaid(ctx, &fp.ticketHash, &feeHash, fp.client.URL, fp.client.PubKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// confirmPayment will remove the fee payment processing when the fee
|
|
// has reached sufficient confirmations, and reschedule itself if the
|
|
// fee is not confirmed yet. If the fee tx is ever removed from the
|
|
// wallet, this will schedule another reconcile.
|
|
return fp.confirmPayment()
|
|
|
|
/*
|
|
// XXX? for each input, c.Wallet.UnlockOutpoint(&outpoint.Hash, outpoint.Index)
|
|
// xxx, or let the published tx replace the unpublished one, and unlock
|
|
// outpoints as it is processed.
|
|
|
|
*/
|
|
}
|
|
|
|
func (fp *feePayment) submitPayment() (err error) {
|
|
ctx := fp.ctx
|
|
w := fp.client.wallet
|
|
|
|
// stop processing if ticket is expired or spent
|
|
if fp.removedExpiredOrSpent() {
|
|
// nothing scheduled
|
|
return errStopped
|
|
}
|
|
|
|
// submitting a payment requires the fee tx to already be created.
|
|
fp.mu.Lock()
|
|
feeTx := fp.feeTx
|
|
votingKey := fp.votingKey
|
|
fp.mu.Unlock()
|
|
if feeTx == nil {
|
|
feeTx = new(wire.MsgTx)
|
|
}
|
|
if len(feeTx.TxOut) == 0 {
|
|
err := fp.makeFeeTx(feeTx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if votingKey == "" {
|
|
votingKey, err = w.DumpWIFPrivateKey(ctx, fp.votingAddr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fp.mu.Lock()
|
|
fp.votingKey = votingKey
|
|
fp.mu.Unlock()
|
|
}
|
|
|
|
// Retrieve voting preferences
|
|
voteChoices := make(map[string]string)
|
|
agendaChoices, _, err := w.AgendaChoices(ctx, &fp.ticketHash)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, agendaChoice := range agendaChoices {
|
|
voteChoices[agendaChoice.AgendaID] = agendaChoice.ChoiceID
|
|
}
|
|
|
|
feeTxHex, err := marshalTx(feeTx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req := types.PayFeeRequest{
|
|
Timestamp: time.Now().Unix(),
|
|
TicketHash: fp.ticketHash.String(),
|
|
FeeTx: feeTxHex,
|
|
VotingKey: votingKey,
|
|
VoteChoices: voteChoices,
|
|
TSpendPolicy: w.TSpendPolicyForTicket(&fp.ticketHash),
|
|
TreasuryPolicy: w.TreasuryKeyPolicyForTicket(&fp.ticketHash),
|
|
}
|
|
|
|
_, err = fp.client.PayFee(ctx, req, fp.commitmentAddr)
|
|
if err != nil {
|
|
var apiErr types.ErrorResponse
|
|
if errors.As(err, &apiErr) && apiErr.Code == types.ErrFeeExpired {
|
|
// Fee has been expired, so abandon current feetx, set fp.feeTx
|
|
// to nil and retry submit payment to make a new fee tx.
|
|
feeHash := feeTx.TxHash()
|
|
err := w.AbandonTransaction(ctx, &feeHash)
|
|
if err != nil {
|
|
fp.log.Errorf("error abandoning expired fee tx %v", err)
|
|
}
|
|
fp.mu.Lock()
|
|
fp.feeTx = nil
|
|
fp.mu.Unlock()
|
|
}
|
|
return fmt.Errorf("payfee: %w", err)
|
|
}
|
|
|
|
// TODO - validate server timestamp?
|
|
|
|
fp.log.Infof("successfully processed %v", fp.ticketHash)
|
|
return nil
|
|
}
|
|
|
|
func (fp *feePayment) confirmPayment() (err error) {
|
|
ctx := fp.ctx
|
|
w := fp.client.wallet
|
|
|
|
// stop processing if ticket is expired or spent
|
|
if fp.removedExpiredOrSpent() {
|
|
// nothing scheduled
|
|
return errStopped
|
|
}
|
|
|
|
defer func() {
|
|
if err != nil && !errors.Is(err, errStopped) {
|
|
fp.schedule("reconcile payment", fp.reconcilePayment)
|
|
}
|
|
}()
|
|
|
|
status, err := fp.client.status(ctx, &fp.ticketHash)
|
|
// Suppress log if the wallet is currently locked.
|
|
if err != nil && !errors.Is(err, wallet_errs.Locked) {
|
|
fp.log.Warnf("Rescheduling status check for %v: %v", &fp.ticketHash, err)
|
|
}
|
|
if err != nil {
|
|
// Stop processing if the status check cannot be performed, but
|
|
// a significant amount of confirmations are observed on the fee
|
|
// transaction.
|
|
//
|
|
// Otherwise, chedule another confirmation check, in case the
|
|
// status API can be performed at a later time or more
|
|
// confirmations are observed.
|
|
fp.mu.Lock()
|
|
feeHash := fp.feeHash
|
|
fp.mu.Unlock()
|
|
confs, err := w.TxConfirms(ctx, &feeHash)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if confs >= 6 {
|
|
fp.remove("confirmed")
|
|
err = w.UpdateVspTicketFeeToConfirmed(ctx, &fp.ticketHash, &feeHash, fp.client.URL, fp.client.PubKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
fp.schedule("confirm payment", fp.confirmPayment)
|
|
return nil
|
|
}
|
|
|
|
switch status.FeeTxStatus {
|
|
case "received":
|
|
// VSP has received the fee tx but has not yet broadcast it.
|
|
// VSP will only broadcast the tx when ticket has 6+ confirmations.
|
|
fp.schedule("confirm payment", fp.confirmPayment)
|
|
return nil
|
|
case "broadcast":
|
|
fp.log.Infof("VSP has successfully sent the fee tx for %v", &fp.ticketHash)
|
|
// Broadcasted, but not confirmed.
|
|
fp.schedule("confirm payment", fp.confirmPayment)
|
|
return nil
|
|
case "confirmed":
|
|
fp.remove("confirmed by VSP")
|
|
// nothing scheduled
|
|
fp.mu.Lock()
|
|
feeHash := fp.feeHash
|
|
fp.mu.Unlock()
|
|
err = w.UpdateVspTicketFeeToConfirmed(ctx, &fp.ticketHash, &feeHash, fp.client.URL, fp.client.PubKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
case "error":
|
|
fp.log.Warnf("VSP failed to broadcast feetx for %v -- restarting payment",
|
|
&fp.ticketHash)
|
|
fp.schedule("reconcile payment", fp.reconcilePayment)
|
|
return nil
|
|
default:
|
|
// XXX put in unknown state
|
|
fp.log.Warnf("VSP responded with %v for %v", status.FeeTxStatus,
|
|
&fp.ticketHash)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func marshalTx(tx *wire.MsgTx) (string, error) {
|
|
var buf bytes.Buffer
|
|
buf.Grow(tx.SerializeSize() * 2)
|
|
err := tx.Serialize(hex.NewEncoder(&buf))
|
|
return buf.String(), err
|
|
}
|