From 784c64bacca1ccedbabfb5b0a86eccf56286973e Mon Sep 17 00:00:00 2001 From: Brian Stafford Date: Wed, 15 Nov 2023 13:49:53 -0600 Subject: [PATCH] work around ticket purchase bug --- client/asset/dcr/dcr.go | 144 ++++++++++++++-- client/asset/dcr/dcr_test.go | 160 +++++++++++++++++- client/asset/dcr/simnet_test.go | 5 +- client/asset/dcr/spv.go | 44 ----- client/asset/dcr/spv_test.go | 3 +- client/asset/interface.go | 49 ++++-- client/core/core.go | 20 +-- client/rpcserver/handlers.go | 14 +- client/rpcserver/handlers_test.go | 55 +++--- client/rpcserver/rpcserver.go | 2 +- client/rpcserver/rpcserver_test.go | 13 +- client/webserver/api.go | 11 +- client/webserver/live_test.go | 4 +- client/webserver/locales/en-us.go | 3 + client/webserver/site/src/css/wallets.scss | 4 + .../webserver/site/src/css/wallets_dark.scss | 4 + client/webserver/site/src/html/wallets.tmpl | 23 ++- client/webserver/site/src/js/locales.ts | 2 +- client/webserver/site/src/js/registry.ts | 13 +- client/webserver/site/src/js/wallets.ts | 77 +++++++-- client/webserver/webserver.go | 2 +- client/webserver/webserver_test.go | 4 +- 22 files changed, 475 insertions(+), 181 deletions(-) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index bd136a0bb2..1169b02a01 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -657,6 +657,12 @@ type ExchangeWallet struct { connected atomic.Bool subsidyCache *blockchain.SubsidyCache + + ticketBuyer struct { + running atomic.Bool + remaining atomic.Int32 + unconfirmedTickets map[chainhash.Hash]struct{} + } } func (dcr *ExchangeWallet) config() *exchangeWalletConfig { @@ -5019,6 +5025,7 @@ func (dcr *ExchangeWallet) StakeStatus() (*asset.TicketStakingStatus, error) { TicketCount: sinfo.OwnMempoolTix + sinfo.Unspent + sinfo.Immature + sinfo.Voted + sinfo.Revoked, Votes: sinfo.Voted, Revokes: sinfo.Revoked, + Queued: uint32(dcr.ticketBuyer.remaining.Load()), }, }, nil } @@ -5119,28 +5126,142 @@ func (dcr *ExchangeWallet) SetVSP(url string) error { // PurchaseTickets purchases n number of tickets. Part of the asset.TicketBuyer // interface. -func (dcr *ExchangeWallet) PurchaseTickets(n int, feeSuggestion uint64) ([]*asset.Ticket, error) { +func (dcr *ExchangeWallet) PurchaseTickets(n int, feeSuggestion uint64) error { if n < 1 { - return nil, nil + return nil } if !dcr.connected.Load() { - return nil, errors.New("not connected, login first") + return errors.New("not connected, login first") } // I think we need to set this, otherwise we probably end up with default // of DefaultRelayFeePerKb = 1e4 => 10 atoms/byte. feePerKB := dcrutil.Amount(dcr.feeRateWithFallback(feeSuggestion) * 1000) if err := dcr.wallet.SetTxFee(dcr.ctx, feePerKB); err != nil { - return nil, fmt.Errorf("error setting wallet tx fee: %w", err) + return fmt.Errorf("error setting wallet tx fee: %w", err) + } + + remain := dcr.ticketBuyer.remaining.Add(int32(n)) + dcr.emit.Data(ticketDataRoute, &TicketPurchaseUpdate{Remaining: uint32(remain)}) + go dcr.runTicketBuyer() + + return nil +} + +const ticketDataRoute = "ticketPurchaseUpdate" + +// TicketPurchaseUpdate is an update from the asynchronous ticket purchasing +// loop. +type TicketPurchaseUpdate struct { + Err string `json:"err,omitempty"` + Remaining uint32 `json:"remaining"` + Tickets []*asset.Ticket `json:"tickets"` +} + +// runTicketBuyer attempts to buy requested tickets. Because of a dcrwallet bug, +// its possible that (Wallet).PurchaseTickets will purchase fewer tickets than +// requested, without error. To work around this bug, we add requested tickets +// to ExchangeWallet.ticketBuyer.remaining, and re-run runTicketBuyer every +// block. +func (dcr *ExchangeWallet) runTicketBuyer() { + tb := &dcr.ticketBuyer + if !tb.running.CompareAndSwap(false, true) { + // already running + return + } + defer tb.running.Store(false) + var ok bool + defer func() { + if !ok { + tb.remaining.Store(0) + } + }() + + if tb.unconfirmedTickets == nil { + tb.unconfirmedTickets = make(map[chainhash.Hash]struct{}) + } + + remain := tb.remaining.Load() + if remain < 1 { + return + } + + var unconf int + for txHash := range tb.unconfirmedTickets { + tx, err := dcr.wallet.GetTransaction(dcr.ctx, &txHash) + if err != nil { + dcr.log.Errorf("GetTransaction error ticket tx %s: %v", txHash, err) + dcr.emit.Data(ticketDataRoute, &TicketPurchaseUpdate{Err: err.Error()}) + return + } + if tx.Confirmations > 0 { + delete(tb.unconfirmedTickets, txHash) + } else { + unconf++ + } + } + if unconf > 0 { + ok = true + dcr.log.Tracef("Skipping ticket purchase attempt because there are still %d unconfirmed tickets", unconf) + } + + dcr.log.Tracef("Attempting to purchase %d tickets", remain) + + bal, err := dcr.Balance() + if err != nil { + dcr.log.Errorf("GetBalance error: %v", err) + dcr.emit.Data(ticketDataRoute, &TicketPurchaseUpdate{Err: err.Error()}) + return + } + sinfo, err := dcr.wallet.StakeInfo(dcr.ctx) + if err != nil { + dcr.log.Errorf("StakeInfo error: %v", err) + dcr.emit.Data(ticketDataRoute, &TicketPurchaseUpdate{Err: err.Error()}) + return } + if dcrutil.Amount(bal.Available) < sinfo.Sdiff*dcrutil.Amount(remain) { + dcr.log.Errorf("Insufficient balance %s to purches %d ticket at price %s: %v", dcrutil.Amount(bal.Available), remain, sinfo.Sdiff) + dcr.emit.Data(ticketDataRoute, &TicketPurchaseUpdate{Err: "insufficient balance"}) + return + } + + var tickets []*asset.Ticket if !dcr.isNative() { - return dcr.wallet.PurchaseTickets(dcr.ctx, n, "", "") + tickets, err = dcr.wallet.PurchaseTickets(dcr.ctx, int(remain), "", "") + } else { + v := dcr.vspV.Load() + if v == nil { + err = errors.New("no vsp set") + } else { + vInfo := v.(*vsp) + tickets, err = dcr.wallet.PurchaseTickets(dcr.ctx, int(remain), vInfo.URL, vInfo.PubKey) + } } - v := dcr.vspV.Load() - if v == nil { - return nil, errors.New("no vsp set") + if err != nil { + dcr.log.Errorf("PurchaseTickets error: %v", err) + dcr.emit.Data(ticketDataRoute, &TicketPurchaseUpdate{Err: err.Error()}) + return } - vInfo := v.(*vsp) - return dcr.wallet.PurchaseTickets(dcr.ctx, n, vInfo.URL, vInfo.PubKey) + purchased := int32(len(tickets)) + remain = tb.remaining.Add(-purchased) + // sanity check + if remain < 0 { + remain = 0 + tb.remaining.Store(remain) + } + dcr.emit.Data(ticketDataRoute, &TicketPurchaseUpdate{ + Tickets: tickets, + Remaining: uint32(remain), + }) + for _, ticket := range tickets { + txHash, err := chainhash.NewHashFromStr(ticket.Tx.Hash) + if err != nil { + dcr.log.Errorf("NewHashFromStr error for ticket hash %s: %v", ticket.Tx.Hash, err) + dcr.emit.Data(ticketDataRoute, &TicketPurchaseUpdate{Err: err.Error()}) + return + } + tb.unconfirmedTickets[*txHash] = struct{}{} + } + ok = true } // SetVotingPreferences sets the vote choices for all active tickets and future @@ -5334,11 +5455,14 @@ func (dcr *ExchangeWallet) emitTipChange(height int64) { TicketCount: sinfo.OwnMempoolTix + sinfo.Unspent + sinfo.Immature + sinfo.Voted + sinfo.Revoked, Votes: sinfo.Voted, Revokes: sinfo.Revoked, + Queued: uint32(dcr.ticketBuyer.remaining.Load()), }, VotingSubsidy: dcr.voteSubsidy(height), } } + dcr.emit.TipChange(uint64(height), data) + dcr.runTicketBuyer() } // monitorBlocks pings for new blocks and runs the tipChange callback function diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index 2e44f3b695..df407b2f3c 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -16,6 +16,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "testing" "time" @@ -145,7 +146,7 @@ func tNewWalletMonitorBlocks(monitorBlocks bool) (*ExchangeWallet, *tRPCClient, client := newTRPCClient() log := tLogger.SubLogger("trpc") walletCfg := &asset.WalletConfig{ - Emit: asset.NewWalletEmitter(make(chan asset.WalletNotification, 128), BipID, log), + Emit: asset.NewWalletEmitter(client.emitC, BipID, log), PeersChange: func(uint32, error) {}, } walletCtx, shutdown := context.WithCancel(tCtx) @@ -217,6 +218,11 @@ type tRPCClient struct { lockedCoins []*wire.OutPoint // Last submitted to LockUnspent listLockedErr error estFeeErr error + emitC chan asset.WalletNotification + // tickets + purchasedTickets [][]*chainhash.Hash + purchaseTicketsErr error + stakeInfo walletjson.GetStakeInfoResult } type wireTxWithHeight struct { @@ -330,6 +336,7 @@ func newTRPCClient() *tRPCClient { signFunc: defaultSignFunc, rawRes: make(map[string]json.RawMessage), rawErr: make(map[string]error), + emitC: make(chan asset.WalletNotification, 128), } } @@ -520,13 +527,23 @@ func (c *tRPCClient) Disconnected() bool { } func (c *tRPCClient) GetStakeInfo(ctx context.Context) (*walletjson.GetStakeInfoResult, error) { - return &walletjson.GetStakeInfoResult{}, nil + return &c.stakeInfo, nil } func (c *tRPCClient) PurchaseTicket(ctx context.Context, fromAccount string, spendLimit dcrutil.Amount, minConf *int, ticketAddress stdaddr.Address, numTickets *int, poolAddress stdaddr.Address, poolFees *dcrutil.Amount, - expiry *int, ticketChange *bool, ticketFee *dcrutil.Amount) ([]*chainhash.Hash, error) { - return nil, nil + expiry *int, ticketChange *bool, ticketFee *dcrutil.Amount) (tix []*chainhash.Hash, _ error) { + + if c.purchaseTicketsErr != nil { + return nil, c.purchaseTicketsErr + } + + if len(c.purchasedTickets) > 0 { + tix = c.purchasedTickets[0] + c.purchasedTickets = c.purchasedTickets[1:] + } + + return tix, nil } func (c *tRPCClient) GetTickets(ctx context.Context, includeImmature bool) ([]*chainhash.Hash, error) { @@ -4380,3 +4397,138 @@ func TestConfirmRedemption(t *testing.T) { } } } + +func TestPurchaseTickets(t *testing.T) { + wallet, cl, shutdown := tNewWalletMonitorBlocks(false) + defer shutdown() + wallet.connected.Store(true) + cl.stakeInfo.Difficulty = dcrutil.Amount(1).ToCoin() + cl.balanceResult = &walletjson.GetBalanceResult{Balances: []walletjson.GetAccountBalanceResult{{AccountName: tAcctName}}} + setBalance := func(avail uint64, reserves uint64) { + cl.balanceResult.Balances[0].Spendable = dcrutil.Amount(avail).ToCoin() + wallet.bondReserves.Store(reserves) + } + + var blocksToConfirm atomic.Int64 + cl.walletTxFn = func() (*walletjson.GetTransactionResult, error) { + txHex, _ := makeTxHex(nil, []dex.Bytes{randBytes(25)}) + var confs int64 = 1 + if blocksToConfirm.Load() > 0 { + confs = 0 + } + return &walletjson.GetTransactionResult{Hex: txHex, Confirmations: confs}, nil + } + + var remains []uint32 + checkRemains := func(exp ...uint32) { + t.Helper() + if len(remains) != len(exp) { + t.Fatalf("wrong number of remains, wanted %d, got %+v", len(exp), remains) + } + for i := 0; i < len(remains); i++ { + if remains[i] != exp[i] { + t.Fatalf("wrong remains updates: wanted %+v, got %+v", exp, remains) + } + } + } + + waitForTicketLoopToExit := func() { + // Ensure the loop closes + timeout := time.After(time.Second) + for { + if !wallet.ticketBuyer.running.Load() { + break + } + select { + case <-time.After(time.Millisecond): + return + case <-timeout: + t.Fatalf("ticket loop didn't exit") + } + } + } + + buyTickets := func(n int, wantErr bool) { + defer waitForTicketLoopToExit() + remains = make([]uint32, 0) + if err := wallet.PurchaseTickets(n, 100); err != nil { + t.Fatalf("initial PurchaseTickets error: %v", err) + } + + var emitted int + timeout := time.After(time.Second) + out: + for { + var ni asset.WalletNotification + select { + case ni = <-cl.emitC: + case <-timeout: + t.Fatalf("timed out looking for ticket updates") + default: + blocksToConfirm.Add(-1) + wallet.runTicketBuyer() + continue + } + switch nt := ni.(type) { + case *asset.CustomWalletNote: + switch n := nt.Payload.(type) { + case *TicketPurchaseUpdate: + remains = append(remains, n.Remaining) + if n.Err != "" { + if wantErr { + return + } + t.Fatalf("Error received in TicketPurchaseUpdate: %s", n.Err) + } + if n.Remaining == 0 { + break out + } + emitted++ + } + + } + } + } + + tixHashes := func(n int) []*chainhash.Hash { + hs := make([]*chainhash.Hash, n) + for i := 0; i < n; i++ { + var ticketHash chainhash.Hash + copy(ticketHash[:], randBytes(32)) + hs[i] = &ticketHash + } + return hs + } + + // Single ticket purchased right away. + cl.purchasedTickets = [][]*chainhash.Hash{tixHashes(1)} + setBalance(1, 0) + buyTickets(1, false) + checkRemains(1, 0) + + // Multiple tickets purchased right away. + cl.purchasedTickets = [][]*chainhash.Hash{tixHashes(2)} + setBalance(2, 0) + buyTickets(2, false) + checkRemains(2, 0) + + // Two tickets, purchased in two tries, skipping some tries for unconfirmed + // tickets. + blocksToConfirm.Store(3) + cl.purchasedTickets = [][]*chainhash.Hash{tixHashes(1), tixHashes(1)} + buyTickets(2, false) + checkRemains(2, 1, 0) + + // (Wallet).PurchaseTickets error + cl.purchasedTickets = [][]*chainhash.Hash{tixHashes(4)} + cl.purchaseTicketsErr = errors.New("test error") + setBalance(4, 0) + buyTickets(4, true) + checkRemains(4, 0) + + // Low-balance error + cl.purchasedTickets = [][]*chainhash.Hash{tixHashes(1)} + setBalance(1, 1) // reserves make our available balance 0 + buyTickets(1, true) + checkRemains(1, 0) +} diff --git a/client/asset/dcr/simnet_test.go b/client/asset/dcr/simnet_test.go index 462c2c6735..4bf8e792b1 100644 --- a/client/asset/dcr/simnet_test.go +++ b/client/asset/dcr/simnet_test.go @@ -630,11 +630,10 @@ func testTickets(t *testing.T, isInternal bool, ew *ExchangeWallet) { if err := ew.Unlock(walletPassword); err != nil { t.Fatalf("unable to unlock wallet: %v", err) } - tickets, err := ew.PurchaseTickets(3, 20) - if err != nil { + + if err := ew.PurchaseTickets(3, 20); err != nil { t.Fatalf("error purchasing tickets: %v", err) } - tLogger.Infof("Purchased the following tickets: %v", tickets) var currentDeployments []chaincfg.ConsensusDeployment var bestVer uint32 diff --git a/client/asset/dcr/spv.go b/client/asset/dcr/spv.go index 68b09b1450..954bb31269 100644 --- a/client/asset/dcr/spv.go +++ b/client/asset/dcr/spv.go @@ -909,24 +909,6 @@ func (w *spvWallet) PurchaseTickets(ctx context.Context, n int, vspHost, vspPubK return nil, err } - // TODO: When purchasing N tickets with a VSP, if the wallet doesn't find a - // suitable already-existing output for each ticket + vsp fee = 2*N outputs - // https://github.com/decred/dcrwallet/blob/a87fa843495ec57c1d3b478c2ceb3876c3749af5/wallet/createtx.go#L1439-L1471 - // it will end up in the lowBalance loop, where the requested ticket count - // (req.Count) is reduced - // https://github.com/decred/dcrwallet/blob/a87fa843495ec57c1d3b478c2ceb3876c3749af5/wallet/createtx.go#L1499-L1501 - // before ultimately ending with a errVSPFeeRequiresUTXOSplit - // https://github.com/decred/dcrwallet/blob/a87fa843495ec57c1d3b478c2ceb3876c3749af5/wallet/createtx.go#L1537C17-L1537C43 - // which leads us into the special handling in (*Wallet).PurchaseTickets, - // where the requested ticket count is, unceremoniously, forced to 1. - // https://github.com/decred/dcrwallet/blob/a87fa843495ec57c1d3b478c2ceb3876c3749af5/wallet/wallet.go#L1725C15-L1725C15 - // - // tldr; The wallet will apparently not generate split outputs for vsp fees, - // so unless we have existing outputs of suitable size, will - // automatically reduce the requested ticket count to 1. - // - // How do we handle that? Is that a bug? - req := &wallet.PurchaseTicketsRequest{ Count: n, VSPFeePaymentProcess: vspClient.Process, @@ -934,32 +916,6 @@ func (w *spvWallet) PurchaseTickets(ctx context.Context, n int, vspHost, vspPubK // TODO: CSPP/mixing } - // This loop (+ minconf=0) doesn't work. Results in double spend errors when - // split tx outputs already spent in a previous loops tickets are somehow - // selected again for the next loop's split tx. - // - // ticketHashes := make([]*chainhash.Hash, 0, n) - // remain := n - // for remain > 0 { - // req.Count = remain - // res, err := w.dcrWallet.PurchaseTickets(ctx, w.spv, req) - // if err != nil { - // if len(ticketHashes) > 0 { - // w.log.Errorf("ticket loop error: %v", err) - // break - // } - // return nil, err - // } - // for _, tx := range res.Tickets { - // for _, txIn := range tx.TxIn { - // w.dcrWallet.LockOutpoint(&txIn.PreviousOutPoint.Hash, txIn.PreviousOutPoint.Index) - // } - // } - // w.log.Tracef("Purchased %d tickets. %d tickets requested. %d tickets left for this request.", len(res.TicketHashes), n, remain) - // ticketHashes = append(ticketHashes, res.TicketHashes...) - // remain -= len(res.TicketHashes) - // } - res, err := w.dcrWallet.PurchaseTickets(ctx, w.spv, req) if err != nil { return nil, err diff --git a/client/asset/dcr/spv_test.go b/client/asset/dcr/spv_test.go index 3cda395596..00836d111d 100644 --- a/client/asset/dcr/spv_test.go +++ b/client/asset/dcr/spv_test.go @@ -76,6 +76,7 @@ type tDcrWallet struct { spvBlocksErr error unlockedOutpoint *wire.OutPoint lockedOutpoint *wire.OutPoint + stakeInfo wallet.StakeInfoData } func (w *tDcrWallet) KnownAddress(ctx context.Context, a stdaddr.Address) (wallet.KnownAddress, error) { @@ -194,7 +195,7 @@ func (w *tDcrWallet) GetTransactionsByHashes(ctx context.Context, txHashes []*ch } func (w *tDcrWallet) StakeInfo(ctx context.Context) (*wallet.StakeInfoData, error) { - return &wallet.StakeInfoData{}, nil + return &w.stakeInfo, nil } func (w *tDcrWallet) PurchaseTickets(context.Context, wallet.NetworkBackend, *wallet.PurchaseTicketsRequest) (*wallet.PurchaseTicketsResponse, error) { diff --git a/client/asset/interface.go b/client/asset/interface.go index 7c5dad7d87..079d107ff6 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -963,6 +963,7 @@ type TicketStats struct { TicketCount uint32 `json:"ticketCount"` Votes uint32 `json:"votes"` Revokes uint32 `json:"revokes"` + Queued uint32 `json:"queued"` } // TicketStakingStatus holds various stake information from the wallet. @@ -996,9 +997,9 @@ type TicketBuyer interface { StakeStatus() (*TicketStakingStatus, error) // SetVSP sets the VSP provider. SetVSP(addr string) error - // PurchaseTickets purchases n amount of tickets. Returns the purchased - // ticket hashes if successful. - PurchaseTickets(n int, feeSuggestion uint64) ([]*Ticket, error) + // PurchaseTickets starts an aysnchronous process to purchase n tickets. + // Look for TicketPurchaseUpdate notifications to track the process. + PurchaseTickets(n int, feeSuggestion uint64) error // SetVotingPreferences sets default voting settings for all active // tickets and future tickets. Nil maps can be provided for no change. SetVotingPreferences(choices, tSpendPolicy, treasuryPolicy map[string]string) error @@ -1359,27 +1360,32 @@ type MultiOrder struct { // to convey. type WalletNotification any +type baseWalletNotification struct { + AssetID uint32 `json:"assetID"` + Route string `json:"route"` +} + // TipChangeNote is the only required wallet notification. All wallets should // emit a TipChangeNote when a state change occurs that might necessitate swap // progression or new balance checks. type TipChangeNote struct { - AssetID uint32 `json:"assetID"` - Tip uint64 `json:"tip"` - Data any `json:"data"` + baseWalletNotification + Tip uint64 `json:"tip"` + Data any `json:"data"` } // BalanceChangeNote can be sent when the wallet detects a balance change // between tip changes. type BalanceChangeNote struct { - AssetID uint32 + baseWalletNotification Balance *Balance } // CustomWalletNote is any other information the wallet wishes to convey to // the user. type CustomWalletNote struct { - AssetID uint32 `json:"assetID"` - Payload any `json:"payload"` + baseWalletNotification + Payload any `json:"payload"` } // WalletEmitter handles a channel for wallet notifications and provides methods @@ -1408,8 +1414,13 @@ func (e *WalletEmitter) emit(note WalletNotification) { } // Data sends a CustomWalletNote with the specified data payload. -func (e *WalletEmitter) Data(payload any) { - e.emit(&CustomWalletNote{AssetID: e.assetID, Payload: payload}) +func (e *WalletEmitter) Data(route string, payload any) { + e.emit(&CustomWalletNote{ + baseWalletNotification: baseWalletNotification{ + AssetID: e.assetID, + Route: route, + }, Payload: payload, + }) } // TipChange sends a TipChangeNote with optional extra data. @@ -1418,10 +1429,22 @@ func (e *WalletEmitter) TipChange(tip uint64, datas ...any) { if len(datas) > 0 { data = datas[0] } - e.emit(&TipChangeNote{AssetID: e.assetID, Tip: tip, Data: data}) + e.emit(&TipChangeNote{ + baseWalletNotification: baseWalletNotification{ + AssetID: e.assetID, + Route: "tipChange", + }, + Tip: tip, Data: data, + }) } // BalanceChange sends a BalanceChangeNote. func (e *WalletEmitter) BalanceChange(bal *Balance) { - e.emit(&BalanceChangeNote{AssetID: e.assetID, Balance: bal}) + e.emit(&BalanceChangeNote{ + baseWalletNotification: baseWalletNotification{ + AssetID: e.assetID, + Route: "balanceChange", + }, + Balance: bal, + }) } diff --git a/client/core/core.go b/client/core/core.go index 89c95219d0..569cfaceee 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -10774,29 +10774,29 @@ func (c *Core) SetVSP(assetID uint32, addr string) error { // PurchaseTickets purchases n tickets. Returns the purchased ticket hashes if // successful. Used for ticket purchasing. -func (c *Core) PurchaseTickets(assetID uint32, pw []byte, n int) ([]*asset.Ticket, error) { +func (c *Core) PurchaseTickets(assetID uint32, pw []byte, n int) error { wallet, tb, err := c.stakingWallet(assetID) if err != nil { - return nil, err + return err } crypter, err := c.encryptionKey(pw) if err != nil { - return nil, fmt.Errorf("password error: %w", err) + return fmt.Errorf("password error: %w", err) } defer crypter.Close() - err = c.connectAndUnlock(crypter, wallet) - if err != nil { - return nil, err + + if err = c.connectAndUnlock(crypter, wallet); err != nil { + return err } - tickets, err := tb.PurchaseTickets(n, c.feeSuggestionAny(assetID)) - if err != nil { - return nil, err + + if err = tb.PurchaseTickets(n, c.feeSuggestionAny(assetID)); err != nil { + return err } c.updateAssetBalance(assetID) // TODO: Send tickets bought notification. //subject, details := c.formatDetails(TopicSendSuccess, sentValue, unbip(assetID), address, coin) //c.notify(newSendNote(TopicSendSuccess, subject, details, db.Success)) - return tickets, nil + return nil } // SetVotingPreferences sets default voting settings for all active tickets and diff --git a/client/rpcserver/handlers.go b/client/rpcserver/handlers.go index b00fb4812a..b0b09303df 100644 --- a/client/rpcserver/handlers.go +++ b/client/rpcserver/handlers.go @@ -975,19 +975,13 @@ func handlePurchaseTickets(s *RPCServer, params *RawParams) *msgjson.ResponsePay } defer form.appPass.Clear() - tickets, err := s.core.PurchaseTickets(form.assetID, form.appPass, form.num) - if err != nil { + if err = s.core.PurchaseTickets(form.assetID, form.appPass, form.num); err != nil { errMsg := fmt.Sprintf("unable to purchase tickets: %v", err) resErr := msgjson.NewError(msgjson.RPCPurchaseTicketsError, errMsg) return createResponse(purchaseTicketsRoute, nil, resErr) } - hashes := make([]string, len(tickets)) - for i, tkt := range tickets { - hashes[i] = tkt.Tx.Hash - } - - return createResponse(purchaseTicketsRoute, hashes, nil) + return createResponse(purchaseTicketsRoute, true, nil) } func handleStakeStatus(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { @@ -1737,14 +1731,14 @@ an spv wallet and enables options to view and set the vsp. purchaseTicketsRoute: { pwArgsShort: `"appPass"`, argsShort: `assetID num`, - cmdSummary: `Purchase some tickets.`, + cmdSummary: `Starts a asyncrhonous ticket purchasing process. Check stakestatus for number of tickets remaining to be purchased.`, pwArgsLong: `Password Args: appPass (string): The DEX client password.`, argsLong: `Args: assetID (int): The asset's BIP-44 registered coin index. num (int): The number of tickets to purchase`, returns: `Returns: - array: An array of ticket hashes.`, + bool: true is the only non-error return value`, }, setVotingPreferencesRoute: { argsShort: `assetID (choicesMap) (tSpendPolicyMap) (treasuryPolicyMap)`, diff --git a/client/rpcserver/handlers_test.go b/client/rpcserver/handlers_test.go index ab94f4b9a0..f6a9ddde11 100644 --- a/client/rpcserver/handlers_test.go +++ b/client/rpcserver/handlers_test.go @@ -1347,42 +1347,25 @@ func TestPurchaseTickets(t *testing.T) { "2", }, } - tickets := []string{"txidA", "txidB"} - tests := []struct { - name string - params *RawParams - purchaseTicketsErr error - purchaseTickets []string - wantErrCode int - }{{ - name: "ok", - params: params, - purchaseTickets: tickets, - wantErrCode: -1, - }, { - name: "core.PurchaseTickets error", - params: params, - purchaseTicketsErr: errors.New("error"), - wantErrCode: msgjson.RPCPurchaseTicketsError, - }, { - name: "bad params", - params: &RawParams{}, - wantErrCode: msgjson.RPCArgumentsError, - }} - for _, test := range tests { - tc := &TCore{ - purchaseTickets: test.purchaseTickets, - purchaseTicketsErr: test.purchaseTicketsErr, - } - r := &RPCServer{core: tc} - payload := handlePurchaseTickets(r, test.params) - res := new([]string) - if err := verifyResponse(payload, &res, test.wantErrCode); err != nil { - t.Fatal(err) - } - if test.wantErrCode == -1 && len(*res) != 2 { - t.Fatalf("expected two tickets but got %d", len(*res)) - } + tc := &TCore{} + r := &RPCServer{core: tc} + payload := handlePurchaseTickets(r, params) + var res bool + err := verifyResponse(payload, &res, -1) + if err != nil { + t.Fatal(err) + } + if err = verifyResponse(payload, &res, -1); err != nil { + t.Fatal(err) + } + if res != true { + t.Fatal("result is false") + } + + tc.purchaseTicketsErr = errors.New("test error") + payload = handlePurchaseTickets(r, params) + if err = verifyResponse(payload, &res, msgjson.RPCPurchaseTicketsError); err != nil { + t.Fatal(err) } } diff --git a/client/rpcserver/rpcserver.go b/client/rpcserver/rpcserver.go index 4991e5a59d..8843f4b61a 100644 --- a/client/rpcserver/rpcserver.go +++ b/client/rpcserver/rpcserver.go @@ -90,7 +90,7 @@ type clientCore interface { // These are core's ticket buying interface. StakeStatus(assetID uint32) (*asset.TicketStakingStatus, error) SetVSP(assetID uint32, addr string) error - PurchaseTickets(assetID uint32, pw []byte, n int) ([]*asset.Ticket, error) + PurchaseTickets(assetID uint32, pw []byte, n int) error SetVotingPreferences(assetID uint32, choices, tSpendPolicy, treasuryPolicy map[string]string) error } diff --git a/client/rpcserver/rpcserver_test.go b/client/rpcserver/rpcserver_test.go index c75f63ada6..94895d125a 100644 --- a/client/rpcserver/rpcserver_test.go +++ b/client/rpcserver/rpcserver_test.go @@ -68,7 +68,6 @@ type TCore struct { archivedRecords int deleteArchivedRecordsErr error setVSPErr error - purchaseTickets []string purchaseTicketsErr error stakeStatus *asset.TicketStakingStatus stakeStatusErr error @@ -184,16 +183,8 @@ func (c *TCore) MultiTrade(appPass []byte, form *core.MultiTradeForm) ([]*core.O func (c *TCore) SetVSP(assetID uint32, addr string) error { return c.setVSPErr } -func (c *TCore) PurchaseTickets(assetID uint32, pw []byte, n int) ([]*asset.Ticket, error) { - tickets := make([]*asset.Ticket, len(c.purchaseTickets)) - for i, h := range c.purchaseTickets { - tickets[i] = &asset.Ticket{ - Tx: asset.TicketTransaction{ - Hash: h, - }, - } - } - return tickets, c.purchaseTicketsErr +func (c *TCore) PurchaseTickets(assetID uint32, pw []byte, n int) error { + return c.purchaseTicketsErr } func (c *TCore) StakeStatus(assetID uint32) (*asset.TicketStakingStatus, error) { return c.stakeStatus, c.stakeStatusErr diff --git a/client/webserver/api.go b/client/webserver/api.go index 062aff0556..d5f9ac1a94 100644 --- a/client/webserver/api.go +++ b/client/webserver/api.go @@ -1711,18 +1711,11 @@ func (s *WebServer) apiPurchaseTickets(w http.ResponseWriter, r *http.Request) { s.writeAPIError(w, fmt.Errorf("password error: %w", err)) return } - tickets, err := s.core.PurchaseTickets(req.AssetID, appPW, req.N) - if err != nil { + if err = s.core.PurchaseTickets(req.AssetID, appPW, req.N); err != nil { s.writeAPIError(w, fmt.Errorf("error purchasing tickets for asset ID %d: %w", req.AssetID, err)) return } - writeJSON(w, &struct { - OK bool `json:"ok"` - Tickets []*asset.Ticket `json:"tickets"` - }{ - OK: true, - Tickets: tickets, - }, s.indent) + writeJSON(w, simpleAck(), s.indent) } func (s *WebServer) apiSetVotingPreferences(w http.ResponseWriter, r *http.Request) { diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index 1e549d49f5..5530c5b727 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -2015,8 +2015,8 @@ func (c *TCore) SetVSP(assetID uint32, addr string) error { return nil } -func (c *TCore) PurchaseTickets(assetID uint32, pw []byte, n int) ([]*asset.Ticket, error) { - return nil, nil +func (c *TCore) PurchaseTickets(assetID uint32, pw []byte, n int) error { + return nil } func (c *TCore) SetVotingPreferences(assetID uint32, choices, tSpendPolicy, treasuryPolicy map[string]string) error { diff --git a/client/webserver/locales/en-us.go b/client/webserver/locales/en-us.go index 49a6c6d9f2..f708f905f2 100644 --- a/client/webserver/locales/en-us.go +++ b/client/webserver/locales/en-us.go @@ -453,6 +453,9 @@ var EnUS = map[string]string{ "Ticket": "Ticket", "Staking": "Staking", "Active tickets": "Active tickets", + "Currently Queued": "Currently Queued", + "Immature tickets": "Immature tickets", + "Queued tickets": "Queued tickets", "Tickets bought": "Tickets bought", "Total rewards": "Total rewards", "Votes cast": "Votes cast", diff --git a/client/webserver/site/src/css/wallets.scss b/client/webserver/site/src/css/wallets.scss index a55b888a79..565f21596c 100644 --- a/client/webserver/site/src/css/wallets.scss +++ b/client/webserver/site/src/css/wallets.scss @@ -296,6 +296,10 @@ .flex-wrap-lg { flex-wrap: wrap; } + + #purchaseTicketsErrBox { + background-color: $light_body_bg; + } } @include media-breakpoint-up(md) { diff --git a/client/webserver/site/src/css/wallets_dark.scss b/client/webserver/site/src/css/wallets_dark.scss index d279eabdaf..5c13d48ccf 100644 --- a/client/webserver/site/src/css/wallets_dark.scss +++ b/client/webserver/site/src/css/wallets_dark.scss @@ -37,6 +37,10 @@ body.dark { border-color: $dark_border_color; } } + + #purchaseTicketsErrBox { + background-color: $dark_body_bg; + } } #assetSelect { diff --git a/client/webserver/site/src/html/wallets.tmpl b/client/webserver/site/src/html/wallets.tmpl index 3f2abe581b..ce948ccfe3 100644 --- a/client/webserver/site/src/html/wallets.tmpl +++ b/client/webserver/site/src/html/wallets.tmpl @@ -145,6 +145,14 @@
[[[Active tickets]]]
+
+
[[[Immature tickets]]]
+
+
+
+
[[[Queued tickets]]]
+
+
[[[Tickets bought]]]
@@ -211,6 +219,10 @@
+
+
+
+
{{- /* END STAKING */ -}} {{/* END WALLET DETAILS */}} @@ -737,17 +749,22 @@
[[[Purchase Tickets]]]
-
-
+
+
[[[Current Price]]]
a
-
+
[[[Available Balance]]]
a
+
+ [[[Currently Queued]]] +
+ a +

diff --git a/client/webserver/site/src/js/locales.ts b/client/webserver/site/src/js/locales.ts index e6985f17b7..e0ead5674e 100644 --- a/client/webserver/site/src/js/locales.ts +++ b/client/webserver/site/src/js/locales.ts @@ -291,7 +291,7 @@ export const enUS: Locale = { [ID_CREATING_WALLETS]: 'Creating wallets', [ID_ADDING_SERVERS]: 'Connecting to servers', [ID_WALLET_RECOVERY_SUPPORT_MSG]: 'Native {{ walletSymbol }} wallet failed to load properly. Try clicking the "Recover" button below to fix it', - [ID_TICKETS_PURCHASED]: 'Purchased {{ n }} Tickets!', + [ID_TICKETS_PURCHASED]: 'Purchasing {{ n }} Tickets!', [ID_TICKET_STATUS_UNKNOWN]: 'unknown', [ID_TICKET_STATUS_UNMINED]: 'unmined', [ID_TICKET_STATUS_IMMATURE]: 'immature', diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts index 72b7852005..1ff46899df 100644 --- a/client/webserver/site/src/js/registry.ts +++ b/client/webserver/site/src/js/registry.ts @@ -374,16 +374,24 @@ export interface WalletCreationNote extends CoreNote { assetID: number } -export interface TipChangeNote { +export interface BaseWalletNote { + route: string assetID: number +} + +export interface TipChangeNote extends BaseWalletNote { tip: number data: any } -export interface WalletNote extends CoreNote { +export interface CustomWalletNote extends BaseWalletNote { payload: any } +export interface WalletNote extends CoreNote { + payload: BaseWalletNote +} + export interface SpotPriceNote extends CoreNote { host: string spots: Record @@ -763,6 +771,7 @@ export interface TicketStats{ ticketCount: number votes: number revokes: number + queued: number } export interface TicketStakingStatus { diff --git a/client/webserver/site/src/js/wallets.ts b/client/webserver/site/src/js/wallets.ts index 656c414c94..8ae39d71a8 100644 --- a/client/webserver/site/src/js/wallets.ts +++ b/client/webserver/site/src/js/wallets.ts @@ -23,7 +23,9 @@ import { Order, OrderFilter, WalletCreationNote, + BaseWalletNote, WalletNote, + CustomWalletNote, TipChangeNote, Market, PeerSource, @@ -45,6 +47,12 @@ interface DecredTicketTipUpdate { stats: TicketStats } +interface TicketPurchaseUpdate extends BaseWalletNote { + err?: string + remaining:number + tickets?: Ticket[] +} + const animationLength = 300 const traitRescanner = 1 const traitLogFiler = 1 << 2 @@ -236,6 +244,7 @@ export default class WalletsPage extends BasePage { Doc.bind(page.ticketHistoryNextPage, 'click', () => { this.nextTicketPage() }) Doc.bind(page.ticketHistoryPrevPage, 'click', () => { this.prevTicketPage() }) Doc.bind(page.setVotes, 'click', () => { this.showSetVotesDialog() }) + Doc.bind(page.purchaseTicketsErrCloser, 'click', () => { Doc.hide(page.purchaseTicketsErrBox) }) // New deposit address button. this.depositAddrForm = new DepositAddress(page.deposit) @@ -943,8 +952,17 @@ export default class WalletsPage extends BasePage { updateTicketStats (stats: TicketStats, ui: UnitInfo, ticketPrice?: number, votingSubsidy?: number) { const { page, stakeStatus } = this + stakeStatus.stats = stats + if (ticketPrice) stakeStatus.ticketPrice = ticketPrice + if (votingSubsidy) stakeStatus.votingSubsidy = votingSubsidy const liveTicketCount = stakeStatus.tickets.filter((tkt: Ticket) => tkt.status <= ticketStatusLive && tkt.status >= ticketStatusUnmined).length page.stakingTicketCount.textContent = String(liveTicketCount) + const immatureTicketCount = stakeStatus.tickets.filter((tkt: Ticket) => tkt.status === ticketStatusUnmined).length + page.immatureTicketCount.textContent = String(immatureTicketCount) + Doc.setVis(immatureTicketCount > 0, page.immatureTicketCountBox) + page.queuedTicketCount.textContent = String(stats.queued) + page.formQueuedTix.textContent = String(stats.queued) + Doc.setVis(stats.queued > 0, page.formQueueTixBox, page.queuedTicketCountBox) page.totalTicketCount.textContent = String(stats.ticketCount) page.totalTicketRewards.textContent = Doc.formatFourSigFigs(stats.totalRewards / ui.conventional.conversionFactor) page.totalTicketVotes.textContent = String(stats.votes) @@ -1002,7 +1020,7 @@ export default class WalletsPage extends BasePage { } async purchaseTickets () { - const { page, selectedAssetID: assetID, stakeStatus } = this + const { page, selectedAssetID: assetID } = this // DRAFT NOTE: The user will get an actual ticket count somewhere in the // range 1 <= tickets_purchased <= n. See notes in // (*spvWallet).PurchaseTickets. @@ -1019,13 +1037,30 @@ export default class WalletsPage extends BasePage { Doc.show(page.purchaserErr) return } + this.showSuccess(intl.prep(intl.ID_TICKETS_PURCHASED, { n: n.toLocaleString(navigator.languages) })) + } - const tickets = res.tickets as Ticket[] - stakeStatus.stats.ticketCount += tickets.length - stakeStatus.tickets = tickets.concat(stakeStatus.tickets) - this.updateTicketStats(stakeStatus.stats, app().unitInfo(assetID)) - - this.showSuccess(intl.prep(intl.ID_TICKETS_PURCHASED, { n: tickets.length.toLocaleString(navigator.languages) })) + processTicketPurchaseUpdate (walletNote: CustomWalletNote) { + const { stakeStatus, selectedAssetID, page } = this + const { assetID } = walletNote + const { err, remaining, tickets } = walletNote.payload as TicketPurchaseUpdate + if (assetID !== selectedAssetID) return + if (tickets) { + stakeStatus.stats.ticketCount += tickets.length + stakeStatus.tickets = tickets.concat(stakeStatus.tickets) + } + stakeStatus.stats.queued = remaining + page.queuedTicketCount.textContent = String(remaining) + page.formQueuedTix.textContent = String(remaining) + Doc.setVis(remaining > 0, page.queuedTicketCountBox) + let immature = stakeStatus.tickets.filter((tkt: Ticket) => tkt.status === ticketStatusUnmined).length + immature += tickets?.length ?? 0 + page.immatureTicketCount.textContent = String(immature) + Doc.setVis(immature > 0, page.immatureTicketCountBox) + if (err) { + Doc.show(page.purchaseTicketsErrBox) + page.purchaseTicketsErr.textContent = err + } } async setVSP (assetID: number, vsp: VotingServiceProvider) { @@ -1904,19 +1939,25 @@ export default class WalletsPage extends BasePage { } handleCustomWalletNote (note: WalletNote) { - const payload = note.payload - if (payload.tip) { - const n = payload as TipChangeNote - switch (n.assetID) { - case 42: { // dcr - if (!this.stakeStatus) return - const data = n.data as DecredTicketTipUpdate - const synced = app().walletMap[n.assetID].synced - if (synced) { - const ui = app().unitInfo(n.assetID) - this.updateTicketStats(data.stats, ui, data.ticketPrice, data.votingSubsidy) + const walletNote = note.payload as BaseWalletNote + switch (walletNote.route) { + case 'tipChange': { + const n = walletNote as TipChangeNote + switch (n.assetID) { + case 42: { // dcr + if (!this.stakeStatus) return + const data = n.data as DecredTicketTipUpdate + const synced = app().walletMap[n.assetID].synced + if (synced) { + const ui = app().unitInfo(n.assetID) + this.updateTicketStats(data.stats, ui, data.ticketPrice, data.votingSubsidy) + } } } + break + } + case 'ticketPurchaseUpdate': { + this.processTicketPurchaseUpdate(walletNote as CustomWalletNote) } } } diff --git a/client/webserver/webserver.go b/client/webserver/webserver.go index f23b9e084a..b2ec2bb76a 100644 --- a/client/webserver/webserver.go +++ b/client/webserver/webserver.go @@ -165,7 +165,7 @@ type clientCore interface { ApproveTokenFee(assetID uint32, version uint32, approval bool) (uint64, error) StakeStatus(assetID uint32) (*asset.TicketStakingStatus, error) SetVSP(assetID uint32, addr string) error - PurchaseTickets(assetID uint32, pw []byte, n int) ([]*asset.Ticket, error) + PurchaseTickets(assetID uint32, pw []byte, n int) error SetVotingPreferences(assetID uint32, choices, tSpendPolicy, treasuryPolicy map[string]string) error ListVSPs(assetID uint32) ([]*asset.VotingServiceProvider, error) TicketPage(assetID uint32, scanStart int32, n, skipN int) ([]*asset.Ticket, error) diff --git a/client/webserver/webserver_test.go b/client/webserver/webserver_test.go index 796be94db8..3c9417483a 100644 --- a/client/webserver/webserver_test.go +++ b/client/webserver/webserver_test.go @@ -314,8 +314,8 @@ func (c *TCore) SetVSP(assetID uint32, addr string) error { return nil } -func (c *TCore) PurchaseTickets(assetID uint32, appPW []byte, n int) ([]*asset.Ticket, error) { - return nil, nil +func (c *TCore) PurchaseTickets(assetID uint32, appPW []byte, n int) error { + return nil } func (c *TCore) SetVotingPreferences(assetID uint32, choices, tSpendPolicy, treasuryPolicy map[string]string) error {