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