From b87f8d5721161bb6d030917ebc104e926ac66e6c Mon Sep 17 00:00:00 2001 From: dmigwi Date: Wed, 16 Aug 2023 23:11:42 +0300 Subject: [PATCH 01/11] Confirm fee processing error happened before re-initiating it --- ui/page/staking/stake_overview.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/page/staking/stake_overview.go b/ui/page/staking/stake_overview.go index 6b6bb3988..6983f727c 100644 --- a/ui/page/staking/stake_overview.go +++ b/ui/page/staking/stake_overview.go @@ -340,9 +340,9 @@ func (pg *Page) HandleUserInteractions() { ticketTx.Hash, ticketInfo.FeeTxStatus.String(), ticketInfo.VSP) } - // Confirm that fee hasn't been paid, sender account exists, the wallet + // Confirm that fee processing errored, sender account exists, the wallet // is unlocked and no previous ticket processing instance is running. - if ticketInfo.FeeTxStatus != dcr.VSPFeeProcessPaid && len(ticketTx.Inputs) == 1 && + if ticketInfo.FeeTxStatus == dcr.VSPFeeProcessErrored && len(ticketTx.Inputs) == 1 && ticketInfo.Client != nil && atomic.CompareAndSwapUint32(&pg.processingTicket, 0, 1) { log.Infof("Attempting to process the unconfirmed VSP fee for tx: %v", ticketTx.Hash) From 5e1eee9790066baf07962b0fd2f1470570498ca8 Mon Sep 17 00:00:00 2001 From: dmigwi Date: Thu, 17 Aug 2023 17:50:48 +0300 Subject: [PATCH 02/11] Update the vsp implementation code --- libwallet/internal/vsp/client.go | 206 +-- libwallet/internal/vsp/feepayment.go | 2047 ++++++++++++------------- libwallet/internal/vsp/feepayments.go | 838 ++++++++++ libwallet/internal/vsp/vsp.go | 165 +- 4 files changed, 2029 insertions(+), 1227 deletions(-) create mode 100644 libwallet/internal/vsp/feepayments.go diff --git a/libwallet/internal/vsp/client.go b/libwallet/internal/vsp/client.go index 2b3f3731c..94a5c888b 100644 --- a/libwallet/internal/vsp/client.go +++ b/libwallet/internal/vsp/client.go @@ -1,105 +1,105 @@ package vsp -import ( - "context" - "crypto/ed25519" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - - "github.com/crypto-power/cryptopower/libwallet/utils" - "github.com/decred/dcrd/txscript/v4/stdaddr" -) - -type client struct { - http.Client - url string - pub []byte - sign func(context.Context, string, stdaddr.Address) ([]byte, error) -} - -type signer interface { - SignMessage(ctx context.Context, message string, address stdaddr.Address) ([]byte, error) -} - -func newClient(url string, pub []byte, s signer) *client { - return &client{url: url, pub: pub, sign: s.SignMessage} -} - -type BadRequestError struct { - HTTPStatus int `json:"-"` - Code int `json:"code"` - Message string `json:"message"` -} - -func (e *BadRequestError) Error() string { return e.Message } - -func (c *client) post(ctx context.Context, path string, addr stdaddr.Address, response interface{}, body []byte) error { - return c.do(ctx, http.MethodPost, path, addr, response, body) -} - -func (c *client) get(ctx context.Context, path string, resp interface{}) error { - return c.do(ctx, http.MethodGet, path, nil, resp, nil) -} - -func (c *client) do(ctx context.Context, method, path string, addr stdaddr.Address, response interface{}, body []byte) error { - var err error - var sig []byte - reqConf := &utils.ReqConfig{ - Method: method, - HTTPURL: c.url + path, - IsRetByte: true, - Headers: make(http.Header), - } - - if method == http.MethodPost { - sig, err = c.sign(ctx, string(body), addr) - if err != nil { - return fmt.Errorf("sign request: %w", err) - } - reqConf.Payload = body - } - - // Add cookies. - if sig != nil { - reqConf.Headers.Add("VSP-Client-Signature", base64.StdEncoding.EncodeToString(sig)) - } - - respBytes := []byte{} - reply, err := utils.HTTPRequest(reqConf, &respBytes) - if err != nil && reply == nil { - // Status code errors are handled below. - return err - } - - status := reply.StatusCode - is200 := status == 200 - is4xx := status >= 400 && status <= 499 - if !(is200 || is4xx) { - return err - } - - if err = json.Unmarshal(respBytes, response); err != nil { - return fmt.Errorf("could not pack response data: %w", err) - } - - sigBase64 := reply.Header.Get("VSP-Server-Signature") - sig, err = base64.StdEncoding.DecodeString(sigBase64) - if err != nil { - return fmt.Errorf("cannot authenticate server: %w", err) - } - - if !ed25519.Verify(c.pub, respBytes, sig) { - return fmt.Errorf("cannot authenticate server: invalid signature") - } - - var apiError *BadRequestError - if is4xx { - apiError = new(BadRequestError) - apiError.HTTPStatus = status - return apiError - } - - return nil -} +// import ( +// "context" +// "crypto/ed25519" +// "encoding/base64" +// "encoding/json" +// "fmt" +// "net/http" + +// "github.com/crypto-power/cryptopower/libwallet/utils" +// "github.com/decred/dcrd/txscript/v4/stdaddr" +// ) + +// type client struct { +// http.Client +// url string +// pub []byte +// sign func(context.Context, string, stdaddr.Address) ([]byte, error) +// } + +// type signer interface { +// SignMessage(ctx context.Context, message string, address stdaddr.Address) ([]byte, error) +// } + +// func newClient(url string, pub []byte, s signer) *client { +// return &client{url: url, pub: pub, sign: s.SignMessage} +// } + +// type BadRequestError struct { +// HTTPStatus int `json:"-"` +// Code int `json:"code"` +// Message string `json:"message"` +// } + +// func (e *BadRequestError) Error() string { return e.Message } + +// func (c *client) post(ctx context.Context, path string, addr stdaddr.Address, response interface{}, body []byte) error { +// return c.do(ctx, http.MethodPost, path, addr, response, body) +// } + +// func (c *client) get(ctx context.Context, path string, resp interface{}) error { +// return c.do(ctx, http.MethodGet, path, nil, resp, nil) +// } + +// func (c *client) do(ctx context.Context, method, path string, addr stdaddr.Address, response interface{}, body []byte) error { +// var err error +// var sig []byte +// reqConf := &utils.ReqConfig{ +// Method: method, +// HTTPURL: c.url + path, +// IsRetByte: true, +// Headers: make(http.Header), +// } + +// if method == http.MethodPost { +// sig, err = c.sign(ctx, string(body), addr) +// if err != nil { +// return fmt.Errorf("sign request: %w", err) +// } +// reqConf.Payload = body +// } + +// // Add cookies. +// if sig != nil { +// reqConf.Headers.Add("VSP-Client-Signature", base64.StdEncoding.EncodeToString(sig)) +// } + +// respBytes := []byte{} +// reply, err := utils.HTTPRequest(reqConf, &respBytes) +// if err != nil && reply == nil { +// // Status code errors are handled below. +// return err +// } + +// status := reply.StatusCode +// is200 := status == 200 +// is4xx := status >= 400 && status <= 499 +// if !(is200 || is4xx) { +// return err +// } + +// if err = json.Unmarshal(respBytes, response); err != nil { +// return fmt.Errorf("could not pack response data: %w", err) +// } + +// sigBase64 := reply.Header.Get("VSP-Server-Signature") +// sig, err = base64.StdEncoding.DecodeString(sigBase64) +// if err != nil { +// return fmt.Errorf("cannot authenticate server: %w", err) +// } + +// if !ed25519.Verify(c.pub, respBytes, sig) { +// return fmt.Errorf("cannot authenticate server: invalid signature") +// } + +// var apiError *BadRequestError +// if is4xx { +// apiError = new(BadRequestError) +// apiError.HTTPStatus = status +// return apiError +// } + +// return nil +// } diff --git a/libwallet/internal/vsp/feepayment.go b/libwallet/internal/vsp/feepayment.go index c95cf7bac..50edb0fad 100644 --- a/libwallet/internal/vsp/feepayment.go +++ b/libwallet/internal/vsp/feepayment.go @@ -1,1033 +1,1018 @@ package vsp -import ( - "bytes" - "context" - cryptorand "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "sync" - "time" - - "decred.org/dcrwallet/v3/errors" - "decred.org/dcrwallet/v3/wallet" - "decred.org/dcrwallet/v3/wallet/txrules" - "decred.org/dcrwallet/v3/wallet/txsizes" - "github.com/crypto-power/cryptopower/libwallet/internal/uniformprng" - "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" -) - -var prng lockedRand - -type lockedRand struct { - mu sync.Mutex - rand *uniformprng.Source -} - -func (r *lockedRand) int63n(n int64) int64 { - r.mu.Lock() - defer r.mu.Unlock() - return r.rand.Int63n(n) -} - -// duration returns a random time.Duration in [0,d) with uniform distribution. -func (r *lockedRand) duration(d time.Duration) time.Duration { - return time.Duration(r.int63n(int64(d))) -} - -func (r *lockedRand) coinflip() bool { - r.mu.Lock() - defer r.mu.Unlock() - return r.rand.Uint32n(2) == 0 -} - -func init() { - source, err := uniformprng.RandSource(cryptorand.Reader) - if err != nil { - panic(err) - } - prng = lockedRand{ - rand: source, - } -} - -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 *Client - 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 -} - -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() - 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 *Client) feePayment(ticketHash *chainhash.Hash, policy Policy, 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) - } - }() - - ctx := context.Background() - w := c.Wallet - params := w.ChainParams() - - fp = &feePayment{ - client: c, - ctx: ctx, - ticketHash: *ticketHash, - policy: policy, - } - - // 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 { - 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. - 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 { - 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(errors.Locked, err) { - 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.pub) - 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 *Client) 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 { - 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 prng.duration(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 { - 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) - } - - var response struct { - Timestamp int64 `json:"timestamp"` - FeeAddress string `json:"feeaddress"` - FeeAmount int64 `json:"feeamount"` - Request []byte `json:"request"` - } - requestBody, err := json.Marshal(&struct { - Timestamp int64 `json:"timestamp"` - TicketHash string `json:"tickethash"` - TicketHex json.Marshaler `json:"tickethex"` - ParentHex json.Marshaler `json:"parenthex"` - }{ - Timestamp: time.Now().Unix(), - TicketHash: fp.ticketHash.String(), - TicketHex: txMarshaler(ticket), - ParentHex: txMarshaler(parent), - }) - if err != nil { - return err - } - err = fp.client.post(ctx, "/api/v3/feeaddress", fp.commitmentAddr, &response, - json.RawMessage(requestBody)) - if err != nil { - return err - } - - // verify initial request matches server - if !bytes.Equal(requestBody, response.Request) { - return fmt.Errorf("server response has differing request: %#v != %#v", - requestBody, response.Request) - } - - feeAmount := dcrutil.Amount(response.FeeAmount) - feeAddr, err := stdaddr.DecodeAddress(response.FeeAddress, params) - if err != nil { - return fmt.Errorf("server fee address invalid: %w", err) - } - - 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 the 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 { - 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 prng.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 { - log.Errorf("failed to sign transaction: %v", err) - sigErrStr := "" - for _, sigErr := range sigErrs { - 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.pub) - if err != nil { - return err - } - - fp.mu.Lock() - fp.feeTx = tx - fp.feeHash = feeHash - fp.mu.Unlock() - - // nothing scheduled - return nil -} - -type TicketStatus struct { - Timestamp int64 `json:"timestamp"` - TicketConfirmed bool `json:"ticketconfirmed"` - FeeTxStatus string `json:"feetxstatus"` - FeeTxHash string `json:"feetxhash"` - VoteChoices map[string]string `json:"votechoices"` - TSpendPolicy map[string]string `json:"tspendpolicy"` - TreasuryPolicy map[string]string `json:"treasurypolicy"` - Request []byte `json:"request"` -} - -// GetTicketStatus calls the VSP's TicketStatus API for the provided ticket hash -// and returns the VSP's response. -func (c *Client) GetTicketStatus(ctx context.Context, ticketHash *chainhash.Hash) (*TicketStatus, error) { - return c.status(ctx, ticketHash) -} - -func (c *Client) status(ctx context.Context, ticketHash *chainhash.Hash) (*TicketStatus, 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) - } - - var resp TicketStatus - requestBody, err := json.Marshal(&struct { - TicketHash string `json:"tickethash"` - }{ - TicketHash: ticketHash.String(), - }) - if err != nil { - return nil, err - } - err = c.post(ctx, "/api/v3/ticketstatus", commitmentAddr, &resp, - json.RawMessage(requestBody)) - if err != nil { - return nil, err - } - - // verify initial request matches server - if !bytes.Equal(requestBody, resp.Request) { - log.Warnf("server response has differing request: %#v != %#v", - requestBody, resp.Request) - return nil, fmt.Errorf("server response contains differing request") - } - - // XXX validate server timestamp? - - return &resp, nil -} - -func (c *Client) setVoteChoices(ctx context.Context, ticketHash *chainhash.Hash, - choices []wallet.AgendaChoice, 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) - } - - agendaChoices := make(map[string]string, len(choices)) - - // Prepare agenda choice - for _, c := range choices { - agendaChoices[c.AgendaID] = c.ChoiceID - } - - var resp TicketStatus - requestBody, err := json.Marshal(&struct { - Timestamp int64 `json:"timestamp"` - TicketHash string `json:"tickethash"` - VoteChoices map[string]string `json:"votechoices"` - TSpendPolicy map[string]string `json:"tspendpolicy"` - TreasuryPolicy map[string]string `json:"treasurypolicy"` - }{ - Timestamp: time.Now().Unix(), - TicketHash: ticketHash.String(), - VoteChoices: agendaChoices, - TSpendPolicy: tspendPolicy, - TreasuryPolicy: treasuryPolicy, - }) - if err != nil { - return err - } - - err = c.post(ctx, "/api/v3/setvotechoices", commitmentAddr, &resp, - json.RawMessage(requestBody)) - if err != nil { - return err - } - - // verify initial request matches server - if !bytes.Equal(requestBody, resp.Request) { - log.Warnf("server response has differing request: %#v != %#v", - requestBody, resp.Request) - return fmt.Errorf("server response contains differing request") - } - - // 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 *BadRequestError - if errors.As(err, &apiErr) && apiErr.Code == codeTicketCannotVote { - fp.remove("ticket cannot vote") - // Tickets will be automatically revoked. - } - 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 *BadRequestError - if errors.As(err, &apiErr) { - switch apiErr.Code { - case codeFeeAlreadyReceived: - err = w.SetPublished(ctx, &feeHash, true) - if err != nil { - return err - } - err = w.UpdateVspTicketFeeToPaid(ctx, &fp.ticketHash, &feeHash, fp.client.url, fp.client.pub) - if err != nil { - return err - } - err = nil - case codeInvalidFeeTx, codeCannotBroadcastFee: - err := w.UpdateVspTicketFeeToErrored(ctx, &fp.ticketHash, fp.client.url, fp.client.pub) - 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. - } - } - 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.pub) - 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 - } - - var payfeeResponse struct { - Timestamp int64 `json:"timestamp"` - Request []byte `json:"request"` - } - requestBody, err := json.Marshal(&struct { - Timestamp int64 `json:"timestamp"` - TicketHash string `json:"tickethash"` - FeeTx json.Marshaler `json:"feetx"` - VotingKey string `json:"votingkey"` - VoteChoices map[string]string `json:"votechoices"` - TSpendPolicy map[string]string `json:"tspendpolicy"` - TreasuryPolicy map[string]string `json:"treasurypolicy"` - }{ - Timestamp: time.Now().Unix(), - TicketHash: fp.ticketHash.String(), - FeeTx: txMarshaler(feeTx), - VotingKey: votingKey, - VoteChoices: voteChoices, - TSpendPolicy: w.TSpendPolicyForTicket(&fp.ticketHash), - TreasuryPolicy: w.TreasuryKeyPolicyForTicket(&fp.ticketHash), - }) - if err != nil { - return err - } - err = fp.client.post(ctx, "/api/v3/payfee", fp.commitmentAddr, - &payfeeResponse, json.RawMessage(requestBody)) - if err != nil { - var apiErr *BadRequestError - if errors.As(err, &apiErr) && apiErr.Code == codeFeeExpired { - // 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 { - log.Errorf("error abandoning expired fee tx %v", err) - } - fp.feeTx = nil - } - return fmt.Errorf("payfee: %w", err) - } - - // Check for matching original request. - // This is signed by the VSP, and the signature - // has already been checked above. - if !bytes.Equal(requestBody, payfeeResponse.Request) { - return fmt.Errorf("server response has differing request: %#v != %#v", - requestBody, payfeeResponse.Request) - } - // TODO - validate server timestamp? - - 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, errors.Locked) { - 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.pub) - 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": - 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 - err = w.UpdateVspTicketFeeToConfirmed(ctx, &fp.ticketHash, &fp.feeHash, fp.client.url, fp.client.pub) - if err != nil { - return err - } - return nil - case "error": - 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 - log.Warnf("VSP responded with %v for %v", status.FeeTxStatus, - &fp.ticketHash) - } - - return nil -} - -type marshaler struct { - marshaled []byte - err error -} - -func (m *marshaler) MarshalJSON() ([]byte, error) { - return m.marshaled, m.err -} - -func txMarshaler(tx *wire.MsgTx) json.Marshaler { - var buf bytes.Buffer - buf.Grow(2 + tx.SerializeSize()*2) - buf.WriteByte('"') - err := tx.Serialize(hex.NewEncoder(&buf)) - buf.WriteByte('"') - return &marshaler{buf.Bytes(), err} -} +// import ( +// "bytes" +// "context" +// cryptorand "crypto/rand" +// "encoding/hex" +// "encoding/json" +// "fmt" +// "sync" +// "time" + +// "decred.org/dcrwallet/v3/errors" +// "decred.org/dcrwallet/v3/wallet" +// "decred.org/dcrwallet/v3/wallet/txrules" +// "decred.org/dcrwallet/v3/wallet/txsizes" +// "github.com/crypto-power/cryptopower/libwallet/internal/uniformprng" +// "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" +// ) + +// var prng lockedRand + +// type lockedRand struct { +// mu sync.Mutex +// rand *uniformprng.Source +// } + +// func (r *lockedRand) int63n(n int64) int64 { +// r.mu.Lock() +// defer r.mu.Unlock() +// return r.rand.Int63n(n) +// } + +// // duration returns a random time.Duration in [0,d) with uniform distribution. +// func (r *lockedRand) duration(d time.Duration) time.Duration { +// return time.Duration(r.int63n(int64(d))) +// } + +// func (r *lockedRand) coinflip() bool { +// r.mu.Lock() +// defer r.mu.Unlock() +// return r.rand.Uint32n(2) == 0 +// } + +// func init() { +// source, err := uniformprng.RandSource(cryptorand.Reader) +// if err != nil { +// panic(err) +// } +// prng = lockedRand{ +// rand: source, +// } +// } + +// 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 *Client +// 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 +// } + +// 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() +// 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 *Client) 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 + +// fp = &feePayment{ +// client: c, +// ctx: context.Background(), +// ticketHash: *ticketHash, +// policy: c.policy, +// params: c.params, +// } + +// // 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.client.log.Warnf("no ticket found for %v", ticketHash) +// return nil +// } + +// fp.votingAddr, fp.commitmentAddr, err = parseTicket(ticket, c.params) +// if err != nil { +// fp.client.log.Errorf("%v is not a ticket: %v", ticketHash, err) +// return nil +// } +// // Try to access the voting key. +// fp.votingKey, err = w.DumpWIFPrivateKey(ctx, fp.votingAddr) +// if err != nil { +// fp.client.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 *Client) 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 { +// 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 prng.duration(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 { +// 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) +// } + +// var response struct { +// Timestamp int64 `json:"timestamp"` +// FeeAddress string `json:"feeaddress"` +// FeeAmount int64 `json:"feeamount"` +// Request []byte `json:"request"` +// } +// requestBody, err := json.Marshal(&struct { +// Timestamp int64 `json:"timestamp"` +// TicketHash string `json:"tickethash"` +// TicketHex json.Marshaler `json:"tickethex"` +// ParentHex json.Marshaler `json:"parenthex"` +// }{ +// Timestamp: time.Now().Unix(), +// TicketHash: fp.ticketHash.String(), +// TicketHex: txMarshaler(ticket), +// ParentHex: txMarshaler(parent), +// }) +// if err != nil { +// return err +// } +// err = fp.client.post(ctx, "/api/v3/feeaddress", fp.commitmentAddr, &response, +// json.RawMessage(requestBody)) +// if err != nil { +// return err +// } + +// // verify initial request matches server +// if !bytes.Equal(requestBody, response.Request) { +// return fmt.Errorf("server response has differing request: %#v != %#v", +// requestBody, response.Request) +// } + +// feeAmount := dcrutil.Amount(response.FeeAmount) +// feeAddr, err := stdaddr.DecodeAddress(response.FeeAddress, params) +// if err != nil { +// return fmt.Errorf("server fee address invalid: %w", err) +// } + +// 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 the 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 { +// 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 prng.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 { +// log.Errorf("failed to sign transaction: %v", err) +// sigErrStr := "" +// for _, sigErr := range sigErrs { +// 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.pub) +// if err != nil { +// return err +// } + +// fp.mu.Lock() +// fp.feeTx = tx +// fp.feeHash = feeHash +// fp.mu.Unlock() + +// // nothing scheduled +// return nil +// } + +// type TicketStatus struct { +// Timestamp int64 `json:"timestamp"` +// TicketConfirmed bool `json:"ticketconfirmed"` +// FeeTxStatus string `json:"feetxstatus"` +// FeeTxHash string `json:"feetxhash"` +// VoteChoices map[string]string `json:"votechoices"` +// TSpendPolicy map[string]string `json:"tspendpolicy"` +// TreasuryPolicy map[string]string `json:"treasurypolicy"` +// Request []byte `json:"request"` +// } + +// // GetTicketStatus calls the VSP's TicketStatus API for the provided ticket hash +// // and returns the VSP's response. +// func (c *Client) GetTicketStatus(ctx context.Context, ticketHash *chainhash.Hash) (*TicketStatus, error) { +// return c.status(ctx, ticketHash) +// } + +// func (c *Client) status(ctx context.Context, ticketHash *chainhash.Hash) (*TicketStatus, 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) +// } + +// var resp TicketStatus +// requestBody, err := json.Marshal(&struct { +// TicketHash string `json:"tickethash"` +// }{ +// TicketHash: ticketHash.String(), +// }) +// if err != nil { +// return nil, err +// } +// err = c.post(ctx, "/api/v3/ticketstatus", commitmentAddr, &resp, +// json.RawMessage(requestBody)) +// if err != nil { +// return nil, err +// } + +// // verify initial request matches server +// if !bytes.Equal(requestBody, resp.Request) { +// log.Warnf("server response has differing request: %#v != %#v", +// requestBody, resp.Request) +// return nil, fmt.Errorf("server response contains differing request") +// } + +// // XXX validate server timestamp? + +// return &resp, nil +// } + +// func (c *Client) setVoteChoices(ctx context.Context, ticketHash *chainhash.Hash, +// choices []wallet.AgendaChoice, 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) +// } + +// agendaChoices := make(map[string]string, len(choices)) + +// // Prepare agenda choice +// for _, c := range choices { +// agendaChoices[c.AgendaID] = c.ChoiceID +// } + +// var resp TicketStatus +// requestBody, err := json.Marshal(&struct { +// Timestamp int64 `json:"timestamp"` +// TicketHash string `json:"tickethash"` +// VoteChoices map[string]string `json:"votechoices"` +// TSpendPolicy map[string]string `json:"tspendpolicy"` +// TreasuryPolicy map[string]string `json:"treasurypolicy"` +// }{ +// Timestamp: time.Now().Unix(), +// TicketHash: ticketHash.String(), +// VoteChoices: agendaChoices, +// TSpendPolicy: tspendPolicy, +// TreasuryPolicy: treasuryPolicy, +// }) +// if err != nil { +// return err +// } + +// err = c.post(ctx, "/api/v3/setvotechoices", commitmentAddr, &resp, +// json.RawMessage(requestBody)) +// if err != nil { +// return err +// } + +// // verify initial request matches server +// if !bytes.Equal(requestBody, resp.Request) { +// log.Warnf("server response has differing request: %#v != %#v", +// requestBody, resp.Request) +// return fmt.Errorf("server response contains differing request") +// } + +// // 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 *BadRequestError +// if errors.As(err, &apiErr) && apiErr.Code == codeTicketCannotVote { +// fp.remove("ticket cannot vote") +// // Tickets will be automatically revoked. +// } +// 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 *BadRequestError +// if errors.As(err, &apiErr) { +// switch apiErr.Code { +// case codeFeeAlreadyReceived: +// err = w.SetPublished(ctx, &feeHash, true) +// if err != nil { +// return err +// } +// err = w.UpdateVspTicketFeeToPaid(ctx, &fp.ticketHash, &feeHash, fp.client.url, fp.client.pub) +// if err != nil { +// return err +// } +// err = nil +// case codeInvalidFeeTx, codeCannotBroadcastFee: +// err := w.UpdateVspTicketFeeToErrored(ctx, &fp.ticketHash, fp.client.url, fp.client.pub) +// 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. +// } +// } +// 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.pub) +// 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 +// } + +// var payfeeResponse struct { +// Timestamp int64 `json:"timestamp"` +// Request []byte `json:"request"` +// } +// requestBody, err := json.Marshal(&struct { +// Timestamp int64 `json:"timestamp"` +// TicketHash string `json:"tickethash"` +// FeeTx json.Marshaler `json:"feetx"` +// VotingKey string `json:"votingkey"` +// VoteChoices map[string]string `json:"votechoices"` +// TSpendPolicy map[string]string `json:"tspendpolicy"` +// TreasuryPolicy map[string]string `json:"treasurypolicy"` +// }{ +// Timestamp: time.Now().Unix(), +// TicketHash: fp.ticketHash.String(), +// FeeTx: txMarshaler(feeTx), +// VotingKey: votingKey, +// VoteChoices: voteChoices, +// TSpendPolicy: w.TSpendPolicyForTicket(&fp.ticketHash), +// TreasuryPolicy: w.TreasuryKeyPolicyForTicket(&fp.ticketHash), +// }) +// if err != nil { +// return err +// } +// err = fp.client.post(ctx, "/api/v3/payfee", fp.commitmentAddr, +// &payfeeResponse, json.RawMessage(requestBody)) +// if err != nil { +// var apiErr *BadRequestError +// if errors.As(err, &apiErr) && apiErr.Code == codeFeeExpired { +// // 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 { +// log.Errorf("error abandoning expired fee tx %v", err) +// } +// fp.feeTx = nil +// } +// return fmt.Errorf("payfee: %w", err) +// } + +// // Check for matching original request. +// // This is signed by the VSP, and the signature +// // has already been checked above. +// if !bytes.Equal(requestBody, payfeeResponse.Request) { +// return fmt.Errorf("server response has differing request: %#v != %#v", +// requestBody, payfeeResponse.Request) +// } +// // TODO - validate server timestamp? + +// 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, errors.Locked) { +// 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.pub) +// 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": +// 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 +// err = w.UpdateVspTicketFeeToConfirmed(ctx, &fp.ticketHash, &fp.feeHash, fp.client.url, fp.client.pub) +// if err != nil { +// return err +// } +// return nil +// case "error": +// 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 +// log.Warnf("VSP responded with %v for %v", status.FeeTxStatus, +// &fp.ticketHash) +// } + +// return nil +// } + +// type marshaler struct { +// marshaled []byte +// err error +// } + +// func (m *marshaler) MarshalJSON() ([]byte, error) { +// return m.marshaled, m.err +// } + +// func txMarshaler(tx *wire.MsgTx) json.Marshaler { +// var buf bytes.Buffer +// buf.Grow(2 + tx.SerializeSize()*2) +// buf.WriteByte('"') +// err := tx.Serialize(hex.NewEncoder(&buf)) +// buf.WriteByte('"') +// return &marshaler{buf.Bytes(), err} +// } diff --git a/libwallet/internal/vsp/feepayments.go b/libwallet/internal/vsp/feepayments.go new file mode 100644 index 000000000..98d868421 --- /dev/null +++ b/libwallet/internal/vsp/feepayments.go @@ -0,0 +1,838 @@ +package vsp + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "fmt" + "math/bits" + "sync" + "time" + + "decred.org/dcrwallet/v3/errors" + "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/stdaddr" + "github.com/decred/dcrd/txscript/v4/stdscript" + "github.com/decred/dcrd/wire" + "github.com/decred/vspd/types/v2" +) + +// randInt63 returns a cryptographically random 63-bit positive integer as an +// int64. +func randInt63() int64 { + buf := make([]byte, 8) + _, err := rand.Read(buf) + if err != nil { + panic(fmt.Sprintf("unhandled crypto/rand error: %v", err)) + } + return int64(binary.LittleEndian.Uint64(buf) &^ (1 << 63)) +} + +// randInt63n returns, as an int64, a cryptographically-random 63-bit positive +// integer in [0,n) without modulo bias. +// It panics if n <= 0. +func randInt63n(n int64) int64 { + if n <= 0 { + panic("invalid argument to int63n") + } + n-- + mask := int64(^uint64(0) >> bits.LeadingZeros64(uint64(n))) + for { + v := randInt63() & mask + if v <= n { + return v + } + } +} + +// randomDuration returns a random time.Duration in [0,d) with uniform +// distribution. +func randomDuration(d time.Duration) time.Duration { + return time.Duration(randInt63n(int64(d))) +} + +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 *Client + 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 + + params *chaincfg.Params +} + +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 +} + +// calcHeights checks if the ticket has been mined, and if so, sets the live +// height and expiry height fields. Should be called with mutex already held. +func (fp *feePayment) calcHeights() { + _, minedHeight, err := fp.client.wallet.TxBlock(fp.ctx, &fp.ticketHash) + if err != nil { + // This is not expected to ever error, as the ticket has already been + // fetched from the wallet at least one before this point is reached. + log.Errorf("Failed to query block which mines ticket: %v", err) + return + } + + if minedHeight < 2 { + return + } + + // Note the off-by-one; this is correct. Tickets become live one block after + // the params would indicate. + fp.ticketLive = minedHeight + int32(fp.params.TicketMaturity) + 1 + fp.ticketExpires = fp.ticketLive + int32(fp.params.TicketExpiry) +} + +// expiryHeight returns the height at which the ticket expires. Returns zero if +// the block is not yet mined. Should be called with mutex already held. +func (fp *feePayment) expiryHeight() int32 { + if fp.ticketExpires == 0 { + fp.calcHeights() + } + + return fp.ticketExpires +} + +// liveHeight returns the height at which the ticket becomes live. Returns zero +// if the block is not yet mined. Should be called with mutex already held. +func (fp *feePayment) liveHeight() int32 { + if fp.ticketLive == 0 { + fp.calcHeights() + } + + return fp.ticketLive +} + +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.expiryHeight() + 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() + 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 *Client) 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 + + fp = &feePayment{ + client: c, + ctx: context.Background(), + ticketHash: *ticketHash, + policy: c.policy, + params: c.params, + } + + // 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 { + log.Warnf("no ticket found for %v", ticketHash) + return nil + } + + fp.votingAddr, fp.commitmentAddr, err = parseTicket(ticket, c.params) + if err != nil { + log.Errorf("%v is not a ticket: %v", ticketHash, err) + return nil + } + // Try to access the voting key. + fp.votingKey, err = w.DumpWIFPrivateKey(ctx, fp.votingAddr) + if err != nil { + 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 *Client) 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 { + 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 + _, tipHeight := w.MainChainTip(fp.ctx) + + fp.mu.Lock() + ticketLive := fp.liveHeight() + ticketExpires := fp.expiryHeight() + fp.mu.Unlock() + + var jitter time.Duration + switch { + case tipHeight < ticketLive: // immature, mined ticket + blocksUntilLive := ticketLive - tipHeight + jitter = fp.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 { + 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 + + // 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, fp.params) + if err != nil { + return fmt.Errorf("server fee address invalid: %w", err) + } + + 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() + } + + err := w.CreateVspPayment(ctx, tx, fee, feeAddr, fp.policy.FeeAcct, fp.policy.ChangeAcct) + if err != nil { + return fmt.Errorf("unable to create VSP fee tx for ticket %v: %w", fp.ticketHash, err) + } + + feeHash := tx.TxHash() + 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 *Client) status(ctx context.Context, ticketHash *chainhash.Hash) (*types.TicketStatusResponse, error) { + 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, c.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 *Client) setVoteChoices(ctx context.Context, ticketHash *chainhash.Hash, + choices map[string]string, tspendPolicy map[string]string, treasuryPolicy map[string]string, +) error { + 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, c.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. + } + } + 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 + } + + 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, _, err := w.AgendaChoices(ctx, &fp.ticketHash) + if err != nil { + return err + } + + 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 { + 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? + + log.Infof("successfully processed %v", fp.ticketHash) + return nil +} + +// 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. +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) + if err != nil { + log.Warnf("Rescheduling status check for %v: %v", &fp.ticketHash, err) + 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": + 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": + 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 + log.Warnf("VSP responded with unknown FeeTxStatus %q 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 +} diff --git a/libwallet/internal/vsp/vsp.go b/libwallet/internal/vsp/vsp.go index d5fb4afe8..a81dce4ab 100644 --- a/libwallet/internal/vsp/vsp.go +++ b/libwallet/internal/vsp/vsp.go @@ -12,9 +12,11 @@ import ( "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/stdaddr" "github.com/decred/dcrd/wire" + vspd "github.com/decred/vspd/client/v2" ) type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error) @@ -26,11 +28,13 @@ type Policy struct { } type Client struct { - Wallet *wallet.Wallet - *client + wallet *wallet.Wallet + policy *Policy + *vspd.Client - mu sync.Mutex - jobs map[chainhash.Hash]*feePayment + mu sync.Mutex + jobs map[chainhash.Hash]*feePayment + params *chaincfg.Params } type Config struct { @@ -45,6 +49,11 @@ type Config struct { // Wallet specifies a loaded wallet. Wallet *wallet.Wallet + // Default policy for fee payments unless another is provided by the + // caller. + Policy *Policy + + Params *chaincfg.Params } func New(cfg Config) (*Client, error) { @@ -57,107 +66,81 @@ func New(cfg Config) (*Client, error) { return nil, fmt.Errorf("wallet option not set") } - client := newClient(u.String(), cfg.PubKey, cfg.Wallet) + if cfg.Params == nil { + return nil, fmt.Errorf("params option not set") + } + + client := &vspd.Client{ + URL: u.String(), + PubKey: cfg.PubKey, + Sign: cfg.Wallet.SignMessage, + Log: log, + } client.Transport = &http.Transport{ DialContext: cfg.Dialer, } v := &Client{ - Wallet: cfg.Wallet, - client: client, + wallet: cfg.Wallet, + policy: cfg.Policy, + Client: client, jobs: make(map[chainhash.Hash]*feePayment), + params: cfg.Params, } return v, nil } func (c *Client) FeePercentage(ctx context.Context) (float64, error) { - var resp struct { - FeePercentage float64 `json:"feepercentage"` - } - err := c.get(ctx, "/api/v3/vspinfo", &resp) + 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 *Client) ProcessUnprocessedTickets(ctx context.Context, policy Policy) { +// ProcessUnprocessedTickets adds the provided tickets to the client. Noop if +// a given ticket is already added. +func (c *Client) ProcessUnprocessedTickets(ctx context.Context, tickets []*chainhash.Hash) { 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) { - log.Error(err) - return nil - } - - if confirmed { - return nil - } + for _, hash := range tickets { c.mu.Lock() fp := c.jobs[*hash] c.mu.Unlock() if fp != nil { // Already processing this ticket with the VSP. - return nil + continue } // Start processing in the background. wg.Add(1) - go func() { + go func(ticketHash *chainhash.Hash) { defer wg.Done() - err := c.Process(ctx, hash, nil, policy) + err := c.Process(ctx, ticketHash, nil) if err != nil { log.Error(err) } - }() + }(hash) + } - return nil - }) wg.Wait() } -// ProcessTicket attempts to process a given ticket based on the hash provided. -func (c *Client) ProcessTicket(ctx context.Context, hash *chainhash.Hash, policy Policy) error { - err := c.Process(ctx, hash, nil, policy) - 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 *Client) ProcessManagedTickets(ctx context.Context, policy Policy) 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) { - log.Error(err) - return nil - } - if confirmed { - return nil - } +// ProcessManagedTickets adds the provided tickets to the client and resumes +// their fee payment process. Noop if a given ticket is already added, or if the +// ticket is not registered with the VSP. This is used to recover VSP tracking +// after seed restores. +func (c *Client) ProcessManagedTickets(ctx context.Context, tickets []*chainhash.Hash) error { + for _, hash := range tickets { c.mu.Lock() _, ok := c.jobs[*hash] c.mu.Unlock() if ok { // Already processing this ticket with the VSP. - return nil + continue } - // Make TicketStatus api call and only continue if ticket is + // 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) @@ -165,7 +148,7 @@ func (c *Client) ProcessManagedTickets(ctx context.Context, policy Policy) error if errors.Is(err, errors.Locked) { return err } - return nil + continue } if status.FeeTxStatus == "confirmed" { @@ -173,7 +156,7 @@ func (c *Client) ProcessManagedTickets(ctx context.Context, policy Policy) error if err != nil { return err } - err = c.Wallet.UpdateVspTicketFeeToConfirmed(ctx, hash, feeHash, c.client.url, c.client.pub) + err = c.wallet.UpdateVspTicketFeeToConfirmed(ctx, hash, feeHash, c.Client.URL, c.Client.PubKey) if err != nil { return err } @@ -183,33 +166,31 @@ func (c *Client) ProcessManagedTickets(ctx context.Context, policy Policy) error if err != nil { return err } - err = c.Wallet.UpdateVspTicketFeeToPaid(ctx, hash, feeHash, c.client.url, c.client.pub) + err = c.wallet.UpdateVspTicketFeeToPaid(ctx, hash, feeHash, c.Client.URL, c.Client.PubKey) if err != nil { return err } - _ = c.feePayment(hash, policy, true) + _ = c.feePayment(ctx, hash, true) } else { // Fee hasn't been paid at the provided VSP, so this should do that if needed. - _ = c.feePayment(hash, policy, false) + _ = c.feePayment(ctx, hash, false) } - return nil - }) - return err + } + + return nil } // 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 *Client) Process(ctx context.Context, ticketHash *chainhash.Hash, feeTx *wire.MsgTx, - policy Policy, -) error { - vspTicket, err := c.Wallet.VSPTicketInfo(ctx, ticketHash) +// feeTx may be nil or may point to an empty transaction. It 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 *Client) 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 } @@ -222,9 +203,9 @@ func (c *Client) Process(ctx context.Context, ticketHash *chainhash.Hash, feeTx 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(ticketHash, policy, false) + fp := c.feePayment(ctx, ticketHash, false) if fp == nil { - err := c.Wallet.UpdateVspTicketFeeToErrored(ctx, ticketHash, c.client.url, c.client.pub) + err := c.wallet.UpdateVspTicketFeeToErrored(ctx, ticketHash, c.Client.URL, c.Client.PubKey) if err != nil { return err } @@ -237,7 +218,7 @@ func (c *Client) Process(ctx context.Context, ticketHash *chainhash.Hash, feeTx fp.mu.Unlock() err := fp.receiveFeeAddress() if err != nil { - err := c.Wallet.UpdateVspTicketFeeToErrored(ctx, ticketHash, c.client.url, c.client.pub) + err := c.wallet.UpdateVspTicketFeeToErrored(ctx, ticketHash, c.Client.URL, c.Client.PubKey) if err != nil { return err } @@ -248,7 +229,7 @@ func (c *Client) Process(ctx context.Context, ticketHash *chainhash.Hash, feeTx } err = fp.makeFeeTx(feeTx) if err != nil { - err := c.Wallet.UpdateVspTicketFeeToErrored(ctx, ticketHash, c.client.url, c.client.pub) + err := c.wallet.UpdateVspTicketFeeToErrored(ctx, ticketHash, c.Client.URL, c.Client.PubKey) if err != nil { return err } @@ -257,11 +238,11 @@ func (c *Client) Process(ctx context.Context, ticketHash *chainhash.Hash, feeTx 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 { + 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(ticketHash, policy, true) + fp := c.feePayment(ctx, ticketHash, true) if fp == nil { // Don't update VSPStatus to Errored if it was already paid or // confirmed. @@ -281,7 +262,7 @@ func (c *Client) Process(ctx context.Context, ticketHash *chainhash.Hash, feeTx // the connected VSP. The status provides the current voting preferences so we // can just update from there if need be. func (c *Client) SetVoteChoice(ctx context.Context, hash *chainhash.Hash, - choices []wallet.AgendaChoice, tspendPolicy map[string]string, treasuryPolicy map[string]string, + 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) @@ -298,20 +279,18 @@ func (c *Client) SetVoteChoice(ctx context.Context, hash *chainhash.Hash, update := false // Check consensus vote choices. - for _, newChoice := range choices { - vspChoice, ok := status.VoteChoices[newChoice.AgendaID] + for newAgenda, newChoice := range choices { + vspChoice, ok := status.VoteChoices[newAgenda] if !ok { update = true break } - if vspChoice != newChoice.ChoiceID { + if vspChoice != newChoice { update = true break } } - // Apply the above changes to the two checks below. - // Check tspend policies. for newTSpend, newChoice := range tspendPolicy { vspChoice, ok := status.TSpendPolicy[newTSpend] @@ -356,7 +335,7 @@ type TicketInfo struct { TicketHash chainhash.Hash CommitmentAddr stdaddr.StakeAddress VotingAddr stdaddr.StakeAddress - State uint32 + State State Fee dcrutil.Amount FeeHash chainhash.Hash @@ -377,14 +356,14 @@ func (c *Client) TrackedTickets() []*TicketInfo { } c.mu.Unlock() - tickets := make([]*TicketInfo, 0, len(c.jobs)) + 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: uint32(job.state), + State: job.state, Fee: job.fee, FeeHash: job.feeHash, }) From 5f2fb018857e6f35abadef0c40eeed5e847c9445 Mon Sep 17 00:00:00 2001 From: dmigwi Date: Fri, 18 Aug 2023 12:04:59 +0300 Subject: [PATCH 03/11] upgrade dcrwallet to v4 fixing ticket buying bug --- go.mod | 6 +- go.sum | 4 +- libwallet/assets/btc/accounts.go | 2 +- libwallet/assets/btc/address.go | 2 +- libwallet/assets/btc/feerate.go | 2 +- libwallet/assets/btc/rescan.go | 2 +- libwallet/assets/btc/sync.go | 2 +- .../assets/btc/txandblocknotifications.go | 2 +- libwallet/assets/btc/txauthor.go | 2 +- libwallet/assets/btc/wallet.go | 2 +- libwallet/assets/dcr/account_mixer.go | 6 +- libwallet/assets/dcr/accounts.go | 4 +- libwallet/assets/dcr/address.go | 4 +- libwallet/assets/dcr/consensus.go | 32 +- libwallet/assets/dcr/decodetx.go | 2 +- libwallet/assets/dcr/message.go | 4 +- libwallet/assets/dcr/rescan.go | 4 +- libwallet/assets/dcr/sync.go | 8 +- libwallet/assets/dcr/syncnotification.go | 2 +- libwallet/assets/dcr/ticket.go | 48 +- libwallet/assets/dcr/treasury.go | 2 +- .../assets/dcr/txandblocknotifications.go | 2 +- libwallet/assets/dcr/txauthor.go | 10 +- libwallet/assets/dcr/txindex.go | 2 +- libwallet/assets/dcr/txparser.go | 2 +- libwallet/assets/dcr/types.go | 2 +- libwallet/assets/dcr/vsp.go | 2 +- libwallet/assets/dcr/wallet.go | 6 +- libwallet/assets/ltc/accounts.go | 2 +- libwallet/assets/ltc/address.go | 2 +- libwallet/assets/ltc/feerate.go | 2 +- libwallet/assets/ltc/rescan.go | 2 +- libwallet/assets/ltc/sync.go | 2 +- .../assets/ltc/txandblocknotifications.go | 2 +- libwallet/assets/ltc/txauthor.go | 2 +- libwallet/assets/ltc/utils.go | 2 +- libwallet/assets/ltc/wallet.go | 2 +- libwallet/assets/wallet/wallet_shared.go | 4 +- libwallet/assets/wallet/wallet_utils.go | 4 +- libwallet/assets/wallet/walletdata/save.go | 2 +- libwallet/assets_config.go | 2 +- libwallet/assets_manager.go | 2 +- libwallet/badgerdb/bucket.go | 2 +- libwallet/badgerdb/db.go | 4 +- libwallet/badgerdb/driver.go | 4 +- libwallet/btc.go | 2 +- libwallet/dcr.go | 4 +- libwallet/instantswap.go | 2 +- libwallet/instantswap/instantswap.go | 2 +- libwallet/instantswap/sync.go | 2 +- libwallet/internal/loader/config.go | 4 +- libwallet/internal/loader/dcr/loader.go | 16 +- libwallet/internal/politeia/errors.go | 2 +- libwallet/internal/politeia/politeia.go | 2 +- libwallet/internal/politeia/politeia_sync.go | 4 +- libwallet/internal/vsp/client.go | 105 - libwallet/internal/vsp/feepayment.go | 1852 ++++++++--------- libwallet/internal/vsp/feepayments.go | 838 -------- libwallet/internal/vsp/vsp.go | 6 +- libwallet/log.go | 2 +- libwallet/ltc.go | 2 +- libwallet/txhelper/helper.go | 2 +- libwallet/utils/errors.go | 2 +- log.go | 10 +- ui/page/staking/stake_overview.go | 3 +- 65 files changed, 973 insertions(+), 2099 deletions(-) delete mode 100644 libwallet/internal/vsp/client.go delete mode 100644 libwallet/internal/vsp/feepayments.go diff --git a/go.mod b/go.mod index 524f6b5a6..7c48f978d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/crypto-power/cryptopower go 1.19 require ( - decred.org/dcrwallet/v3 v3.0.1 + decred.org/dcrwallet/v4 v4.0.0-20230809150859-a87fa843495e gioui.org v0.1.0 github.com/JohannesKaufmann/html-to-markdown v1.2.1 github.com/PuerkitoBio/goquery v1.6.1 @@ -37,6 +37,8 @@ require ( github.com/decred/dcrdata/v8 v8.0.0-20230617164141-fa4d8e1b4e8e github.com/decred/politeia v1.4.0 github.com/decred/slog v1.2.0 + github.com/decred/vspd/client/v2 v2.0.0 + github.com/decred/vspd/types/v2 v2.0.0 github.com/dgraph-io/badger v1.6.2 github.com/gen2brain/beeep v0.0.0-20220402123239-6a3042f4b71a github.com/gomarkdown/markdown v0.0.0-20220817224203-2206187d3406 @@ -100,8 +102,6 @@ require ( github.com/decred/dcrd/txscript/v3 v3.0.0 // indirect github.com/decred/dcrtime v0.0.0-20191018193024-8d8b4ef0458e // indirect github.com/decred/go-socks v1.1.0 // indirect - github.com/decred/vspd/client/v2 v2.0.0 // indirect - github.com/decred/vspd/types/v2 v2.0.0 // indirect github.com/dgraph-io/ristretto v0.0.2 // indirect github.com/dustin/go-humanize v1.0.1-0.20210705192016-249ff6c91207 // indirect github.com/fogleman/gg v1.3.0 // indirect diff --git a/go.sum b/go.sum index 81193a8e3..af5092e88 100644 --- a/go.sum +++ b/go.sum @@ -79,8 +79,8 @@ decred.org/cspp/v2 v2.1.0 h1:HeHb9+BFqrBaAPc6CsPiUpPFmC1uyBM2mJZUAbUXkRw= decred.org/cspp/v2 v2.1.0/go.mod h1:9nO3bfvCheOPIFZw5f6sRQ42CjBFB5RKSaJ9Iq6G4MA= decred.org/dcrwallet v1.7.0 h1:U/ew00YBdUlx3rJAynt2OdKDgGzBKK4O89FijBq8iVg= decred.org/dcrwallet v1.7.0/go.mod h1:hNOGyvH53gWdgFB601/ubGRzCPfPtWnEVAi9Grs90y4= -decred.org/dcrwallet/v3 v3.0.1 h1:+OLi+u/MvKc3Ubcnf19oyG/a5hJ/qp4OtezdiQZnLIs= -decred.org/dcrwallet/v3 v3.0.1/go.mod h1:a+R8BZIOKVpWVPat5VZoBWNh/cnIciwcRkPtrzfS/tw= +decred.org/dcrwallet/v4 v4.0.0-20230809150859-a87fa843495e h1:Y1f5xYRQi7qMzLYifvAOplMhgHiKxtWWzd1psXqsS6A= +decred.org/dcrwallet/v4 v4.0.0-20230809150859-a87fa843495e/go.mod h1:0+CchVf/baDYJ0tlDjtEzAFsvYcwWRm0eo1+Lf7Z1as= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= gioui.org v0.1.0 h1:fEDY5A4+epOdzjCBYSUC4BzvjWqsjfqf5D6mskbthOs= diff --git a/libwallet/assets/btc/accounts.go b/libwallet/assets/btc/accounts.go index 6e653e1eb..842e156e9 100644 --- a/libwallet/assets/btc/accounts.go +++ b/libwallet/assets/btc/accounts.go @@ -7,7 +7,7 @@ import ( "strconv" "time" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcwallet/waddrmgr" diff --git a/libwallet/assets/btc/address.go b/libwallet/assets/btc/address.go index 9edf2c127..28b5e6372 100644 --- a/libwallet/assets/btc/address.go +++ b/libwallet/assets/btc/address.go @@ -3,7 +3,7 @@ package btc import ( "fmt" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/btcsuite/btcd/btcutil" "github.com/crypto-power/cryptopower/libwallet/utils" ) diff --git a/libwallet/assets/btc/feerate.go b/libwallet/assets/btc/feerate.go index bd41d428a..609a26f3d 100644 --- a/libwallet/assets/btc/feerate.go +++ b/libwallet/assets/btc/feerate.go @@ -7,7 +7,7 @@ import ( "strconv" "sync" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/btcsuite/btcd/btcutil" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" diff --git a/libwallet/assets/btc/rescan.go b/libwallet/assets/btc/rescan.go index 9843805fa..4d3608d02 100644 --- a/libwallet/assets/btc/rescan.go +++ b/libwallet/assets/btc/rescan.go @@ -6,7 +6,7 @@ import ( "sync/atomic" "time" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcwallet/chain" "github.com/btcsuite/btcwallet/waddrmgr" diff --git a/libwallet/assets/btc/sync.go b/libwallet/assets/btc/sync.go index f1be049e3..23a5f5e3d 100644 --- a/libwallet/assets/btc/sync.go +++ b/libwallet/assets/btc/sync.go @@ -7,7 +7,7 @@ import ( "sync/atomic" "time" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/btcsuite/btcwallet/chain" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" diff --git a/libwallet/assets/btc/txandblocknotifications.go b/libwallet/assets/btc/txandblocknotifications.go index 8974090ca..de0ad3826 100644 --- a/libwallet/assets/btc/txandblocknotifications.go +++ b/libwallet/assets/btc/txandblocknotifications.go @@ -4,7 +4,7 @@ import ( "encoding/json" "sync/atomic" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" ) diff --git a/libwallet/assets/btc/txauthor.go b/libwallet/assets/btc/txauthor.go index 0d3941ca6..62b021755 100644 --- a/libwallet/assets/btc/txauthor.go +++ b/libwallet/assets/btc/txauthor.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" diff --git a/libwallet/assets/btc/wallet.go b/libwallet/assets/btc/wallet.go index 90ecee53c..affa0430b 100644 --- a/libwallet/assets/btc/wallet.go +++ b/libwallet/assets/btc/wallet.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/btcsuite/btcd/btcec/v2/ecdsa" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/gcs" diff --git a/libwallet/assets/dcr/account_mixer.go b/libwallet/assets/dcr/account_mixer.go index c2d9c561a..6024ff268 100644 --- a/libwallet/assets/dcr/account_mixer.go +++ b/libwallet/assets/dcr/account_mixer.go @@ -7,9 +7,9 @@ import ( "errors" "net" - "decred.org/dcrwallet/v3/ticketbuyer" - w "decred.org/dcrwallet/v3/wallet" - "decred.org/dcrwallet/v3/wallet/udb" + "decred.org/dcrwallet/v4/ticketbuyer" + w "decred.org/dcrwallet/v4/wallet" + "decred.org/dcrwallet/v4/wallet/udb" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/internal/certs" "github.com/crypto-power/cryptopower/libwallet/utils" diff --git a/libwallet/assets/dcr/accounts.go b/libwallet/assets/dcr/accounts.go index 99d9b2c83..d631f45fc 100644 --- a/libwallet/assets/dcr/accounts.go +++ b/libwallet/assets/dcr/accounts.go @@ -6,8 +6,8 @@ import ( "fmt" "strconv" - "decred.org/dcrwallet/v3/errors" - w "decred.org/dcrwallet/v3/wallet" + "decred.org/dcrwallet/v4/errors" + w "decred.org/dcrwallet/v4/wallet" "github.com/crypto-power/cryptopower/libwallet/addresshelper" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" diff --git a/libwallet/assets/dcr/address.go b/libwallet/assets/dcr/address.go index d041cdeaf..b6f72da27 100644 --- a/libwallet/assets/dcr/address.go +++ b/libwallet/assets/dcr/address.go @@ -3,8 +3,8 @@ package dcr import ( "fmt" - "decred.org/dcrwallet/v3/errors" - w "decred.org/dcrwallet/v3/wallet" + "decred.org/dcrwallet/v4/errors" + w "decred.org/dcrwallet/v4/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" "github.com/decred/dcrd/txscript/v4/stdaddr" ) diff --git a/libwallet/assets/dcr/consensus.go b/libwallet/assets/dcr/consensus.go index 04ed1e486..51b8ef370 100644 --- a/libwallet/assets/dcr/consensus.go +++ b/libwallet/assets/dcr/consensus.go @@ -6,8 +6,7 @@ import ( "sort" "strings" - "decred.org/dcrwallet/v3/errors" - w "decred.org/dcrwallet/v3/wallet" + "decred.org/dcrwallet/v4/errors" "github.com/crypto-power/cryptopower/libwallet/utils" "github.com/decred/dcrd/chaincfg/chainhash" @@ -111,22 +110,19 @@ func (asset *Asset) SetVoteChoice(agendaID, choiceID, hash, passphrase string) e return err } - currentChoice := w.AgendaChoice{ - AgendaID: agendaID, - ChoiceID: "abstain", // default to abstain as current choice if not found in wallet + choice, ok := choices[agendaID] + if ok && choice == strings.ToLower(choiceID) { + // Do not set the same choice again + return nil } - for i := range choices { - if choices[i].AgendaID == agendaID { - currentChoice.ChoiceID = choices[i].ChoiceID - break - } + if !ok { + // Default to abstain if no previous choice existed + choice = "abstain" } - newChoice := w.AgendaChoice{ - AgendaID: agendaID, - ChoiceID: strings.ToLower(choiceID), - } + currentChoice := map[string]string{agendaID: choice} + newChoice := map[string]string{agendaID: strings.ToLower(choiceID)} _, err = asset.Internal().DCR.SetAgendaChoices(ctx, ticketHash, newChoice) if err != nil { @@ -181,7 +177,7 @@ func (asset *Asset) SetVoteChoice(agendaID, choiceID, hash, passphrase string) e firstErr = err continue // try next tHash } - err = vspClient.SetVoteChoice(ctx, tHash, []w.AgendaChoice{newChoice}, nil, nil) + err = vspClient.SetVoteChoice(ctx, tHash, newChoice, nil, nil) if err != nil && firstErr == nil { firstErr = err continue // try next tHash @@ -245,9 +241,9 @@ func (asset *Asset) AllVoteAgendas(hash string, newestFirst bool) ([]*Agenda, er d := &deployments[i] votingPreference := "abstain" // assume abstain, if we have the saved pref, it'll be updated below - for c := range choices { - if choices[c].AgendaID == d.Vote.Id { - votingPreference = choices[c].ChoiceID + for agendaID, choiceID := range choices { + if agendaID == d.Vote.Id { + votingPreference = choiceID break } } diff --git a/libwallet/assets/dcr/decodetx.go b/libwallet/assets/dcr/decodetx.go index a679d07b7..0fd4ab3f3 100644 --- a/libwallet/assets/dcr/decodetx.go +++ b/libwallet/assets/dcr/decodetx.go @@ -3,7 +3,7 @@ package dcr import ( "fmt" - w "decred.org/dcrwallet/v3/wallet" + w "decred.org/dcrwallet/v4/wallet" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/txhelper" "github.com/decred/dcrd/blockchain/stake/v5" diff --git a/libwallet/assets/dcr/message.go b/libwallet/assets/dcr/message.go index f60cd11f9..438017554 100644 --- a/libwallet/assets/dcr/message.go +++ b/libwallet/assets/dcr/message.go @@ -1,8 +1,8 @@ package dcr import ( - "decred.org/dcrwallet/v3/errors" - w "decred.org/dcrwallet/v3/wallet" + "decred.org/dcrwallet/v4/errors" + w "decred.org/dcrwallet/v4/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" "github.com/decred/dcrd/txscript/v4/stdaddr" ) diff --git a/libwallet/assets/dcr/rescan.go b/libwallet/assets/dcr/rescan.go index 0b99549d3..768d1c022 100644 --- a/libwallet/assets/dcr/rescan.go +++ b/libwallet/assets/dcr/rescan.go @@ -5,8 +5,8 @@ import ( "math" "time" - "decred.org/dcrwallet/v3/errors" - w "decred.org/dcrwallet/v3/wallet" + "decred.org/dcrwallet/v4/errors" + w "decred.org/dcrwallet/v4/wallet" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" ) diff --git a/libwallet/assets/dcr/sync.go b/libwallet/assets/dcr/sync.go index 8be6e7e6c..c672be79d 100644 --- a/libwallet/assets/dcr/sync.go +++ b/libwallet/assets/dcr/sync.go @@ -9,10 +9,10 @@ import ( "strings" "sync" - "decred.org/dcrwallet/v3/errors" - "decred.org/dcrwallet/v3/p2p" - "decred.org/dcrwallet/v3/spv" - w "decred.org/dcrwallet/v3/wallet" + "decred.org/dcrwallet/v4/errors" + "decred.org/dcrwallet/v4/p2p" + "decred.org/dcrwallet/v4/spv" + w "decred.org/dcrwallet/v4/wallet" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" "github.com/decred/dcrd/addrmgr/v2" diff --git a/libwallet/assets/dcr/syncnotification.go b/libwallet/assets/dcr/syncnotification.go index 8825126ed..0d898403f 100644 --- a/libwallet/assets/dcr/syncnotification.go +++ b/libwallet/assets/dcr/syncnotification.go @@ -4,7 +4,7 @@ import ( "math" "time" - "decred.org/dcrwallet/v3/spv" + "decred.org/dcrwallet/v4/spv" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "golang.org/x/sync/errgroup" ) diff --git a/libwallet/assets/dcr/ticket.go b/libwallet/assets/dcr/ticket.go index 6fe988433..3a97a6bc5 100644 --- a/libwallet/assets/dcr/ticket.go +++ b/libwallet/assets/dcr/ticket.go @@ -7,14 +7,15 @@ import ( "sync" "time" - "decred.org/dcrwallet/v3/errors" - w "decred.org/dcrwallet/v3/wallet" + "decred.org/dcrwallet/v4/errors" + w "decred.org/dcrwallet/v4/wallet" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" - "github.com/crypto-power/cryptopower/libwallet/internal/vsp" "github.com/crypto-power/cryptopower/libwallet/utils" + "github.com/decred/dcrd/blockchain/stake/v5" "github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/dcrutil/v4" "github.com/decred/dcrd/wire" + "github.com/decred/vspd/types/v2" ) func (asset *Asset) TotalStakingRewards() (int64, error) { @@ -127,9 +128,9 @@ func (asset *Asset) PurchaseTickets(account, numTickets int32, vspHost, passphra Count: int(numTickets), SourceAccount: uint32(account), MinConf: asset.RequiredConfirmations(), - VSPFeeProcess: vspClient.FeePercentage, + VSPFeePercent: vspClient.FeePercentage, VSPFeePaymentProcess: func(ctx context.Context, ticketHash *chainhash.Hash, feeTx *wire.MsgTx) error { - return vspClient.Process(ctx, ticketHash, feeTx, asset.GetvspPolicy(account)) + return vspClient.Process(ctx, ticketHash, feeTx) }, } @@ -152,17 +153,6 @@ func (asset *Asset) PurchaseTickets(account, numTickets int32, vspHost, passphra return ticketsResponse.TicketHashes, err } -// GetvspPolicy creates the VSP policy using the account number provided. -// Uses the user-specified instructions for processing fee payments -// on a ticket, rather than some default policy. -func (asset *Asset) GetvspPolicy(account int32) vsp.Policy { - return vsp.Policy{ - MaxFee: 0.2e8, - FeeAcct: uint32(account), - ChangeAcct: uint32(account), - } -} - // VSPTicketInfo returns vsp-related info for a given ticket. Returns an error // if the ticket is not yet assigned to a VSP. func (asset *Asset) VSPTicketInfo(hash string) (*VSPTicketInfo, error) { @@ -204,7 +194,22 @@ func (asset *Asset) VSPTicketInfo(hash string) (*VSPTicketInfo, error) { ticketInfo.Client = vspClient - vspTicketStatus, err := vspClient.GetTicketStatus(ctx, ticketHash) + txs, _, err := asset.Internal().DCR.GetTransactionsByHashes(ctx, []*chainhash.Hash{ticketHash}) + if err != nil { + return nil, err + } + + commitmentAddr, err := stake.AddrFromSStxPkScrCommitment(txs[0].TxOut[1].PkScript, asset.chainParams) + if err != nil { + return nil, fmt.Errorf("failed to extract commitment address from %v: %w", + ticketHash, err) + } + + req := types.TicketStatusRequest{ + TicketHash: ticketHash.String(), + } + + vspTicketStatus, err := vspClient.TicketStatus(ctx, req, commitmentAddr) if err != nil { log.Warnf("unable to get vsp ticket: %s Error: %v", hash, err) return ticketInfo, nil @@ -452,19 +457,14 @@ func (asset *Asset) buyTicket(ctx context.Context, passphrase string, sdiff dcru // Count is 1 to prevent combining multiple split outputs in one tx, // which can be used to link the tickets eventually purchased with the // split outputs. - vspPolicy := vsp.Policy{ - MaxFee: 0.2e8, - FeeAcct: uint32(cfg.PurchaseAccount), - ChangeAcct: uint32(cfg.PurchaseAccount), - } request := &w.PurchaseTicketsRequest{ Count: 1, SourceAccount: uint32(cfg.PurchaseAccount), Expiry: expiry, MinConf: asset.RequiredConfirmations(), - VSPFeeProcess: cfg.VspClient.FeePercentage, + VSPFeePercent: cfg.VspClient.FeePercentage, VSPFeePaymentProcess: func(ctx context.Context, ticketHash *chainhash.Hash, feeTx *wire.MsgTx) error { - return cfg.VspClient.Process(ctx, ticketHash, feeTx, vspPolicy) + return cfg.VspClient.Process(ctx, ticketHash, feeTx) }, } // Mixed split buying through CoinShuffle++, if configured. diff --git a/libwallet/assets/dcr/treasury.go b/libwallet/assets/dcr/treasury.go index 2d68ac5f0..35481d8d3 100644 --- a/libwallet/assets/dcr/treasury.go +++ b/libwallet/assets/dcr/treasury.go @@ -4,7 +4,7 @@ import ( "encoding/hex" "fmt" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/crypto-power/cryptopower/libwallet/utils" "github.com/decred/dcrd/blockchain/stake/v5" diff --git a/libwallet/assets/dcr/txandblocknotifications.go b/libwallet/assets/dcr/txandblocknotifications.go index f5e046033..1b9b7246b 100644 --- a/libwallet/assets/dcr/txandblocknotifications.go +++ b/libwallet/assets/dcr/txandblocknotifications.go @@ -3,7 +3,7 @@ package dcr import ( "encoding/json" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" ) diff --git a/libwallet/assets/dcr/txauthor.go b/libwallet/assets/dcr/txauthor.go index ffe2566ba..5f7c545cc 100644 --- a/libwallet/assets/dcr/txauthor.go +++ b/libwallet/assets/dcr/txauthor.go @@ -7,11 +7,11 @@ import ( "fmt" "time" - "decred.org/dcrwallet/v3/errors" - w "decred.org/dcrwallet/v3/wallet" - "decred.org/dcrwallet/v3/wallet/txauthor" - "decred.org/dcrwallet/v3/wallet/txrules" - "decred.org/dcrwallet/v3/wallet/txsizes" + "decred.org/dcrwallet/v4/errors" + w "decred.org/dcrwallet/v4/wallet" + "decred.org/dcrwallet/v4/wallet/txauthor" + "decred.org/dcrwallet/v4/wallet/txrules" + "decred.org/dcrwallet/v4/wallet/txsizes" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/txhelper" "github.com/crypto-power/cryptopower/libwallet/utils" diff --git a/libwallet/assets/dcr/txindex.go b/libwallet/assets/dcr/txindex.go index ad8e98bfc..5bfb16222 100644 --- a/libwallet/assets/dcr/txindex.go +++ b/libwallet/assets/dcr/txindex.go @@ -1,7 +1,7 @@ package dcr import ( - w "decred.org/dcrwallet/v3/wallet" + w "decred.org/dcrwallet/v4/wallet" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" "github.com/decred/dcrd/chaincfg/chainhash" diff --git a/libwallet/assets/dcr/txparser.go b/libwallet/assets/dcr/txparser.go index 0c0541cb0..fe8ec6137 100644 --- a/libwallet/assets/dcr/txparser.go +++ b/libwallet/assets/dcr/txparser.go @@ -3,7 +3,7 @@ package dcr import ( "fmt" - w "decred.org/dcrwallet/v3/wallet" + w "decred.org/dcrwallet/v4/wallet" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/decred/dcrd/chaincfg/chainhash" ) diff --git a/libwallet/assets/dcr/types.go b/libwallet/assets/dcr/types.go index 8519c43d3..0a051a6ec 100644 --- a/libwallet/assets/dcr/types.go +++ b/libwallet/assets/dcr/types.go @@ -5,7 +5,7 @@ import ( "fmt" "net" - "decred.org/dcrwallet/v3/wallet/udb" + "decred.org/dcrwallet/v4/wallet/udb" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/internal/vsp" "github.com/decred/dcrd/chaincfg/v3" diff --git a/libwallet/assets/dcr/vsp.go b/libwallet/assets/dcr/vsp.go index ca3a3c7a7..ab225f126 100644 --- a/libwallet/assets/dcr/vsp.go +++ b/libwallet/assets/dcr/vsp.go @@ -9,7 +9,7 @@ import ( "net/http" "strings" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/internal/vsp" "github.com/crypto-power/cryptopower/libwallet/utils" diff --git a/libwallet/assets/dcr/wallet.go b/libwallet/assets/dcr/wallet.go index e4991b56b..0a1687a3b 100644 --- a/libwallet/assets/dcr/wallet.go +++ b/libwallet/assets/dcr/wallet.go @@ -5,8 +5,8 @@ import ( "path/filepath" "sync" - dcrW "decred.org/dcrwallet/v3/wallet" - "decred.org/dcrwallet/v3/wallet/txrules" + dcrW "decred.org/dcrwallet/v4/wallet" + "decred.org/dcrwallet/v4/wallet/txrules" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/internal/loader" "github.com/crypto-power/cryptopower/libwallet/internal/loader/dcr" @@ -60,7 +60,6 @@ func initWalletLoader(chainParams *chaincfg.Params, rootdir, walletDbDriver stri stakeOptions := &dcr.StakeOptions{ VotingEnabled: false, - AddressReuse: false, VotingAddress: nil, } @@ -82,6 +81,7 @@ func initWalletLoader(chainParams *chaincfg.Params, rootdir, walletDbDriver stri ManualTickets: cfg.ManualTickets, AccountGapLimit: cfg.AccountGapLimit, MixSplitLimit: cfg.MixSplitLimit, + WatchLast: 20, // Limit number of watched addresses to 20. } walletLoader := dcr.NewLoader(loaderCfg) diff --git a/libwallet/assets/ltc/accounts.go b/libwallet/assets/ltc/accounts.go index 2f0f55e59..fbfac536d 100644 --- a/libwallet/assets/ltc/accounts.go +++ b/libwallet/assets/ltc/accounts.go @@ -7,7 +7,7 @@ import ( "strconv" "time" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" "github.com/ltcsuite/ltcd/chaincfg" diff --git a/libwallet/assets/ltc/address.go b/libwallet/assets/ltc/address.go index b18003f7d..f2263fa28 100644 --- a/libwallet/assets/ltc/address.go +++ b/libwallet/assets/ltc/address.go @@ -3,7 +3,7 @@ package ltc import ( "fmt" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/crypto-power/cryptopower/libwallet/utils" "github.com/ltcsuite/ltcd/ltcutil" ) diff --git a/libwallet/assets/ltc/feerate.go b/libwallet/assets/ltc/feerate.go index 48964965a..359283350 100644 --- a/libwallet/assets/ltc/feerate.go +++ b/libwallet/assets/ltc/feerate.go @@ -7,7 +7,7 @@ import ( "strconv" "sync" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" "github.com/ltcsuite/ltcd/ltcutil" diff --git a/libwallet/assets/ltc/rescan.go b/libwallet/assets/ltc/rescan.go index 6b978c742..8c10773a8 100644 --- a/libwallet/assets/ltc/rescan.go +++ b/libwallet/assets/ltc/rescan.go @@ -6,7 +6,7 @@ import ( "sync/atomic" "time" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" "github.com/ltcsuite/ltcd/ltcutil" diff --git a/libwallet/assets/ltc/sync.go b/libwallet/assets/ltc/sync.go index 31050c349..d36127185 100644 --- a/libwallet/assets/ltc/sync.go +++ b/libwallet/assets/ltc/sync.go @@ -7,7 +7,7 @@ import ( "sync/atomic" "time" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" "github.com/ltcsuite/ltcd/chaincfg" diff --git a/libwallet/assets/ltc/txandblocknotifications.go b/libwallet/assets/ltc/txandblocknotifications.go index f88397e6e..43a2c9fc7 100644 --- a/libwallet/assets/ltc/txandblocknotifications.go +++ b/libwallet/assets/ltc/txandblocknotifications.go @@ -4,7 +4,7 @@ import ( "encoding/json" "sync/atomic" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" ) diff --git a/libwallet/assets/ltc/txauthor.go b/libwallet/assets/ltc/txauthor.go index 52242dd29..ff8d0da05 100644 --- a/libwallet/assets/ltc/txauthor.go +++ b/libwallet/assets/ltc/txauthor.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/txhelper" "github.com/crypto-power/cryptopower/libwallet/utils" diff --git a/libwallet/assets/ltc/utils.go b/libwallet/assets/ltc/utils.go index 28bce33d0..ea51faaa8 100644 --- a/libwallet/assets/ltc/utils.go +++ b/libwallet/assets/ltc/utils.go @@ -3,7 +3,7 @@ package ltc import ( "encoding/binary" - "decred.org/dcrwallet/v3/walletseed" + "decred.org/dcrwallet/v4/walletseed" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" "github.com/ltcsuite/ltcd/chaincfg" diff --git a/libwallet/assets/ltc/wallet.go b/libwallet/assets/ltc/wallet.go index 84a269645..f34ddedf5 100644 --- a/libwallet/assets/ltc/wallet.go +++ b/libwallet/assets/ltc/wallet.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/internal/loader" "github.com/crypto-power/cryptopower/libwallet/internal/loader/ltc" diff --git a/libwallet/assets/wallet/wallet_shared.go b/libwallet/assets/wallet/wallet_shared.go index 42691fbb3..6bc55451b 100644 --- a/libwallet/assets/wallet/wallet_shared.go +++ b/libwallet/assets/wallet/wallet_shared.go @@ -9,8 +9,8 @@ import ( "sync" "time" - "decred.org/dcrwallet/v3/errors" - w "decred.org/dcrwallet/v3/wallet" + "decred.org/dcrwallet/v4/errors" + w "decred.org/dcrwallet/v4/wallet" "github.com/asdine/storm" "github.com/crypto-power/cryptopower/libwallet/assets/wallet/walletdata" "github.com/crypto-power/cryptopower/libwallet/internal/loader" diff --git a/libwallet/assets/wallet/wallet_utils.go b/libwallet/assets/wallet/wallet_utils.go index 5071cbfc7..3d510c514 100644 --- a/libwallet/assets/wallet/wallet_utils.go +++ b/libwallet/assets/wallet/wallet_utils.go @@ -6,8 +6,8 @@ import ( "os" "strconv" - "decred.org/dcrwallet/v3/errors" - "decred.org/dcrwallet/v3/walletseed" + "decred.org/dcrwallet/v4/errors" + "decred.org/dcrwallet/v4/walletseed" "github.com/asdine/storm" btchdkeychain "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/crypto-power/cryptopower/libwallet/utils" diff --git a/libwallet/assets/wallet/walletdata/save.go b/libwallet/assets/wallet/walletdata/save.go index 37de75622..3f673c869 100644 --- a/libwallet/assets/wallet/walletdata/save.go +++ b/libwallet/assets/wallet/walletdata/save.go @@ -4,7 +4,7 @@ import ( "fmt" "reflect" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/asdine/storm" ) diff --git a/libwallet/assets_config.go b/libwallet/assets_config.go index 1133bb59e..5271f4f93 100644 --- a/libwallet/assets_config.go +++ b/libwallet/assets_config.go @@ -3,7 +3,7 @@ package libwallet import ( "fmt" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/asdine/storm" "github.com/crypto-power/cryptopower/libwallet/utils" diff --git a/libwallet/assets_manager.go b/libwallet/assets_manager.go index b4645c82d..876ffccad 100644 --- a/libwallet/assets_manager.go +++ b/libwallet/assets_manager.go @@ -7,7 +7,7 @@ import ( "path/filepath" "strconv" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/asdine/storm" "github.com/asdine/storm/q" "github.com/crypto-power/cryptopower/libwallet/ext" diff --git a/libwallet/badgerdb/bucket.go b/libwallet/badgerdb/bucket.go index bfa71af52..19767c634 100644 --- a/libwallet/badgerdb/bucket.go +++ b/libwallet/badgerdb/bucket.go @@ -3,7 +3,7 @@ package badgerdb import ( "bytes" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/dgraph-io/badger" ) diff --git a/libwallet/badgerdb/db.go b/libwallet/badgerdb/db.go index 1bf604884..41e1dc442 100644 --- a/libwallet/badgerdb/db.go +++ b/libwallet/badgerdb/db.go @@ -10,8 +10,8 @@ import ( "io" "os" - "decred.org/dcrwallet/v3/errors" - "decred.org/dcrwallet/v3/wallet/walletdb" + "decred.org/dcrwallet/v4/errors" + "decred.org/dcrwallet/v4/wallet/walletdb" "github.com/dgraph-io/badger" "github.com/dgraph-io/badger/options" ) diff --git a/libwallet/badgerdb/driver.go b/libwallet/badgerdb/driver.go index 5a2ed3eed..b693e008c 100644 --- a/libwallet/badgerdb/driver.go +++ b/libwallet/badgerdb/driver.go @@ -8,8 +8,8 @@ package badgerdb import ( "fmt" - "decred.org/dcrwallet/v3/errors" - "decred.org/dcrwallet/v3/wallet/walletdb" + "decred.org/dcrwallet/v4/errors" + "decred.org/dcrwallet/v4/wallet/walletdb" ) const ( diff --git a/libwallet/btc.go b/libwallet/btc.go index 567cce705..fc821423d 100644 --- a/libwallet/btc.go +++ b/libwallet/btc.go @@ -3,7 +3,7 @@ package libwallet import ( "fmt" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcwallet/waddrmgr" diff --git a/libwallet/dcr.go b/libwallet/dcr.go index d646463f8..b988360de 100644 --- a/libwallet/dcr.go +++ b/libwallet/dcr.go @@ -3,8 +3,8 @@ package libwallet import ( "context" - "decred.org/dcrwallet/v3/errors" - "decred.org/dcrwallet/v3/walletseed" + "decred.org/dcrwallet/v4/errors" + "decred.org/dcrwallet/v4/walletseed" "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/hdkeychain/v3" diff --git a/libwallet/instantswap.go b/libwallet/instantswap.go index b5dee2b2f..0d94a0b40 100644 --- a/libwallet/instantswap.go +++ b/libwallet/instantswap.go @@ -5,7 +5,7 @@ import ( "math" "time" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" api "github.com/crypto-power/instantswap/instantswap" "github.com/crypto-power/cryptopower/libwallet/assets/btc" diff --git a/libwallet/instantswap/instantswap.go b/libwallet/instantswap/instantswap.go index fccf2fc80..074e61b4e 100644 --- a/libwallet/instantswap/instantswap.go +++ b/libwallet/instantswap/instantswap.go @@ -5,7 +5,7 @@ import ( "sync" "time" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/asdine/storm" "github.com/asdine/storm/q" "github.com/crypto-power/instantswap/instantswap" diff --git a/libwallet/instantswap/sync.go b/libwallet/instantswap/sync.go index b0958335d..52c43198f 100644 --- a/libwallet/instantswap/sync.go +++ b/libwallet/instantswap/sync.go @@ -4,7 +4,7 @@ import ( "context" "time" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/asdine/storm" "github.com/crypto-power/instantswap/instantswap" ) diff --git a/libwallet/internal/loader/config.go b/libwallet/internal/loader/config.go index 84f0263f8..07548a8af 100644 --- a/libwallet/internal/loader/config.go +++ b/libwallet/internal/loader/config.go @@ -5,8 +5,8 @@ import ( "os" "path/filepath" - "decred.org/dcrwallet/v3/errors" - dcrW "decred.org/dcrwallet/v3/wallet" + "decred.org/dcrwallet/v4/errors" + dcrW "decred.org/dcrwallet/v4/wallet" btcW "github.com/btcsuite/btcwallet/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" ltcW "github.com/ltcsuite/ltcwallet/wallet" diff --git a/libwallet/internal/loader/dcr/loader.go b/libwallet/internal/loader/dcr/loader.go index ead8ac3f1..072b996b3 100644 --- a/libwallet/internal/loader/dcr/loader.go +++ b/libwallet/internal/loader/dcr/loader.go @@ -10,15 +10,15 @@ import ( "path/filepath" "sync" - "decred.org/dcrwallet/v3/errors" - "decred.org/dcrwallet/v3/wallet" + "decred.org/dcrwallet/v4/errors" + "decred.org/dcrwallet/v4/wallet" "github.com/crypto-power/cryptopower/libwallet/internal/loader" "github.com/crypto-power/cryptopower/libwallet/utils" "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/dcrutil/v4" "github.com/decred/dcrd/txscript/v4/stdaddr" - _ "decred.org/dcrwallet/v3/wallet/drivers/bdb" // driver loaded during init + _ "decred.org/dcrwallet/v4/wallet/drivers/bdb" // driver loaded during init ) const walletDbName = "wallet.db" @@ -48,6 +48,7 @@ type dcrLoader struct { manualTickets bool relayFee dcrutil.Amount mixSplitLimit int + watchLast uint32 mu sync.RWMutex } @@ -55,7 +56,6 @@ type dcrLoader struct { // StakeOptions contains the various options necessary for stake mining. type StakeOptions struct { VotingEnabled bool - AddressReuse bool VotingAddress stdaddr.StakeAddress PoolAddress stdaddr.StakeAddress PoolFees float64 @@ -77,6 +77,7 @@ type LoaderConf struct { ManualTickets bool AccountGapLimit int MixSplitLimit int + WatchLast uint32 } // NewLoader constructs a DCR Loader. @@ -91,6 +92,7 @@ func NewLoader(cfg *LoaderConf) loader.AssetLoader { manualTickets: cfg.ManualTickets, relayFee: cfg.RelayFee, mixSplitLimit: cfg.MixSplitLimit, + watchLast: cfg.WatchLast, Loader: loader.NewLoader(cfg.DBDirPath), } @@ -157,7 +159,7 @@ func (l *dcrLoader) CreateWatchingOnlyWallet(ctx context.Context, params *loader DB: db, PubPassphrase: params.PubPassphrase, VotingEnabled: so.VotingEnabled, - AddressReuse: so.AddressReuse, + WatchLast: l.watchLast, VotingAddress: so.VotingAddress, PoolAddress: so.PoolAddress, PoolFees: so.PoolFees, @@ -215,7 +217,7 @@ func (l *dcrLoader) CreateNewWallet(ctx context.Context, params *loader.CreateWa DB: db, PubPassphrase: params.PubPassphrase, VotingEnabled: so.VotingEnabled, - AddressReuse: so.AddressReuse, + WatchLast: l.watchLast, VotingAddress: so.VotingAddress, PoolAddress: so.PoolAddress, PoolFees: so.PoolFees, @@ -280,7 +282,7 @@ func (l *dcrLoader) OpenExistingWallet(ctx context.Context, walletID string, pub DB: db, PubPassphrase: pubPassphrase, VotingEnabled: so.VotingEnabled, - AddressReuse: so.AddressReuse, + WatchLast: l.watchLast, VotingAddress: so.VotingAddress, PoolAddress: so.PoolAddress, PoolFees: so.PoolFees, diff --git a/libwallet/internal/politeia/errors.go b/libwallet/internal/politeia/errors.go index 1d18f7ae6..1bb1e6267 100644 --- a/libwallet/internal/politeia/errors.go +++ b/libwallet/internal/politeia/errors.go @@ -1,7 +1,7 @@ package politeia import ( - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/asdine/storm" ) diff --git a/libwallet/internal/politeia/politeia.go b/libwallet/internal/politeia/politeia.go index 238ecb092..1ba7bd6fd 100644 --- a/libwallet/internal/politeia/politeia.go +++ b/libwallet/internal/politeia/politeia.go @@ -6,7 +6,7 @@ import ( "fmt" "sync" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/asdine/storm" "github.com/asdine/storm/q" ) diff --git a/libwallet/internal/politeia/politeia_sync.go b/libwallet/internal/politeia/politeia_sync.go index 7c5e52344..3ff95bbb6 100644 --- a/libwallet/internal/politeia/politeia_sync.go +++ b/libwallet/internal/politeia/politeia_sync.go @@ -11,8 +11,8 @@ import ( "strconv" "time" - "decred.org/dcrwallet/v3/wallet" - "decred.org/dcrwallet/v3/wallet/udb" + "decred.org/dcrwallet/v4/wallet" + "decred.org/dcrwallet/v4/wallet/udb" "github.com/asdine/storm" "github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/txscript/v4/stdaddr" diff --git a/libwallet/internal/vsp/client.go b/libwallet/internal/vsp/client.go deleted file mode 100644 index 94a5c888b..000000000 --- a/libwallet/internal/vsp/client.go +++ /dev/null @@ -1,105 +0,0 @@ -package vsp - -// import ( -// "context" -// "crypto/ed25519" -// "encoding/base64" -// "encoding/json" -// "fmt" -// "net/http" - -// "github.com/crypto-power/cryptopower/libwallet/utils" -// "github.com/decred/dcrd/txscript/v4/stdaddr" -// ) - -// type client struct { -// http.Client -// url string -// pub []byte -// sign func(context.Context, string, stdaddr.Address) ([]byte, error) -// } - -// type signer interface { -// SignMessage(ctx context.Context, message string, address stdaddr.Address) ([]byte, error) -// } - -// func newClient(url string, pub []byte, s signer) *client { -// return &client{url: url, pub: pub, sign: s.SignMessage} -// } - -// type BadRequestError struct { -// HTTPStatus int `json:"-"` -// Code int `json:"code"` -// Message string `json:"message"` -// } - -// func (e *BadRequestError) Error() string { return e.Message } - -// func (c *client) post(ctx context.Context, path string, addr stdaddr.Address, response interface{}, body []byte) error { -// return c.do(ctx, http.MethodPost, path, addr, response, body) -// } - -// func (c *client) get(ctx context.Context, path string, resp interface{}) error { -// return c.do(ctx, http.MethodGet, path, nil, resp, nil) -// } - -// func (c *client) do(ctx context.Context, method, path string, addr stdaddr.Address, response interface{}, body []byte) error { -// var err error -// var sig []byte -// reqConf := &utils.ReqConfig{ -// Method: method, -// HTTPURL: c.url + path, -// IsRetByte: true, -// Headers: make(http.Header), -// } - -// if method == http.MethodPost { -// sig, err = c.sign(ctx, string(body), addr) -// if err != nil { -// return fmt.Errorf("sign request: %w", err) -// } -// reqConf.Payload = body -// } - -// // Add cookies. -// if sig != nil { -// reqConf.Headers.Add("VSP-Client-Signature", base64.StdEncoding.EncodeToString(sig)) -// } - -// respBytes := []byte{} -// reply, err := utils.HTTPRequest(reqConf, &respBytes) -// if err != nil && reply == nil { -// // Status code errors are handled below. -// return err -// } - -// status := reply.StatusCode -// is200 := status == 200 -// is4xx := status >= 400 && status <= 499 -// if !(is200 || is4xx) { -// return err -// } - -// if err = json.Unmarshal(respBytes, response); err != nil { -// return fmt.Errorf("could not pack response data: %w", err) -// } - -// sigBase64 := reply.Header.Get("VSP-Server-Signature") -// sig, err = base64.StdEncoding.DecodeString(sigBase64) -// if err != nil { -// return fmt.Errorf("cannot authenticate server: %w", err) -// } - -// if !ed25519.Verify(c.pub, respBytes, sig) { -// return fmt.Errorf("cannot authenticate server: invalid signature") -// } - -// var apiError *BadRequestError -// if is4xx { -// apiError = new(BadRequestError) -// apiError.HTTPStatus = status -// return apiError -// } - -// return nil -// } diff --git a/libwallet/internal/vsp/feepayment.go b/libwallet/internal/vsp/feepayment.go index 50edb0fad..9b07afc72 100644 --- a/libwallet/internal/vsp/feepayment.go +++ b/libwallet/internal/vsp/feepayment.go @@ -1,1018 +1,838 @@ package vsp -// import ( -// "bytes" -// "context" -// cryptorand "crypto/rand" -// "encoding/hex" -// "encoding/json" -// "fmt" -// "sync" -// "time" - -// "decred.org/dcrwallet/v3/errors" -// "decred.org/dcrwallet/v3/wallet" -// "decred.org/dcrwallet/v3/wallet/txrules" -// "decred.org/dcrwallet/v3/wallet/txsizes" -// "github.com/crypto-power/cryptopower/libwallet/internal/uniformprng" -// "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" -// ) - -// var prng lockedRand - -// type lockedRand struct { -// mu sync.Mutex -// rand *uniformprng.Source -// } - -// func (r *lockedRand) int63n(n int64) int64 { -// r.mu.Lock() -// defer r.mu.Unlock() -// return r.rand.Int63n(n) -// } - -// // duration returns a random time.Duration in [0,d) with uniform distribution. -// func (r *lockedRand) duration(d time.Duration) time.Duration { -// return time.Duration(r.int63n(int64(d))) -// } - -// func (r *lockedRand) coinflip() bool { -// r.mu.Lock() -// defer r.mu.Unlock() -// return r.rand.Uint32n(2) == 0 -// } - -// func init() { -// source, err := uniformprng.RandSource(cryptorand.Reader) -// if err != nil { -// panic(err) -// } -// prng = lockedRand{ -// rand: source, -// } -// } - -// 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 *Client -// 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 -// } - -// 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() -// 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 *Client) 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 - -// fp = &feePayment{ -// client: c, -// ctx: context.Background(), -// ticketHash: *ticketHash, -// policy: c.policy, -// params: c.params, -// } - -// // 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.client.log.Warnf("no ticket found for %v", ticketHash) -// return nil -// } - -// fp.votingAddr, fp.commitmentAddr, err = parseTicket(ticket, c.params) -// if err != nil { -// fp.client.log.Errorf("%v is not a ticket: %v", ticketHash, err) -// return nil -// } -// // Try to access the voting key. -// fp.votingKey, err = w.DumpWIFPrivateKey(ctx, fp.votingAddr) -// if err != nil { -// fp.client.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 *Client) 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 { -// 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 prng.duration(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 { -// 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) -// } - -// var response struct { -// Timestamp int64 `json:"timestamp"` -// FeeAddress string `json:"feeaddress"` -// FeeAmount int64 `json:"feeamount"` -// Request []byte `json:"request"` -// } -// requestBody, err := json.Marshal(&struct { -// Timestamp int64 `json:"timestamp"` -// TicketHash string `json:"tickethash"` -// TicketHex json.Marshaler `json:"tickethex"` -// ParentHex json.Marshaler `json:"parenthex"` -// }{ -// Timestamp: time.Now().Unix(), -// TicketHash: fp.ticketHash.String(), -// TicketHex: txMarshaler(ticket), -// ParentHex: txMarshaler(parent), -// }) -// if err != nil { -// return err -// } -// err = fp.client.post(ctx, "/api/v3/feeaddress", fp.commitmentAddr, &response, -// json.RawMessage(requestBody)) -// if err != nil { -// return err -// } - -// // verify initial request matches server -// if !bytes.Equal(requestBody, response.Request) { -// return fmt.Errorf("server response has differing request: %#v != %#v", -// requestBody, response.Request) -// } - -// feeAmount := dcrutil.Amount(response.FeeAmount) -// feeAddr, err := stdaddr.DecodeAddress(response.FeeAddress, params) -// if err != nil { -// return fmt.Errorf("server fee address invalid: %w", err) -// } - -// 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 the 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 { -// 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 prng.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 { -// log.Errorf("failed to sign transaction: %v", err) -// sigErrStr := "" -// for _, sigErr := range sigErrs { -// 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.pub) -// if err != nil { -// return err -// } - -// fp.mu.Lock() -// fp.feeTx = tx -// fp.feeHash = feeHash -// fp.mu.Unlock() - -// // nothing scheduled -// return nil -// } - -// type TicketStatus struct { -// Timestamp int64 `json:"timestamp"` -// TicketConfirmed bool `json:"ticketconfirmed"` -// FeeTxStatus string `json:"feetxstatus"` -// FeeTxHash string `json:"feetxhash"` -// VoteChoices map[string]string `json:"votechoices"` -// TSpendPolicy map[string]string `json:"tspendpolicy"` -// TreasuryPolicy map[string]string `json:"treasurypolicy"` -// Request []byte `json:"request"` -// } - -// // GetTicketStatus calls the VSP's TicketStatus API for the provided ticket hash -// // and returns the VSP's response. -// func (c *Client) GetTicketStatus(ctx context.Context, ticketHash *chainhash.Hash) (*TicketStatus, error) { -// return c.status(ctx, ticketHash) -// } - -// func (c *Client) status(ctx context.Context, ticketHash *chainhash.Hash) (*TicketStatus, 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) -// } - -// var resp TicketStatus -// requestBody, err := json.Marshal(&struct { -// TicketHash string `json:"tickethash"` -// }{ -// TicketHash: ticketHash.String(), -// }) -// if err != nil { -// return nil, err -// } -// err = c.post(ctx, "/api/v3/ticketstatus", commitmentAddr, &resp, -// json.RawMessage(requestBody)) -// if err != nil { -// return nil, err -// } - -// // verify initial request matches server -// if !bytes.Equal(requestBody, resp.Request) { -// log.Warnf("server response has differing request: %#v != %#v", -// requestBody, resp.Request) -// return nil, fmt.Errorf("server response contains differing request") -// } - -// // XXX validate server timestamp? - -// return &resp, nil -// } - -// func (c *Client) setVoteChoices(ctx context.Context, ticketHash *chainhash.Hash, -// choices []wallet.AgendaChoice, 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) -// } - -// agendaChoices := make(map[string]string, len(choices)) - -// // Prepare agenda choice -// for _, c := range choices { -// agendaChoices[c.AgendaID] = c.ChoiceID -// } - -// var resp TicketStatus -// requestBody, err := json.Marshal(&struct { -// Timestamp int64 `json:"timestamp"` -// TicketHash string `json:"tickethash"` -// VoteChoices map[string]string `json:"votechoices"` -// TSpendPolicy map[string]string `json:"tspendpolicy"` -// TreasuryPolicy map[string]string `json:"treasurypolicy"` -// }{ -// Timestamp: time.Now().Unix(), -// TicketHash: ticketHash.String(), -// VoteChoices: agendaChoices, -// TSpendPolicy: tspendPolicy, -// TreasuryPolicy: treasuryPolicy, -// }) -// if err != nil { -// return err -// } - -// err = c.post(ctx, "/api/v3/setvotechoices", commitmentAddr, &resp, -// json.RawMessage(requestBody)) -// if err != nil { -// return err -// } - -// // verify initial request matches server -// if !bytes.Equal(requestBody, resp.Request) { -// log.Warnf("server response has differing request: %#v != %#v", -// requestBody, resp.Request) -// return fmt.Errorf("server response contains differing request") -// } - -// // 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 *BadRequestError -// if errors.As(err, &apiErr) && apiErr.Code == codeTicketCannotVote { -// fp.remove("ticket cannot vote") -// // Tickets will be automatically revoked. -// } -// 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 *BadRequestError -// if errors.As(err, &apiErr) { -// switch apiErr.Code { -// case codeFeeAlreadyReceived: -// err = w.SetPublished(ctx, &feeHash, true) -// if err != nil { -// return err -// } -// err = w.UpdateVspTicketFeeToPaid(ctx, &fp.ticketHash, &feeHash, fp.client.url, fp.client.pub) -// if err != nil { -// return err -// } -// err = nil -// case codeInvalidFeeTx, codeCannotBroadcastFee: -// err := w.UpdateVspTicketFeeToErrored(ctx, &fp.ticketHash, fp.client.url, fp.client.pub) -// 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. -// } -// } -// 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.pub) -// 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 -// } - -// var payfeeResponse struct { -// Timestamp int64 `json:"timestamp"` -// Request []byte `json:"request"` -// } -// requestBody, err := json.Marshal(&struct { -// Timestamp int64 `json:"timestamp"` -// TicketHash string `json:"tickethash"` -// FeeTx json.Marshaler `json:"feetx"` -// VotingKey string `json:"votingkey"` -// VoteChoices map[string]string `json:"votechoices"` -// TSpendPolicy map[string]string `json:"tspendpolicy"` -// TreasuryPolicy map[string]string `json:"treasurypolicy"` -// }{ -// Timestamp: time.Now().Unix(), -// TicketHash: fp.ticketHash.String(), -// FeeTx: txMarshaler(feeTx), -// VotingKey: votingKey, -// VoteChoices: voteChoices, -// TSpendPolicy: w.TSpendPolicyForTicket(&fp.ticketHash), -// TreasuryPolicy: w.TreasuryKeyPolicyForTicket(&fp.ticketHash), -// }) -// if err != nil { -// return err -// } -// err = fp.client.post(ctx, "/api/v3/payfee", fp.commitmentAddr, -// &payfeeResponse, json.RawMessage(requestBody)) -// if err != nil { -// var apiErr *BadRequestError -// if errors.As(err, &apiErr) && apiErr.Code == codeFeeExpired { -// // 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 { -// log.Errorf("error abandoning expired fee tx %v", err) -// } -// fp.feeTx = nil -// } -// return fmt.Errorf("payfee: %w", err) -// } - -// // Check for matching original request. -// // This is signed by the VSP, and the signature -// // has already been checked above. -// if !bytes.Equal(requestBody, payfeeResponse.Request) { -// return fmt.Errorf("server response has differing request: %#v != %#v", -// requestBody, payfeeResponse.Request) -// } -// // TODO - validate server timestamp? - -// 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, errors.Locked) { -// 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.pub) -// 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": -// 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 -// err = w.UpdateVspTicketFeeToConfirmed(ctx, &fp.ticketHash, &fp.feeHash, fp.client.url, fp.client.pub) -// if err != nil { -// return err -// } -// return nil -// case "error": -// 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 -// log.Warnf("VSP responded with %v for %v", status.FeeTxStatus, -// &fp.ticketHash) -// } - -// return nil -// } - -// type marshaler struct { -// marshaled []byte -// err error -// } - -// func (m *marshaler) MarshalJSON() ([]byte, error) { -// return m.marshaled, m.err -// } - -// func txMarshaler(tx *wire.MsgTx) json.Marshaler { -// var buf bytes.Buffer -// buf.Grow(2 + tx.SerializeSize()*2) -// buf.WriteByte('"') -// err := tx.Serialize(hex.NewEncoder(&buf)) -// buf.WriteByte('"') -// return &marshaler{buf.Bytes(), err} -// } +import ( + "bytes" + "context" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "fmt" + "math/bits" + "sync" + "time" + + "decred.org/dcrwallet/v4/errors" + "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/stdaddr" + "github.com/decred/dcrd/txscript/v4/stdscript" + "github.com/decred/dcrd/wire" + "github.com/decred/vspd/types/v2" +) + +// randInt63 returns a cryptographically random 63-bit positive integer as an +// int64. +func randInt63() int64 { + buf := make([]byte, 8) + _, err := rand.Read(buf) + if err != nil { + panic(fmt.Sprintf("unhandled crypto/rand error: %v", err)) + } + return int64(binary.LittleEndian.Uint64(buf) &^ (1 << 63)) +} + +// randInt63n returns, as an int64, a cryptographically-random 63-bit positive +// integer in [0,n) without modulo bias. +// It panics if n <= 0. +func randInt63n(n int64) int64 { + if n <= 0 { + panic("invalid argument to int63n") + } + n-- + mask := int64(^uint64(0) >> bits.LeadingZeros64(uint64(n))) + for { + v := randInt63() & mask + if v <= n { + return v + } + } +} + +// randomDuration returns a random time.Duration in [0,d) with uniform +// distribution. +func randomDuration(d time.Duration) time.Duration { + return time.Duration(randInt63n(int64(d))) +} + +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 *Client + 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 + + params *chaincfg.Params +} + +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 +} + +// calcHeights checks if the ticket has been mined, and if so, sets the live +// height and expiry height fields. Should be called with mutex already held. +func (fp *feePayment) calcHeights() { + _, minedHeight, err := fp.client.wallet.TxBlock(fp.ctx, &fp.ticketHash) + if err != nil { + // This is not expected to ever error, as the ticket has already been + // fetched from the wallet at least one before this point is reached. + log.Errorf("Failed to query block which mines ticket: %v", err) + return + } + + if minedHeight < 2 { + return + } + + // Note the off-by-one; this is correct. Tickets become live one block after + // the params would indicate. + fp.ticketLive = minedHeight + int32(fp.params.TicketMaturity) + 1 + fp.ticketExpires = fp.ticketLive + int32(fp.params.TicketExpiry) +} + +// expiryHeight returns the height at which the ticket expires. Returns zero if +// the block is not yet mined. Should be called with mutex already held. +func (fp *feePayment) expiryHeight() int32 { + if fp.ticketExpires == 0 { + fp.calcHeights() + } + + return fp.ticketExpires +} + +// liveHeight returns the height at which the ticket becomes live. Returns zero +// if the block is not yet mined. Should be called with mutex already held. +func (fp *feePayment) liveHeight() int32 { + if fp.ticketLive == 0 { + fp.calcHeights() + } + + return fp.ticketLive +} + +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.expiryHeight() + 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() + 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 *Client) 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 + + fp = &feePayment{ + client: c, + ctx: context.Background(), + ticketHash: *ticketHash, + policy: c.policy, + params: c.params, + } + + // 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 { + log.Warnf("no ticket found for %v", ticketHash) + return nil + } + + fp.votingAddr, fp.commitmentAddr, err = parseTicket(ticket, c.params) + if err != nil { + log.Errorf("%v is not a ticket: %v", ticketHash, err) + return nil + } + // Try to access the voting key. + fp.votingKey, err = w.DumpWIFPrivateKey(ctx, fp.votingAddr) + if err != nil { + 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 *Client) 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 { + 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 + _, tipHeight := w.MainChainTip(fp.ctx) + + fp.mu.Lock() + ticketLive := fp.liveHeight() + ticketExpires := fp.expiryHeight() + fp.mu.Unlock() + + var jitter time.Duration + switch { + case tipHeight < ticketLive: // immature, mined ticket + blocksUntilLive := ticketLive - tipHeight + jitter = fp.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 { + 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 + + // 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, fp.params) + if err != nil { + return fmt.Errorf("server fee address invalid: %w", err) + } + + 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() + } + + err := w.CreateVspPayment(ctx, tx, fee, feeAddr, fp.policy.FeeAcct, fp.policy.ChangeAcct) + if err != nil { + return fmt.Errorf("unable to create VSP fee tx for ticket %v: %w", fp.ticketHash, err) + } + + feeHash := tx.TxHash() + 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 *Client) status(ctx context.Context, ticketHash *chainhash.Hash) (*types.TicketStatusResponse, error) { + 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, c.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 *Client) setVoteChoices(ctx context.Context, ticketHash *chainhash.Hash, + choices map[string]string, tspendPolicy map[string]string, treasuryPolicy map[string]string, +) error { + 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, c.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. + } + } + 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 + } + + 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, _, err := w.AgendaChoices(ctx, &fp.ticketHash) + if err != nil { + return err + } + + 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 { + 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? + + log.Infof("successfully processed %v", fp.ticketHash) + return nil +} + +// 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. +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) + if err != nil { + log.Warnf("Rescheduling status check for %v: %v", &fp.ticketHash, err) + 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": + 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": + 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 + log.Warnf("VSP responded with unknown FeeTxStatus %q 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 +} diff --git a/libwallet/internal/vsp/feepayments.go b/libwallet/internal/vsp/feepayments.go deleted file mode 100644 index 98d868421..000000000 --- a/libwallet/internal/vsp/feepayments.go +++ /dev/null @@ -1,838 +0,0 @@ -package vsp - -import ( - "bytes" - "context" - "crypto/rand" - "encoding/binary" - "encoding/hex" - "fmt" - "math/bits" - "sync" - "time" - - "decred.org/dcrwallet/v3/errors" - "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/stdaddr" - "github.com/decred/dcrd/txscript/v4/stdscript" - "github.com/decred/dcrd/wire" - "github.com/decred/vspd/types/v2" -) - -// randInt63 returns a cryptographically random 63-bit positive integer as an -// int64. -func randInt63() int64 { - buf := make([]byte, 8) - _, err := rand.Read(buf) - if err != nil { - panic(fmt.Sprintf("unhandled crypto/rand error: %v", err)) - } - return int64(binary.LittleEndian.Uint64(buf) &^ (1 << 63)) -} - -// randInt63n returns, as an int64, a cryptographically-random 63-bit positive -// integer in [0,n) without modulo bias. -// It panics if n <= 0. -func randInt63n(n int64) int64 { - if n <= 0 { - panic("invalid argument to int63n") - } - n-- - mask := int64(^uint64(0) >> bits.LeadingZeros64(uint64(n))) - for { - v := randInt63() & mask - if v <= n { - return v - } - } -} - -// randomDuration returns a random time.Duration in [0,d) with uniform -// distribution. -func randomDuration(d time.Duration) time.Duration { - return time.Duration(randInt63n(int64(d))) -} - -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 *Client - 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 - - params *chaincfg.Params -} - -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 -} - -// calcHeights checks if the ticket has been mined, and if so, sets the live -// height and expiry height fields. Should be called with mutex already held. -func (fp *feePayment) calcHeights() { - _, minedHeight, err := fp.client.wallet.TxBlock(fp.ctx, &fp.ticketHash) - if err != nil { - // This is not expected to ever error, as the ticket has already been - // fetched from the wallet at least one before this point is reached. - log.Errorf("Failed to query block which mines ticket: %v", err) - return - } - - if minedHeight < 2 { - return - } - - // Note the off-by-one; this is correct. Tickets become live one block after - // the params would indicate. - fp.ticketLive = minedHeight + int32(fp.params.TicketMaturity) + 1 - fp.ticketExpires = fp.ticketLive + int32(fp.params.TicketExpiry) -} - -// expiryHeight returns the height at which the ticket expires. Returns zero if -// the block is not yet mined. Should be called with mutex already held. -func (fp *feePayment) expiryHeight() int32 { - if fp.ticketExpires == 0 { - fp.calcHeights() - } - - return fp.ticketExpires -} - -// liveHeight returns the height at which the ticket becomes live. Returns zero -// if the block is not yet mined. Should be called with mutex already held. -func (fp *feePayment) liveHeight() int32 { - if fp.ticketLive == 0 { - fp.calcHeights() - } - - return fp.ticketLive -} - -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.expiryHeight() - 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() - 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 *Client) 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 - - fp = &feePayment{ - client: c, - ctx: context.Background(), - ticketHash: *ticketHash, - policy: c.policy, - params: c.params, - } - - // 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 { - log.Warnf("no ticket found for %v", ticketHash) - return nil - } - - fp.votingAddr, fp.commitmentAddr, err = parseTicket(ticket, c.params) - if err != nil { - log.Errorf("%v is not a ticket: %v", ticketHash, err) - return nil - } - // Try to access the voting key. - fp.votingKey, err = w.DumpWIFPrivateKey(ctx, fp.votingAddr) - if err != nil { - 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 *Client) 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 { - 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 - _, tipHeight := w.MainChainTip(fp.ctx) - - fp.mu.Lock() - ticketLive := fp.liveHeight() - ticketExpires := fp.expiryHeight() - fp.mu.Unlock() - - var jitter time.Duration - switch { - case tipHeight < ticketLive: // immature, mined ticket - blocksUntilLive := ticketLive - tipHeight - jitter = fp.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 { - 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 - - // 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, fp.params) - if err != nil { - return fmt.Errorf("server fee address invalid: %w", err) - } - - 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() - } - - err := w.CreateVspPayment(ctx, tx, fee, feeAddr, fp.policy.FeeAcct, fp.policy.ChangeAcct) - if err != nil { - return fmt.Errorf("unable to create VSP fee tx for ticket %v: %w", fp.ticketHash, err) - } - - feeHash := tx.TxHash() - 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 *Client) status(ctx context.Context, ticketHash *chainhash.Hash) (*types.TicketStatusResponse, error) { - 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, c.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 *Client) setVoteChoices(ctx context.Context, ticketHash *chainhash.Hash, - choices map[string]string, tspendPolicy map[string]string, treasuryPolicy map[string]string, -) error { - 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, c.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. - } - } - 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 - } - - 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, _, err := w.AgendaChoices(ctx, &fp.ticketHash) - if err != nil { - return err - } - - 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 { - 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? - - log.Infof("successfully processed %v", fp.ticketHash) - return nil -} - -// 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. -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) - if err != nil { - log.Warnf("Rescheduling status check for %v: %v", &fp.ticketHash, err) - 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": - 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": - 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 - log.Warnf("VSP responded with unknown FeeTxStatus %q 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 -} diff --git a/libwallet/internal/vsp/vsp.go b/libwallet/internal/vsp/vsp.go index a81dce4ab..8e0e222f2 100644 --- a/libwallet/internal/vsp/vsp.go +++ b/libwallet/internal/vsp/vsp.go @@ -8,9 +8,9 @@ import ( "net/url" "sync" - "decred.org/dcrwallet/v3/errors" - "decred.org/dcrwallet/v3/wallet" - "decred.org/dcrwallet/v3/wallet/udb" + "decred.org/dcrwallet/v4/errors" + "decred.org/dcrwallet/v4/wallet" + "decred.org/dcrwallet/v4/wallet/udb" "github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/dcrutil/v4" diff --git a/libwallet/log.go b/libwallet/log.go index b6effc459..8811f3b8a 100644 --- a/libwallet/log.go +++ b/libwallet/log.go @@ -8,7 +8,7 @@ package libwallet import ( "os" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/crypto-power/cryptopower/libwallet/internal/loader" "github.com/crypto-power/cryptopower/libwallet/internal/politeia" "github.com/crypto-power/cryptopower/libwallet/internal/vsp" diff --git a/libwallet/ltc.go b/libwallet/ltc.go index 3e1abe71c..1435d9858 100644 --- a/libwallet/ltc.go +++ b/libwallet/ltc.go @@ -3,7 +3,7 @@ package libwallet import ( "fmt" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/crypto-power/cryptopower/libwallet/assets/ltc" sharedW "github.com/crypto-power/cryptopower/libwallet/assets/wallet" "github.com/crypto-power/cryptopower/libwallet/utils" diff --git a/libwallet/txhelper/helper.go b/libwallet/txhelper/helper.go index 3243e1803..b780f078f 100644 --- a/libwallet/txhelper/helper.go +++ b/libwallet/txhelper/helper.go @@ -3,7 +3,7 @@ package txhelper import ( "math" - "decred.org/dcrwallet/v3/wallet" + "decred.org/dcrwallet/v4/wallet" "github.com/decred/dcrd/dcrutil/v4" "github.com/decred/dcrd/wire" "github.com/decred/dcrdata/v8/txhelpers" diff --git a/libwallet/utils/errors.go b/libwallet/utils/errors.go index 73b2862bd..39ab4ef11 100644 --- a/libwallet/utils/errors.go +++ b/libwallet/utils/errors.go @@ -5,7 +5,7 @@ import ( "net" "strings" - "decred.org/dcrwallet/v3/errors" + "decred.org/dcrwallet/v4/errors" "github.com/asdine/storm" ) diff --git a/log.go b/log.go index ba6b36de1..9996a43c2 100644 --- a/log.go +++ b/log.go @@ -34,11 +34,11 @@ import ( "github.com/crypto-power/cryptopower/ui/page/transaction" "github.com/crypto-power/cryptopower/wallet" - "decred.org/dcrwallet/v3/p2p" - "decred.org/dcrwallet/v3/spv" - "decred.org/dcrwallet/v3/ticketbuyer" - dcrw "decred.org/dcrwallet/v3/wallet" - "decred.org/dcrwallet/v3/wallet/udb" + "decred.org/dcrwallet/v4/p2p" + "decred.org/dcrwallet/v4/spv" + "decred.org/dcrwallet/v4/ticketbuyer" + dcrw "decred.org/dcrwallet/v4/wallet" + "decred.org/dcrwallet/v4/wallet/udb" "github.com/btcsuite/btclog" btcC "github.com/btcsuite/btcwallet/chain" btcw "github.com/btcsuite/btcwallet/wallet" diff --git a/ui/page/staking/stake_overview.go b/ui/page/staking/stake_overview.go index 6983f727c..cacc6ff55 100644 --- a/ui/page/staking/stake_overview.go +++ b/ui/page/staking/stake_overview.go @@ -353,8 +353,7 @@ func (pg *Page) HandleUserInteractions() { return } - account := ticketTx.Inputs[0].AccountNumber - err = ticketInfo.Client.ProcessTicket(pg.ctx, txHash, pg.dcrImpl.GetvspPolicy(account)) + err = ticketInfo.Client.Process(pg.ctx, txHash, nil) if err != nil { log.Errorf("processing the unconfirmed tx fee failed: %v", err) } From 860029ee0e42636d914311e0208832c71c8b7824 Mon Sep 17 00:00:00 2001 From: dmigwi Date: Fri, 18 Aug 2023 12:08:53 +0300 Subject: [PATCH 04/11] Delete unused package uniformprng --- libwallet/internal/uniformprng/prng.go | 89 -------------------------- 1 file changed, 89 deletions(-) delete mode 100644 libwallet/internal/uniformprng/prng.go diff --git a/libwallet/internal/uniformprng/prng.go b/libwallet/internal/uniformprng/prng.go deleted file mode 100644 index 0cca7f69b..000000000 --- a/libwallet/internal/uniformprng/prng.go +++ /dev/null @@ -1,89 +0,0 @@ -// Package uniformprng implements a uniform, cryptographically secure -// pseudo-random number generator. -package uniformprng - -import ( - "encoding/binary" - "io" - "math/bits" - - "golang.org/x/crypto/chacha20" -) - -// Source returns cryptographically-secure pseudorandom numbers with uniform -// distribution. -type Source struct { - buf [8]byte - cipher *chacha20.Cipher -} - -var nonce = make([]byte, chacha20.NonceSize) - -// NewSource seeds a Source from a 32-byte key. -func NewSource(seed *[32]byte) *Source { - cipher, _ := chacha20.NewUnauthenticatedCipher(seed[:], nonce) - return &Source{cipher: cipher} -} - -// RandSource creates a Source with seed randomness read from rand. -func RandSource(rand io.Reader) (*Source, error) { - seed := new([32]byte) - _, err := io.ReadFull(rand, seed[:]) - if err != nil { - return nil, err - } - return NewSource(seed), nil -} - -// Uint32 returns a pseudo-random uint32. -func (s *Source) Uint32() uint32 { - b := s.buf[:4] - for i := range b { - b[i] = 0 - } - s.cipher.XORKeyStream(b, b) - return binary.LittleEndian.Uint32(b) -} - -// Uint32n returns a pseudo-random uint32 in range [0,n) without modulo bias. -func (s *Source) Uint32n(n uint32) uint32 { - if n < 2 { - return 0 - } - n-- - mask := ^uint32(0) >> bits.LeadingZeros32(n) - for { - u := s.Uint32() & mask - if u <= n { - return u - } - } -} - -// Int63 returns a pseudo-random 63-bit positive integer as an int64 without -// modulo bias. -func (s *Source) Int63() int64 { - b := s.buf[:] - for i := range b { - b[i] = 0 - } - s.cipher.XORKeyStream(b, b) - return int64(binary.LittleEndian.Uint64(b) &^ (1 << 63)) -} - -// Int63n returns, as an int64, a pseudo-random 63-bit positive integer in [0,n) -// without modulo bias. -// It panics if n <= 0. -func (s *Source) Int63n(n int64) int64 { - if n <= 0 { - panic("invalid argument to Int63n") - } - n-- - mask := int64(^uint64(0) >> bits.LeadingZeros64(uint64(n))) - for { - i := s.Int63() & mask - if i <= n { - return i - } - } -} From b980746a902af7ab398d8ed3ad98bf97d46d3ad5 Mon Sep 17 00:00:00 2001 From: dmigwi Date: Mon, 21 Aug 2023 14:56:45 +0300 Subject: [PATCH 05/11] Set the default privacy policy Update the workflow cache version --- .github/workflows/go.yml | 2 +- libwallet/assets/dcr/ticket.go | 6 ++++++ libwallet/assets/dcr/vsp.go | 4 ++++ libwallet/internal/vsp/vsp.go | 18 ++++++++++++++++-- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 7eb1e6a48..eabf09ba3 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -24,7 +24,7 @@ jobs: id: cache with: path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}-v2 + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}-v3 restore-keys: | ${{ runner.os }}-go- diff --git a/libwallet/assets/dcr/ticket.go b/libwallet/assets/dcr/ticket.go index 3a97a6bc5..cbd35c7f4 100644 --- a/libwallet/assets/dcr/ticket.go +++ b/libwallet/assets/dcr/ticket.go @@ -124,6 +124,9 @@ func (asset *Asset) PurchaseTickets(account, numTickets int32, vspHost, passphra } defer asset.LockWallet() + log.Info("Setting the ticket(s) purchasing account info") + vspClient.SetAccountInfo(account, account) + request := &w.PurchaseTicketsRequest{ Count: int(numTickets), SourceAccount: uint32(account), @@ -454,6 +457,9 @@ func (asset *Asset) buyTicket(ctx context.Context, passphrase string, sdiff dcru return err } + log.Info("Setting the ticket(s) purchasing account info") + cfg.VspClient.SetAccountInfo(cfg.PurchaseAccount, cfg.PurchaseAccount) + // Count is 1 to prevent combining multiple split outputs in one tx, // which can be used to link the tickets eventually purchased with the // split outputs. diff --git a/libwallet/assets/dcr/vsp.go b/libwallet/assets/dcr/vsp.go index ab225f126..8ee617b6c 100644 --- a/libwallet/assets/dcr/vsp.go +++ b/libwallet/assets/dcr/vsp.go @@ -37,6 +37,10 @@ func (asset *Asset) VSPClient(host string, pubKey []byte) (*vsp.Client, error) { PubKey: pubKey, Dialer: nil, // optional, but consider providing a value Wallet: asset.Internal().DCR, + Params: asset.chainParams, + Policy: &vsp.Policy{ + MaxFee: 0.1e8, + }, } client, err := vsp.New(cfg) if err != nil { diff --git a/libwallet/internal/vsp/vsp.go b/libwallet/internal/vsp/vsp.go index 8e0e222f2..4e6e551a0 100644 --- a/libwallet/internal/vsp/vsp.go +++ b/libwallet/internal/vsp/vsp.go @@ -62,12 +62,20 @@ func New(cfg Config) (*Client, error) { return nil, err } + if len(cfg.PubKey) == 0 { + return nil, errors.New("pubkey option not set") + } + if cfg.Wallet == nil { - return nil, fmt.Errorf("wallet option not set") + return nil, errors.New("wallet option not set") } if cfg.Params == nil { - return nil, fmt.Errorf("params option not set") + return nil, errors.New("params option not set") + } + + if cfg.Policy == nil { + return nil, errors.New("policy option not set") } client := &vspd.Client{ @@ -90,6 +98,12 @@ func New(cfg Config) (*Client, error) { return v, nil } +// SetAccountInfo set the account purchase information +func (c *Client) SetAccountInfo(feeAcc, changeAcc int32) { + c.policy.FeeAcct = uint32(feeAcc) + c.policy.ChangeAcct = uint32(changeAcc) +} + func (c *Client) FeePercentage(ctx context.Context) (float64, error) { resp, err := c.Client.VspInfo(ctx) if err != nil { From c92100a8d6a5371428aff3bb644cc7917b4c19c9 Mon Sep 17 00:00:00 2001 From: dmigwi Date: Mon, 21 Aug 2023 15:05:15 +0300 Subject: [PATCH 06/11] Update code checkout information --- .github/workflows/go.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index eabf09ba3..d3fe0267a 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -6,10 +6,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code into the Go module directory - uses: actions/checkout@v1 + uses: actions/checkout@v2 - name: Set up Go 1.19 - uses: actions/setup-go@v1 + uses: actions/setup-go@v2 with: go-version: 1.19 id: go @@ -20,13 +20,13 @@ jobs: sudo apt install libwayland-dev libx11-dev libx11-xcb-dev libxkbcommon-x11-dev libgles2-mesa-dev libegl1-mesa-dev libffi-dev libxcursor-dev libvulkan-dev - name: Cache (dependencies) - uses: actions/cache@v1 + uses: actions/cache@v2 id: cache with: path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}-v3 + key: ${{ runner.os }}-go-v1-${{ hashFiles('**/go.sum') }} restore-keys: | - ${{ runner.os }}-go- + ${{ runner.os }}-go-v1 - name: Install linter run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.53.3 From 000de69d03a0d5110bb7e5da0100f984c162f046 Mon Sep 17 00:00:00 2001 From: dmigwi Date: Mon, 21 Aug 2023 15:14:26 +0300 Subject: [PATCH 07/11] upgrade go version from go 1.19 to go 1.20 --- .github/workflows/go.yml | 14 +++++++------- go.mod | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index d3fe0267a..a515ea0f6 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -6,12 +6,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code into the Go module directory - uses: actions/checkout@v2 + uses: actions/checkout@v3 - - name: Set up Go 1.19 - uses: actions/setup-go@v2 + - name: Set up Go 1.20 + uses: actions/setup-go@v3 with: - go-version: 1.19 + go-version: "1.20" id: go - name: Libraries for gio @@ -20,13 +20,13 @@ jobs: sudo apt install libwayland-dev libx11-dev libx11-xcb-dev libxkbcommon-x11-dev libgles2-mesa-dev libegl1-mesa-dev libffi-dev libxcursor-dev libvulkan-dev - name: Cache (dependencies) - uses: actions/cache@v2 + uses: actions/cache@v3 id: cache with: path: ~/go/pkg/mod - key: ${{ runner.os }}-go-v1-${{ hashFiles('**/go.sum') }} + key: ${{ runner.os }}-go-v2-${{ hashFiles('**/go.sum') }} restore-keys: | - ${{ runner.os }}-go-v1 + ${{ runner.os }}-go-v2 - name: Install linter run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.53.3 diff --git a/go.mod b/go.mod index 7c48f978d..8dc37b685 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/crypto-power/cryptopower -go 1.19 +go 1.20 require ( decred.org/dcrwallet/v4 v4.0.0-20230809150859-a87fa843495e From ccb3b21cd3ae9c2486d2116f16932cde6b944412 Mon Sep 17 00:00:00 2001 From: dmigwi Date: Mon, 21 Aug 2023 15:57:47 +0300 Subject: [PATCH 08/11] Remove deprecate code --- libwallet/internal/vsp/errors.go | 21 --------------------- libwallet/libwallet_suite_test.go | 2 +- ui/page/seedbackup/verify_seed.go | 8 ++++---- 3 files changed, 5 insertions(+), 26 deletions(-) delete mode 100644 libwallet/internal/vsp/errors.go diff --git a/libwallet/internal/vsp/errors.go b/libwallet/internal/vsp/errors.go deleted file mode 100644 index 64c2e13b6..000000000 --- a/libwallet/internal/vsp/errors.go +++ /dev/null @@ -1,21 +0,0 @@ -package vsp - -const ( - codeBadRequest = iota - codeInternalErr - codeVspClosed - codeFeeAlreadyReceived - codeInvalidFeeTx - codeFeeTooSmall - codeUnknownTicket - codeTicketCannotVote - codeFeeExpired - codeInvalidVoteChoices - codeBadSignature - codeInvalidPrivKey - codeFeeNotReceived - codeInvalidTicket - codeCannotBroadcastTicket - codeCannotBroadcastFee - codeCannotBroadcastFeeUnknownOutputs -) diff --git a/libwallet/libwallet_suite_test.go b/libwallet/libwallet_suite_test.go index 021e8c6fc..b77098ae8 100644 --- a/libwallet/libwallet_suite_test.go +++ b/libwallet/libwallet_suite_test.go @@ -10,6 +10,6 @@ import ( func TestLibwallet(t *testing.T) { RegisterFailHandler(Fail) - rand.Seed(GinkgoRandomSeed()) + rand.New(rand.NewSource(GinkgoRandomSeed())) RunSpecs(t, "Libwallet Suite") } diff --git a/ui/page/seedbackup/verify_seed.go b/ui/page/seedbackup/verify_seed.go index c4baf3871..70fcf57a5 100644 --- a/ui/page/seedbackup/verify_seed.go +++ b/ui/page/seedbackup/verify_seed.go @@ -49,6 +49,7 @@ type VerifySeedPage struct { toggleSeedInput *cryptomaterial.Switch seedInputEditor cryptomaterial.Editor verifySeedButton cryptomaterial.Button + random *rand.Rand } func NewVerifySeedPage(l *load.Load, wallet sharedW.Asset, seed string, redirect Redirectfunc) *VerifySeedPage { @@ -68,7 +69,7 @@ func NewVerifySeedPage(l *load.Load, wallet sharedW.Asset, seed string, redirect Axis: layout.Vertical, }, } - + pg.random = rand.New(rand.NewSource(time.Now().UnixNano())) pg.actionButton.Font.Weight = font.Medium pg.backButton, _ = components.SubpageHeaderButtons(l) @@ -95,7 +96,6 @@ func (pg *VerifySeedPage) OnNavigatedTo() { listGroupSeed := make([]*layout.List, 0) multiSeedList := make([]shuffledSeedWords, 0) seedWords := strings.Split(pg.seed, " ") - rand.Seed(time.Now().UnixNano()) for _, word := range seedWords { listGroupSeed = append(listGroupSeed, &layout.List{Axis: layout.Horizontal}) index := seedPosition(word, allSeeds) @@ -126,14 +126,14 @@ func (pg *VerifySeedPage) getMultiSeed(realSeedIndex int, allSeeds []string) shu allSeeds = removeSeed(allSeeds, realSeedIndex) for i := 0; i < 3; i++ { - randomSeed := rand.Intn(len(allSeeds)) + randomSeed := pg.random.Intn(len(allSeeds)) shuffledSeed.words = append(shuffledSeed.words, allSeeds[randomSeed]) shuffledSeed.clickables = append(shuffledSeed.clickables, clickable()) allSeeds = removeSeed(allSeeds, randomSeed) } - rand.Shuffle(len(shuffledSeed.words), func(i, j int) { + pg.random.Shuffle(len(shuffledSeed.words), func(i, j int) { shuffledSeed.words[i], shuffledSeed.words[j] = shuffledSeed.words[j], shuffledSeed.words[i] }) From e812b40a3109d886b84c0265b6bf2d89270b48dc Mon Sep 17 00:00:00 2001 From: dmigwi Date: Mon, 28 Aug 2023 20:03:06 +0300 Subject: [PATCH 09/11] Set ticket purchasing account before looping starts --- libwallet/assets/dcr/consensus.go | 16 ++++++++-------- libwallet/assets/dcr/ticket.go | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/libwallet/assets/dcr/consensus.go b/libwallet/assets/dcr/consensus.go index 51b8ef370..e5de08846 100644 --- a/libwallet/assets/dcr/consensus.go +++ b/libwallet/assets/dcr/consensus.go @@ -110,21 +110,21 @@ func (asset *Asset) SetVoteChoice(agendaID, choiceID, hash, passphrase string) e return err } - choice, ok := choices[agendaID] - if ok && choice == strings.ToLower(choiceID) { + currentChoice, ok := choices[agendaID] + if ok && currentChoice == strings.ToLower(choiceID) { // Do not set the same choice again return nil } if !ok { // Default to abstain if no previous choice existed - choice = "abstain" + currentChoice = "abstain" } - currentChoice := map[string]string{agendaID: choice} - newChoice := map[string]string{agendaID: strings.ToLower(choiceID)} + currentChoiceMap := map[string]string{agendaID: currentChoice} + newChoiceMap := map[string]string{agendaID: strings.ToLower(choiceID)} - _, err = asset.Internal().DCR.SetAgendaChoices(ctx, ticketHash, newChoice) + _, err = asset.Internal().DCR.SetAgendaChoices(ctx, ticketHash, newChoiceMap) if err != nil { return err } @@ -134,7 +134,7 @@ func (asset *Asset) SetVoteChoice(agendaID, choiceID, hash, passphrase string) e if !vspPreferenceUpdateSuccess { // Updating the agenda voting preference with the vsp failed, // revert the locally saved voting preference for the agenda. - _, revertError := asset.Internal().DCR.SetAgendaChoices(ctx, ticketHash, currentChoice) + _, revertError := asset.Internal().DCR.SetAgendaChoices(ctx, ticketHash, currentChoiceMap) if revertError != nil { log.Errorf("unable to revert locally saved voting preference: %v", revertError) } @@ -177,7 +177,7 @@ func (asset *Asset) SetVoteChoice(agendaID, choiceID, hash, passphrase string) e firstErr = err continue // try next tHash } - err = vspClient.SetVoteChoice(ctx, tHash, newChoice, nil, nil) + err = vspClient.SetVoteChoice(ctx, tHash, newChoiceMap, nil, nil) if err != nil && firstErr == nil { firstErr = err continue // try next tHash diff --git a/libwallet/assets/dcr/ticket.go b/libwallet/assets/dcr/ticket.go index cbd35c7f4..d8fd1bda8 100644 --- a/libwallet/assets/dcr/ticket.go +++ b/libwallet/assets/dcr/ticket.go @@ -409,6 +409,9 @@ func (asset *Asset) runTicketBuyer(ctx context.Context, passphrase string, cfg * continue } + log.Info("Setting the ticket(s) purchasing account info") + cfg.VspClient.SetAccountInfo(cfg.PurchaseAccount, cfg.PurchaseAccount) + cancelCtx, cancel := context.WithCancel(ctx) cancels = append(cancels, cancel) buyTicket := func() { @@ -457,9 +460,6 @@ func (asset *Asset) buyTicket(ctx context.Context, passphrase string, sdiff dcru return err } - log.Info("Setting the ticket(s) purchasing account info") - cfg.VspClient.SetAccountInfo(cfg.PurchaseAccount, cfg.PurchaseAccount) - // Count is 1 to prevent combining multiple split outputs in one tx, // which can be used to link the tickets eventually purchased with the // split outputs. From 60d8e048303691995dc324e0d906a8b148241da9 Mon Sep 17 00:00:00 2001 From: dmigwi Date: Mon, 11 Sep 2023 18:41:04 +0300 Subject: [PATCH 10/11] Make state variable local in the internal pkg --- libwallet/internal/vsp/feepayment.go | 6 +++--- libwallet/internal/vsp/vsp.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/libwallet/internal/vsp/feepayment.go b/libwallet/internal/vsp/feepayment.go index 9b07afc72..f29fe2f00 100644 --- a/libwallet/internal/vsp/feepayment.go +++ b/libwallet/internal/vsp/feepayment.go @@ -89,7 +89,7 @@ type feePayment struct { feeAddr stdaddr.Address feeHash chainhash.Hash feeTx *wire.MsgTx - state State + state state err error timerMu sync.Mutex @@ -98,10 +98,10 @@ type feePayment struct { params *chaincfg.Params } -type State uint32 +type state uint32 const ( - _ State = iota + _ state = iota Unprocessed FeePublished _ // ... diff --git a/libwallet/internal/vsp/vsp.go b/libwallet/internal/vsp/vsp.go index 4e6e551a0..a3b05741a 100644 --- a/libwallet/internal/vsp/vsp.go +++ b/libwallet/internal/vsp/vsp.go @@ -349,7 +349,7 @@ type TicketInfo struct { TicketHash chainhash.Hash CommitmentAddr stdaddr.StakeAddress VotingAddr stdaddr.StakeAddress - State State + State state Fee dcrutil.Amount FeeHash chainhash.Hash From e431d1d0f74f5c1c6c279eabf49390dbbb050147 Mon Sep 17 00:00:00 2001 From: dmigwi Date: Mon, 11 Sep 2023 23:34:03 +0300 Subject: [PATCH 11/11] Validate a ticket before querying its status upstream --- libwallet/assets/dcr/ticket.go | 16 +++++++++++++++- libwallet/internal/vsp/feepayment.go | 2 +- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/libwallet/assets/dcr/ticket.go b/libwallet/assets/dcr/ticket.go index d8fd1bda8..355c259f2 100644 --- a/libwallet/assets/dcr/ticket.go +++ b/libwallet/assets/dcr/ticket.go @@ -202,7 +202,21 @@ func (asset *Asset) VSPTicketInfo(hash string) (*VSPTicketInfo, error) { return nil, err } - commitmentAddr, err := stake.AddrFromSStxPkScrCommitment(txs[0].TxOut[1].PkScript, asset.chainParams) + if len(txs) == 0 { + return nil, fmt.Errorf("%v is not a ticket", ticketHash) + } + + ticketTx := txs[0] + + if len(ticketTx.TxOut) != 3 { + return nil, fmt.Errorf("ticket %v has multiple commitments", ticketHash) + } + + if !stake.IsSStx(ticketTx) { + return nil, fmt.Errorf("%v is not a ticket", ticketHash) + } + + commitmentAddr, err := stake.AddrFromSStxPkScrCommitment(ticketTx.TxOut[1].PkScript, asset.chainParams) if err != nil { return nil, fmt.Errorf("failed to extract commitment address from %v: %w", ticketHash, err) diff --git a/libwallet/internal/vsp/feepayment.go b/libwallet/internal/vsp/feepayment.go index f29fe2f00..f7d54bd85 100644 --- a/libwallet/internal/vsp/feepayment.go +++ b/libwallet/internal/vsp/feepayment.go @@ -38,7 +38,7 @@ func randInt63() int64 { // It panics if n <= 0. func randInt63n(n int64) int64 { if n <= 0 { - panic("invalid argument to int63n") + panic("invalid argument to randInt63n") } n-- mask := int64(^uint64(0) >> bits.LeadingZeros64(uint64(n)))