From 1b79545ab5af6adacb1a3bb295169a8807328244 Mon Sep 17 00:00:00 2001 From: buck54321 Date: Thu, 31 Aug 2023 17:38:58 -0700 Subject: [PATCH] multi: gui staking (#2482) * add gui for staking --- client/asset/dcr/dcr.go | 190 +++++++-- client/asset/dcr/dcr_test.go | 4 + client/asset/dcr/rpcwallet.go | 116 ++++- client/asset/dcr/simnet_test.go | 36 +- client/asset/dcr/spv.go | 277 +++++++++--- client/asset/dcr/spv_test.go | 6 + client/asset/dcr/wallet.go | 13 +- client/asset/interface.go | 66 ++- client/core/core.go | 28 +- client/rpcserver/handlers.go | 30 +- client/rpcserver/handlers_test.go | 2 +- client/rpcserver/rpcserver.go | 2 +- client/rpcserver/rpcserver_test.go | 14 +- client/webserver/api.go | 123 ++++++ client/webserver/live_test.go | 24 ++ client/webserver/site/src/css/icons.scss | 4 + client/webserver/site/src/css/main.scss | 5 + client/webserver/site/src/css/wallets.scss | 42 ++ .../webserver/site/src/css/wallets_dark.scss | 18 + client/webserver/site/src/font/icomoon.svg | 1 + client/webserver/site/src/font/icomoon.ttf | Bin 7284 -> 9088 bytes client/webserver/site/src/font/icomoon.woff | Bin 7360 -> 9164 bytes .../webserver/site/src/html/bodybuilder.tmpl | 4 +- client/webserver/site/src/html/wallets.tmpl | 258 ++++++++++- client/webserver/site/src/js/locales.ts | 22 +- client/webserver/site/src/js/order.ts | 17 +- client/webserver/site/src/js/registry.ts | 79 ++++ client/webserver/site/src/js/wallets.ts | 402 +++++++++++++++++- client/webserver/webserver.go | 13 + client/webserver/webserver_test.go | 23 + 30 files changed, 1627 insertions(+), 192 deletions(-) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 4d5b651364..3c42955dcc 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -34,7 +34,7 @@ import ( walletjson "decred.org/dcrwallet/v3/rpc/jsonrpc/types" _ "decred.org/dcrwallet/v3/wallet/drivers/bdb" "github.com/decred/dcrd/blockchain/stake/v5" - "github.com/decred/dcrd/blockchain/standalone/v2" + blockchain "github.com/decred/dcrd/blockchain/standalone/v2" "github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/dcrec" @@ -649,6 +649,8 @@ type ExchangeWallet struct { vspV atomic.Value // *vsp connected atomic.Bool + + subsidyCache *blockchain.SubsidyCache } func (dcr *ExchangeWallet) config() *exchangeWalletConfig { @@ -888,6 +890,7 @@ func unconnectedWallet(cfg *asset.WalletConfig, dcrCfg *walletConfig, chainParam mempoolRedeems: make(map[[32]byte]*mempoolRedeem), vspFilepath: vspFilepath, walletType: cfg.Type, + subsidyCache: blockchain.NewSubsidyCache(chainParams), } if b, err := os.ReadFile(vspFilepath); err == nil { @@ -3460,7 +3463,7 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, reb if err != nil { return nil, fmt.Errorf("invalid contract tx data: %w", err) } - if err = standalone.CheckTransactionSanity(contractTx, uint64(dcr.chainParams.MaxTxSize)); err != nil { + if err = blockchain.CheckTransactionSanity(contractTx, uint64(dcr.chainParams.MaxTxSize)); err != nil { return nil, fmt.Errorf("invalid contract tx data: %w", err) } if checkHash := contractTx.TxHash(); checkHash != *txHash { @@ -5146,14 +5149,53 @@ func (dcr *ExchangeWallet) isNative() bool { return dcr.walletType == walletTypeSPV } +// currentAgendas gets the most recent agendas from the chain params. The caller +// must populate the CurrentChoice field of the agendas. +func currentAgendas(chainParams *chaincfg.Params) (agendas []*asset.TBAgenda) { + var bestID uint32 + for deploymentID := range chainParams.Deployments { + if bestID == 0 || deploymentID > bestID { + bestID = deploymentID + } + } + for _, deployment := range chainParams.Deployments[bestID] { + v := deployment.Vote + agenda := &asset.TBAgenda{ + ID: v.Id, + Description: v.Description, + } + for _, choice := range v.Choices { + agenda.Choices = append(agenda.Choices, &asset.TBChoice{ + ID: choice.Id, + Description: choice.Description, + }) + } + agendas = append(agendas, agenda) + } + return +} + func (dcr *ExchangeWallet) StakeStatus() (*asset.TicketStakingStatus, error) { if !dcr.connected.Load() { return nil, errors.New("not connected, login first") } - sdiff, err := dcr.wallet.StakeDiff(dcr.ctx) + // Try to get tickets first, because this will error for RPC + SPV wallets. + tickets, err := dcr.tickets(dcr.ctx) + if err != nil { + return nil, fmt.Errorf("error retrieving tickets: %w", err) + } + sinfo, err := dcr.wallet.StakeInfo(dcr.ctx) if err != nil { return nil, err } + // Chance of a given ticket voting in a block is + // p = chainParams.TicketsPerBlock / (chainParams.TicketPoolSize * chainParams.TicketsPerBlock) + // = 1 / chainParams.TicketPoolSize + // Expected number of blocks to vote is + // 1 / p = chainParams.TicketPoolSize + expectedBlocksToVote := int64(dcr.chainParams.TicketPoolSize) + voteHeightExpectationValue := dcr.cachedBestBlock().height + expectedBlocksToVote + voteSubsidy := dcr.subsidyCache.CalcStakeVoteSubsidyV3(voteHeightExpectationValue, blockchain.SSVDCP0012) isRPC := !dcr.isNative() var vspURL string if !isRPC { @@ -5161,27 +5203,74 @@ func (dcr *ExchangeWallet) StakeStatus() (*asset.TicketStakingStatus, error) { vspURL = v.(*vsp).URL } } - tickets, err := dcr.wallet.Tickets(dcr.ctx) - if err != nil { - return nil, fmt.Errorf("error retrieving tickets: %w", err) - } - voteChoices, tSpendPolicy, treasuryPolicy, err := dcr.wallet.VotingPreferences(dcr.ctx) + voteChoices, tSpends, treasuryPolicy, err := dcr.wallet.VotingPreferences(dcr.ctx) if err != nil { return nil, fmt.Errorf("error retrieving stances: %w", err) } + agendas := currentAgendas(dcr.chainParams) + for _, agenda := range agendas { + for _, c := range voteChoices { + if c.AgendaID == agenda.ID { + agenda.CurrentChoice = c.ChoiceID + break + } + } + } + return &asset.TicketStakingStatus{ - TicketPrice: uint64(sdiff), - VSP: vspURL, - IsRPC: isRPC, - Tickets: tickets, + TicketPrice: uint64(sinfo.Sdiff), + VotingSubsidy: uint64(voteSubsidy), + VSP: vspURL, + IsRPC: isRPC, + Tickets: tickets, Stances: asset.Stances{ - VoteChoices: voteChoices, - TSpendPolicy: tSpendPolicy, - TreasuryPolicy: treasuryPolicy, + Agendas: agendas, + TreasurySpends: tSpends, + TreasuryKeys: treasuryPolicy, + }, + Stats: asset.TicketStats{ + TotalRewards: uint64(sinfo.TotalSubsidy), + TicketCount: sinfo.OwnMempoolTix + sinfo.Unspent + sinfo.Immature + sinfo.Voted + sinfo.Revoked, + Votes: sinfo.Voted, + Revokes: sinfo.Revoked, }, }, nil } +// tickets gets tickets from the wallet and changes the status of "unspent" +// tickets that haven't reached expiration "live". +// DRAFT NOTE: From dcrwallet: +// +// TicketStatusUnspent is a matured ticket that has not been spent. It +// is only used under SPV mode where it is unknown if a ticket is live, +// was missed, or expired. +// +// But if the ticket has not reached a certain number of confirmations, we +// can say for sure it's not expired. With auto-revocations, "missed" or +// "expired" tickets are actually "revoked", I think. +// The only thing I can't figure out is how SPV wallets set the spender in the +// case of an auto-revocation. It might be happening here +// https://github.com/decred/dcrwallet/blob/a87fa843495ec57c1d3b478c2ceb3876c3749af5/wallet/chainntfns.go#L770-L775 +// If we're seeing auto-revocations, we're fine to make the changes in this +// method. +func (dcr *ExchangeWallet) tickets(ctx context.Context) ([]*asset.Ticket, error) { + tickets, err := dcr.wallet.Tickets(ctx) + if err != nil { + return nil, fmt.Errorf("error retrieving tickets: %w", err) + } + // Adjust status for SPV tickets that aren't expired. + oldestTicketsBlock := dcr.cachedBestBlock().height - int64(dcr.chainParams.TicketExpiry) - int64(dcr.chainParams.TicketMaturity) + for _, t := range tickets { + if t.Status != asset.TicketStatusUnspent { + continue + } + if t.Tx.BlockHeight == -1 || t.Tx.BlockHeight > oldestTicketsBlock { + t.Status = asset.TicketStatusLive + } + } + return tickets, nil +} + func vspInfo(url string) (*vspdjson.VspInfoResponse, error) { suffix := "/api/v3/vspinfo" path, err := neturl.JoinPath(url, suffix) @@ -5233,13 +5322,19 @@ 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) ([]string, error) { +func (dcr *ExchangeWallet) PurchaseTickets(n int, feeSuggestion uint64) ([]*asset.Ticket, error) { if n < 1 { return nil, nil } if !dcr.connected.Load() { return nil, 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) + } if !dcr.isNative() { return dcr.wallet.PurchaseTickets(dcr.ctx, n, "", "") } @@ -5263,6 +5358,29 @@ func (dcr *ExchangeWallet) SetVotingPreferences(choices map[string]string, tspen // ListVSPs lists known available voting service providers. func (dcr *ExchangeWallet) ListVSPs() ([]*asset.VotingServiceProvider, error) { + if dcr.network == dex.Simnet { + const simnetVSPUrl = "http://127.0.0.1:19591" + vspi, err := vspInfo(simnetVSPUrl) + if err != nil { + dcr.log.Warnf("Error getting simnet VSP info: %v", err) + return []*asset.VotingServiceProvider{}, nil + } + return []*asset.VotingServiceProvider{{ + URL: simnetVSPUrl, + Network: dex.Simnet, + Launched: uint64(time.Now().Add(-time.Hour * 24 * 180).UnixMilli()), + LastUpdated: uint64(time.Now().Add(-time.Minute * 15).UnixMilli()), + APIVersions: vspi.APIVersions, + FeePercentage: vspi.FeePercentage, + Closed: vspi.VspClosed, + Voting: vspi.Voting, + Voted: vspi.Voted, + Revoked: vspi.Revoked, + VSPDVersion: vspi.VspdVersion, + BlockHeight: vspi.BlockHeight, + NetShare: vspi.NetworkProportion, + }}, nil + } resp, err := http.Get("https://api.decred.org/?c=vsp") if err != nil { return nil, fmt.Errorf("http get error: %v", err) @@ -5274,18 +5392,18 @@ func (dcr *ExchangeWallet) ListVSPs() ([]*asset.VotingServiceProvider, error) { // This struct is not quite compatible with vspdjson.VspInfoResponse. var res map[string]*struct { - Network string `json:"network"` - Launched uint64 `json:"launched"` // seconds - LastUpdated uint64 `json:"lastupdated"` // seconds - APIVersions []uint32 `json:"apiversions"` - FeePercentage float64 `json:"feepercentage"` - Closed bool `json:"closed"` - Voting uint64 `json:"voting"` - Voted uint64 `json:"voted"` - Revoked uint64 `json:"revoked"` - VSPDVersion string `json:"vspdversion"` - BlockHeight uint64 `json:"blockheight"` - NetShare float64 `json:"estimatednetworkproportion"` + Network string `json:"network"` + Launched uint64 `json:"launched"` // seconds + LastUpdated uint64 `json:"lastupdated"` // seconds + APIVersions []int64 `json:"apiversions"` + FeePercentage float64 `json:"feepercentage"` + Closed bool `json:"closed"` + Voting int64 `json:"voting"` + Voted int64 `json:"voted"` + Revoked int64 `json:"revoked"` + VSPDVersion string `json:"vspdversion"` + BlockHeight uint32 `json:"blockheight"` + NetShare float32 `json:"estimatednetworkproportion"` } if err = json.Unmarshal(b, &res); err != nil { return nil, err @@ -5319,6 +5437,22 @@ func (dcr *ExchangeWallet) ListVSPs() ([]*asset.VotingServiceProvider, error) { return vspds, nil } +// TicketPage fetches a page of tickets within a range of block numbers with a +// target page size and optional offset. scanStart is the block in which to +// start the scan. The scan progresses in reverse block number order, starting +// at scanStart and going to progressively lower blocks. scanStart can be set to +// -1 to indicate the current chain tip. +func (dcr *ExchangeWallet) TicketPage(scanStart int32, n, skipN int) ([]*asset.Ticket, error) { + if !dcr.connected.Load() { + return nil, errors.New("not connected, login first") + } + pager, is := dcr.wallet.(ticketPager) + if !is { + return nil, errors.New("ticket pagination not supported for this wallet") + } + return pager.TicketPage(dcr.ctx, scanStart, n, skipN) +} + func (dcr *ExchangeWallet) broadcastTx(signedTx *wire.MsgTx) (*chainhash.Hash, error) { txHash, err := dcr.wallet.SendRawTransaction(dcr.ctx, signedTx, false) if err != nil { diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index edcc5426c5..8ba42d949d 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -688,6 +688,10 @@ func (c *tRPCClient) RawRequest(_ context.Context, method string, params []json. return nil, fmt.Errorf("method %v not implemented by (*tRPCClient).RawRequest", method) } +func (c *tRPCClient) SetTxFee(ctx context.Context, fee dcrutil.Amount) error { + return nil +} + func TestMain(m *testing.M) { tChainParams = chaincfg.MainNetParams() tPKHAddr, _ = stdaddr.DecodeAddress("DsTya4cCFBgtofDLiRhkyPYEQjgs3HnarVP", tChainParams) diff --git a/client/asset/dcr/rpcwallet.go b/client/asset/dcr/rpcwallet.go index fe66ae8562..ec123f99a1 100644 --- a/client/asset/dcr/rpcwallet.go +++ b/client/asset/dcr/rpcwallet.go @@ -20,6 +20,7 @@ import ( "decred.org/dcrdex/dex" "decred.org/dcrwallet/v3/rpc/client/dcrwallet" walletjson "decred.org/dcrwallet/v3/rpc/jsonrpc/types" + "decred.org/dcrwallet/v3/wallet" "github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/dcrec/secp256k1/v4" @@ -145,6 +146,7 @@ type rpcClient interface { GetTickets(ctx context.Context, includeImmature bool) ([]*chainhash.Hash, error) GetVoteChoices(ctx context.Context) (*walletjson.GetVoteChoicesResult, error) SetVoteChoice(ctx context.Context, agendaID, choiceID string) error + SetTxFee(ctx context.Context, fee dcrutil.Amount) error } // newRPCWallet creates an rpcClient and uses it to construct a new instance @@ -871,30 +873,92 @@ func (w *rpcWallet) AddressPrivKey(ctx context.Context, address stdaddr.Address) return &priv, nil } -// StakeDiff returns the current stake difficulty. -func (w *rpcWallet) StakeDiff(ctx context.Context) (dcrutil.Amount, error) { - si, err := w.rpcClient.GetStakeInfo(ctx) +// StakeInfo returns the current gestakeinfo results. +func (w *rpcWallet) StakeInfo(ctx context.Context) (*wallet.StakeInfoData, error) { + res, err := w.rpcClient.GetStakeInfo(ctx) if err != nil { - return 0, err + return nil, err } - amt, err := dcrutil.NewAmount(si.Difficulty) + sdiff, err := dcrutil.NewAmount(res.Difficulty) if err != nil { - return 0, err + return nil, err } - return amt, nil + totalSubsidy, err := dcrutil.NewAmount(res.TotalSubsidy) + if err != nil { + return nil, err + } + return &wallet.StakeInfoData{ + BlockHeight: res.BlockHeight, + TotalSubsidy: totalSubsidy, + Sdiff: sdiff, + OwnMempoolTix: res.OwnMempoolTix, + Unspent: res.Unspent, + Voted: res.Voted, + Revoked: res.Revoked, + UnspentExpired: res.UnspentExpired, + PoolSize: res.PoolSize, + AllMempoolTix: res.AllMempoolTix, + Immature: res.Immature, + Live: res.Live, + Missed: res.Missed, + Expired: res.Expired, + }, nil } // PurchaseTickets purchases n amount of tickets. Returns the purchased ticket // hashes if successful. -func (w *rpcWallet) PurchaseTickets(ctx context.Context, n int, _, _ string) ([]string, error) { - hashes, err := w.rpcClient.PurchaseTicket(ctx, "default", 0 /*spendLimit dcrutil.Amount*/, nil, /*minConf *int*/ - nil /*ticketAddress stdaddr.Address*/, &n, nil /*poolAddress stdaddr.Address*/, nil, /*poolFees *dcrutil.Amount*/ - nil /*expiry *int*/, nil /*ticketChange *bool*/, nil /*ticketFee *dcrutil.Amount*/) - hashStrs := make([]string, len(hashes)) - for i := range hashes { - hashStrs[i] = hashes[i].String() - } - return hashStrs, err +func (w *rpcWallet) PurchaseTickets(ctx context.Context, n int, _, _ string) ([]*asset.Ticket, error) { + hashes, err := w.rpcClient.PurchaseTicket( + ctx, + "default", + 0, // spendLimit + nil, // minConf + nil, // ticketAddress + &n, // numTickets + nil, // poolAddress + nil, // poolFees + nil, // expiry + nil, // ticketChange + nil, // ticketFee + ) + if err != nil { + return nil, err + } + + now := uint64(time.Now().Unix()) + tickets := make([]*asset.Ticket, len(hashes)) + for i, h := range hashes { + // Need to get the ticket price + tx, err := w.rpcClient.GetTransaction(ctx, h) + if err != nil { + return nil, fmt.Errorf("error getting transaction for new ticket %s: %w", h, err) + } + msgTx, err := msgTxFromHex(tx.Hex) + if err != nil { + return nil, fmt.Errorf("error decoding ticket %s tx hex: %v", h, err) + } + if len(msgTx.TxOut) == 0 { + return nil, fmt.Errorf("malformed ticket transaction %s", h) + } + var fees uint64 + for _, txIn := range msgTx.TxIn { + fees += uint64(txIn.ValueIn) + } + for _, txOut := range msgTx.TxOut { + fees -= uint64(txOut.Value) + } + tickets[i] = &asset.Ticket{ + Tx: asset.TicketTransaction{ + Hash: h.String(), + TicketPrice: uint64(msgTx.TxOut[0].Value), + Fees: fees, + Stamp: now, + BlockHeight: -1, + }, + Status: asset.TicketStatusUnmined, + } + } + return tickets, nil } // Tickets returns active tickets. @@ -941,7 +1005,7 @@ func (w *rpcWallet) Tickets(ctx context.Context) ([]*asset.Ticket, error) { feeAmt, _ := dcrutil.NewAmount(-tx.Fee) tickets = append(tickets, &asset.Ticket{ - Ticket: asset.TicketTransaction{ + Tx: asset.TicketTransaction{ Hash: h.String(), TicketPrice: uint64(msgTx.TxOut[0].Value), Fees: uint64(feeAmt), @@ -962,7 +1026,7 @@ func (w *rpcWallet) Tickets(ctx context.Context) ([]*asset.Ticket, error) { } // VotingPreferences returns current wallet voting preferences. -func (w *rpcWallet) VotingPreferences(ctx context.Context) ([]*walletjson.VoteChoice, []*walletjson.TSpendPolicyResult, []*walletjson.TreasuryPolicyResult, error) { +func (w *rpcWallet) VotingPreferences(ctx context.Context) ([]*walletjson.VoteChoice, []*asset.TBTreasurySpend, []*walletjson.TreasuryPolicyResult, error) { // Get consensus vote choices. choices, err := w.rpcClient.GetVoteChoices(ctx) if err != nil { @@ -980,10 +1044,14 @@ func (w *rpcWallet) VotingPreferences(ctx context.Context) ([]*walletjson.VoteCh if err != nil { return nil, nil, nil, fmt.Errorf("unable to get treasury spend policy: %v", err) } - tSpendPolicy := make([]*walletjson.TSpendPolicyResult, len(tSpendRes)) - for i, v := range tSpendRes { - tp := v - tSpendPolicy[i] = &tp + tSpendPolicy := make([]*asset.TBTreasurySpend, len(tSpendRes)) + for i, tp := range tSpendRes { + // TODO: Find a way to get the tspend total value? Probably only + // possible with a full node and txindex. + tSpendPolicy[i] = &asset.TBTreasurySpend{ + Hash: tp.Hash, + CurrentPolicy: tp.Policy, + } } // Get treasury voting policy. const treasuryPolicyMethod = "treasurypolicy" @@ -1025,6 +1093,10 @@ func (w *rpcWallet) SetVotingPreferences(ctx context.Context, choices, tSpendPol return nil } +func (w *rpcWallet) SetTxFee(ctx context.Context, feePerKB dcrutil.Amount) error { + return w.rpcClient.SetTxFee(ctx, feePerKB) +} + // anylist is a list of RPC parameters to be converted to []json.RawMessage and // sent via nodeRawRequest. type anylist []interface{} diff --git a/client/asset/dcr/simnet_test.go b/client/asset/dcr/simnet_test.go index 803d7305fe..01b5385248 100644 --- a/client/asset/dcr/simnet_test.go +++ b/client/asset/dcr/simnet_test.go @@ -619,7 +619,7 @@ 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) + tickets, err := ew.PurchaseTickets(3, 20) if err != nil { t.Fatalf("error purchasing tickets: %v", err) } @@ -679,25 +679,25 @@ func testTickets(t *testing.T, isInternal bool, ew *ExchangeWallet) { tLogger.Info("The following are stake status after setting vsp and purchasing tickets.") spew.Dump(ss) - if len(ss.Stances.VoteChoices) != len(choices) { - t.Fatalf("wrong number of vote choices. expected %d, got %d", len(choices), len(ss.Stances.VoteChoices)) + if len(ss.Stances.Agendas) != len(choices) { + t.Fatalf("wrong number of vote choices. expected %d, got %d", len(choices), len(ss.Stances.Agendas)) } - for _, reportedChoice := range ss.Stances.VoteChoices { - choiceID, found := choices[reportedChoice.AgendaID] + for _, agenda := range ss.Stances.Agendas { + choiceID, found := choices[agenda.ID] if !found { - t.Fatalf("unknown agenda %s", reportedChoice.AgendaID) + t.Fatalf("unknown agenda %s", agenda.ID) } - if reportedChoice.ChoiceID != choiceID { - t.Fatalf("wrong choice reported. expected %s, got %s", choiceID, reportedChoice.ChoiceID) + if agenda.CurrentChoice != choiceID { + t.Fatalf("wrong choice reported. expected %s, got %s", choiceID, agenda.CurrentChoice) } } - if len(ss.Stances.TreasuryPolicy) != len(treasuryPolicy) { - t.Fatalf("wrong number of treasury keys. expected %d, got %d", len(treasuryPolicy), len(ss.Stances.TreasuryPolicy)) + if len(ss.Stances.TreasuryKeys) != len(treasuryPolicy) { + t.Fatalf("wrong number of treasury keys. expected %d, got %d", len(treasuryPolicy), len(ss.Stances.TreasuryKeys)) } - for _, tp := range ss.Stances.TreasuryPolicy { + for _, tp := range ss.Stances.TreasuryKeys { policy, found := treasuryPolicy[tp.Key] if !found { t.Fatalf("unknown treasury key %s", tp.Key) @@ -707,17 +707,17 @@ func testTickets(t *testing.T, isInternal bool, ew *ExchangeWallet) { } } - if len(ss.Stances.TSpendPolicy) != len(tspendPolicy) { - t.Fatalf("wrong number of tspends. expected %d, got %d", len(tspendPolicy), len(ss.Stances.TSpendPolicy)) + if len(ss.Stances.TreasurySpends) != len(tspendPolicy) { + t.Fatalf("wrong number of tspends. expected %d, got %d", len(tspendPolicy), len(ss.Stances.TreasurySpends)) } - for _, p := range ss.Stances.TSpendPolicy { - policy, found := tspendPolicy[p.Hash] + for _, tspend := range ss.Stances.TreasurySpends { + policy, found := tspendPolicy[tspend.Hash] if !found { - t.Fatalf("unknown tspend tx %s", p.Hash) + t.Fatalf("unknown tspend tx %s", tspend.Hash) } - if p.Policy != policy { - t.Fatalf("wrong policy reported. expected %s, got %s", policy, p.Policy) + if tspend.CurrentPolicy != policy { + t.Fatalf("wrong policy reported. expected %s, got %s", policy, tspend.CurrentPolicy) } } } diff --git a/client/asset/dcr/spv.go b/client/asset/dcr/spv.go index f7b4e266d9..080d54a507 100644 --- a/client/asset/dcr/spv.go +++ b/client/asset/dcr/spv.go @@ -92,6 +92,8 @@ type dcrWallet interface { SetAgendaChoices(ctx context.Context, ticketHash *chainhash.Hash, choices ...wallet.AgendaChoice) (voteBits uint16, err error) SetTSpendPolicy(ctx context.Context, tspendHash *chainhash.Hash, policy stake.TreasuryVoteT, ticketHash *chainhash.Hash) error SetTreasuryKeyPolicy(ctx context.Context, pikey []byte, policy stake.TreasuryVoteT, ticketHash *chainhash.Hash) error + SetRelayFee(relayFee dcrutil.Amount) + GetTicketInfo(ctx context.Context, hash *chainhash.Hash) (*wallet.TicketSummary, *wire.BlockHeader, error) vspclient.Wallet // TODO: Rescan and DiscoverActiveAddresses can be used for a Rescanner. } @@ -240,6 +242,42 @@ func createSPVWallet(pw, seed []byte, dataDir string, extIdx, intIdx uint32, cha return nil } +// If we're running on simnet, add some tspends and treasury keys. +func (w *spvWallet) initializeSimnetTspends(ctx context.Context) { + if w.chainParams.Net != wire.SimNet { + return + } + tspendWallet, is := w.dcrWallet.(interface { + AddTSpend(tx wire.MsgTx) error + GetAllTSpends(ctx context.Context) []*wire.MsgTx + SetTreasuryKeyPolicy(ctx context.Context, pikey []byte, policy stake.TreasuryVoteT, ticketHash *chainhash.Hash) error + TreasuryKeyPolicies() []wallet.TreasuryKeyPolicy + }) + if !is { + return + } + const numFakeTspends = 3 + if len(tspendWallet.GetAllTSpends(ctx)) >= numFakeTspends { + return + } + expiryBase := uint32(time.Now().Add(time.Hour * 24 * 365).Unix()) + for i := uint32(0); i < numFakeTspends; i++ { + var signatureScript [100]byte + tx := &wire.MsgTx{ + Expiry: expiryBase + i, + TxIn: []*wire.TxIn{wire.NewTxIn(&wire.OutPoint{}, 0, signatureScript[:])}, + TxOut: []*wire.TxOut{{Value: int64(i+1) * 1e8}}, + } + if err := tspendWallet.AddTSpend(*tx); err != nil { + w.log.Errorf("Error adding simnet tspend: %v", err) + } + } + if len(tspendWallet.TreasuryKeyPolicies()) == 0 { + priv, _ := secp256k1.GeneratePrivateKey() + tspendWallet.SetTreasuryKeyPolicy(ctx, priv.PubKey().SerializeCompressed(), 0x01 /* yes */, nil) + } +} + func (w *spvWallet) Reconfigure(ctx context.Context, cfg *asset.WalletConfig, net dex.Network, currentAddress, depositAccount string) (restart bool, err error) { return cfg.Type != walletTypeSPV, nil } @@ -288,6 +326,8 @@ func (w *spvWallet) startWallet(ctx context.Context) error { w.notesLoop(ctx, dcrw) }() + w.initializeSimnetTspends(ctx) + return nil } @@ -834,13 +874,9 @@ func (w *spvWallet) AddressPrivKey(ctx context.Context, addr stdaddr.Address) (* return privKey, err } -// StakeDiff returns the current stake difficulty. -func (w *spvWallet) StakeDiff(ctx context.Context) (dcrutil.Amount, error) { - si, err := w.dcrWallet.StakeInfo(ctx) - if err != nil { - return 0, err - } - return si.Sdiff, nil +// StakeInfo returns the current stake info. +func (w *spvWallet) StakeInfo(ctx context.Context) (*wallet.StakeInfoData, error) { + return w.dcrWallet.StakeInfo(ctx) } func newVSPClient(w vspclient.Wallet, vspHost, vspPubKey string, log dex.Logger) (*vspclient.AutoClient, error) { @@ -859,95 +895,162 @@ func newVSPClient(w vspclient.Wallet, vspHost, vspPubKey string, log dex.Logger) // PurchaseTickets purchases n tickets, tells the provided vspd to monitor the // ticket, and pays the vsp fee. -func (w *spvWallet) PurchaseTickets(ctx context.Context, n int, vspHost, vspPubKey string) ([]string, error) { +func (w *spvWallet) PurchaseTickets(ctx context.Context, n int, vspHost, vspPubKey string) ([]*asset.Ticket, error) { vspClient, err := newVSPClient(w.dcrWallet, vspHost, vspPubKey, w.log.SubLogger("VSP")) if err != nil { return nil, err } - request := &wallet.PurchaseTicketsRequest{ + + // 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, - MinConf: 1, VSPFeePaymentProcess: vspClient.Process, VSPFeeProcess: vspClient.FeePercentage, // TODO: CSPP/mixing } - res, err := w.dcrWallet.PurchaseTickets(ctx, w.spv, request) + + // 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 } - hashes := res.TicketHashes - hashStrs := make([]string, len(hashes)) - for i := range hashes { - hashStrs[i] = hashes[i].String() + + tickets := make([]*asset.Ticket, len(res.TicketHashes)) + for i, h := range res.TicketHashes { + w.log.Debugf("Purchased ticket %s", h) + ticketSummary, hdr, err := w.dcrWallet.GetTicketInfo(ctx, h) + if err != nil { + return nil, fmt.Errorf("error fetching info for new ticket") + } + ticket := ticketSummaryToAssetTicket(ticketSummary, hdr, w.log) + if ticket == nil { + return nil, fmt.Errorf("invalid ticket summary for %s", h) + } + tickets[i] = ticket } - return hashStrs, err + return tickets, err } +const ( + upperHeightMempool = -1 + lowerHeightAutomatic = -1 + pageSizeUnlimited = 0 +) + // Tickets returns current active tickets. func (w *spvWallet) Tickets(ctx context.Context) ([]*asset.Ticket, error) { - return w.ticketsInRange(ctx, 0, 0) + return w.ticketsInRange(ctx, lowerHeightAutomatic, upperHeightMempool, pageSizeUnlimited, 0) } -func (w *spvWallet) ticketsInRange(ctx context.Context, fromHeight, toHeight int32) ([]*asset.Ticket, error) { - params := w.ChainParams() +var _ ticketPager = (*spvWallet)(nil) - tickets := make([]*asset.Ticket, 0) +func (w *spvWallet) TicketPage(ctx context.Context, scanStart int32, n, skipN int) ([]*asset.Ticket, error) { + if scanStart == -1 { + _, scanStart = w.MainChainTip(ctx) + } + return w.ticketsInRange(ctx, 0, scanStart, n, skipN) +} + +func (w *spvWallet) ticketsInRange(ctx context.Context, lowerHeight, upperHeight int32, maxN, skipN /* 0 = mempool */ int) ([]*asset.Ticket, error) { + p := w.chainParams + var startBlock, endBlock *wallet.BlockIdentifier // null endBlock goes through mempool + // If mempool is included, there is no way to scan backwards. + includeMempool := upperHeight == upperHeightMempool + if includeMempool { + _, upperHeight = w.MainChainTip(ctx) + } else { + endBlock = wallet.NewBlockIdentifierFromHeight(upperHeight) + } + if lowerHeight == lowerHeightAutomatic { + bn := upperHeight - int32(p.TicketExpiry+uint32(p.TicketMaturity)) + startBlock = wallet.NewBlockIdentifierFromHeight(bn) + } else { + startBlock = wallet.NewBlockIdentifierFromHeight(lowerHeight) + } + + // If not looking at mempool, we can reverse iteration order by swapping + // start and end blocks. + if endBlock != nil { + startBlock, endBlock = endBlock, startBlock + } - // TODO: This does not seem to return tickets withought confirmations. - // Investigate this and return unconfirmed tickets with block height - // set to -1 if possible. + tickets := make([]*asset.Ticket, 0) + var skipped int processTicket := func(ticketSummaries []*wallet.TicketSummary, hdr *wire.BlockHeader) (bool, error) { for _, ticketSummary := range ticketSummaries { - spender := "" - if ticketSummary.Spender != nil { - spender = ticketSummary.Spender.Hash.String() + if skipped < skipN { + skipped++ + continue } - - if ticketSummary.Ticket == nil || len(ticketSummary.Ticket.MyOutputs) < 1 { - w.log.Errorf("No zeroth output") + if ticket := ticketSummaryToAssetTicket(ticketSummary, hdr, w.log); ticket != nil { + tickets = append(tickets, ticket) } - var blockHeight int64 = -1 - if hdr != nil { - blockHeight = int64(hdr.Height) + if maxN > 0 && len(tickets) >= maxN { + return true, nil } - - tickets = append(tickets, &asset.Ticket{ - Ticket: asset.TicketTransaction{ - Hash: ticketSummary.Ticket.Hash.String(), - TicketPrice: uint64(ticketSummary.Ticket.MyOutputs[0].Amount), - Fees: uint64(ticketSummary.Ticket.Fee), - Stamp: uint64(ticketSummary.Ticket.Timestamp), - BlockHeight: blockHeight, - }, - Status: asset.TicketStatus(ticketSummary.Status), - Spender: spender, - }) } return false, nil } - const requiredConfs = 6 + 2 - endBlockNum := toHeight - if endBlockNum == 0 { - _, endBlockNum = w.MainChainTip(ctx) - } - startBlockNum := fromHeight - if startBlockNum == 0 { - startBlockNum = endBlockNum - - int32(params.TicketExpiry+uint32(params.TicketMaturity)-requiredConfs) - } - startBlock := wallet.NewBlockIdentifierFromHeight(startBlockNum) - endBlock := wallet.NewBlockIdentifierFromHeight(endBlockNum) if err := w.dcrWallet.GetTickets(ctx, processTicket, startBlock, endBlock); err != nil { return nil, err } + + // If this is a mempool scan, we cannot scan backwards, so reverse the + // result order. + if includeMempool { + reverseSlice(tickets) + } + return tickets, nil } // VotingPreferences returns current voting preferences. -func (w *spvWallet) VotingPreferences(ctx context.Context) ([]*walletjson.VoteChoice, []*walletjson.TSpendPolicyResult, []*walletjson.TreasuryPolicyResult, error) { +func (w *spvWallet) VotingPreferences(ctx context.Context) ([]*walletjson.VoteChoice, []*asset.TBTreasurySpend, []*walletjson.TreasuryPolicyResult, error) { _, agendas := wallet.CurrentAgendas(w.chainParams) choices, _, err := w.dcrWallet.AgendaChoices(ctx, nil) @@ -981,15 +1084,20 @@ func (w *spvWallet) VotingPreferences(ctx context.Context) ([]*walletjson.VoteCh return policy } tspends := w.dcrWallet.GetAllTSpends(ctx) - tSpendPolicy := make([]*walletjson.TSpendPolicyResult, 0, len(tspends)) + tSpendPolicy := make([]*asset.TBTreasurySpend, 0, len(tspends)) for i := range tspends { - tspendHash := tspends[i].TxHash() - p := w.dcrWallet.TSpendPolicy(&tspendHash, nil) - r := walletjson.TSpendPolicyResult{ - Hash: tspendHash.String(), - Policy: policyToStr(p), + msgTx := tspends[i] + tspendHash := msgTx.TxHash() + var val uint64 + for _, txOut := range msgTx.TxOut { + val += uint64(txOut.Value) } - tSpendPolicy = append(tSpendPolicy, &r) + p := w.dcrWallet.TSpendPolicy(&tspendHash, nil) + tSpendPolicy = append(tSpendPolicy, &asset.TBTreasurySpend{ + Hash: tspendHash.String(), + CurrentPolicy: policyToStr(p), + Value: val, + }) } policies := w.dcrWallet.TreasuryKeyPolicies() @@ -1080,6 +1188,7 @@ func (w *spvWallet) SetVotingPreferences(ctx context.Context, choices, tspendPol } clientCache := make(map[string]*vspclient.AutoClient) // Set voting preferences for VSPs. Continuing for all errors. + // NOTE: Doing this in an unmetered loop like this is a privacy breaker. return w.dcrWallet.ForUnspentUnexpiredTickets(ctx, func(hash *chainhash.Hash) error { vspHost, err := w.dcrWallet.VSPHostForTicket(ctx, hash) if err != nil { @@ -1114,6 +1223,11 @@ func (w *spvWallet) SetVotingPreferences(ctx context.Context, choices, tspendPol }) } +func (w *spvWallet) SetTxFee(_ context.Context, feePerKB dcrutil.Amount) error { + w.dcrWallet.SetRelayFee(feePerKB) + return nil +} + // cacheBlock caches a block for future use. The block has a lastAccess stamp // added, and will be discarded if not accessed again within 2 hours. func (w *spvWallet) cacheBlock(block *wire.MsgBlock) { @@ -1297,3 +1411,38 @@ func initLogging(netDir string) error { return nil } + +func reverseSlice[T any](s []T) { + for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { + s[i], s[j] = s[j], s[i] + } +} + +func ticketSummaryToAssetTicket(ticketSummary *wallet.TicketSummary, hdr *wire.BlockHeader, log dex.Logger) *asset.Ticket { + spender := "" + if ticketSummary.Spender != nil { + spender = ticketSummary.Spender.Hash.String() + } + + if ticketSummary.Ticket == nil || len(ticketSummary.Ticket.MyOutputs) < 1 { + log.Errorf("No zeroth output") + return nil + } + + var blockHeight int64 = -1 + if hdr != nil { + blockHeight = int64(hdr.Height) + } + + return &asset.Ticket{ + Tx: asset.TicketTransaction{ + Hash: ticketSummary.Ticket.Hash.String(), + TicketPrice: uint64(ticketSummary.Ticket.MyOutputs[0].Amount), + Fees: uint64(ticketSummary.Ticket.Fee), + Stamp: uint64(ticketSummary.Ticket.Timestamp), + BlockHeight: blockHeight, + }, + Status: asset.TicketStatus(ticketSummary.Status), + Spender: spender, + } +} diff --git a/client/asset/dcr/spv_test.go b/client/asset/dcr/spv_test.go index f713095a5b..fee346e3b6 100644 --- a/client/asset/dcr/spv_test.go +++ b/client/asset/dcr/spv_test.go @@ -333,6 +333,12 @@ func (w *tDcrWallet) SignMessage(ctx context.Context, msg string, addr stdaddr.A return nil, nil } +func (w *tDcrWallet) SetRelayFee(relayFee dcrutil.Amount) {} + +func (w *tDcrWallet) GetTicketInfo(ctx context.Context, hash *chainhash.Hash) (*wallet.TicketSummary, *wire.BlockHeader, error) { + return nil, nil, nil +} + func tNewSpvWallet() (*spvWallet, *tDcrWallet) { dcrw := &tDcrWallet{ blockHeader: make(map[chainhash.Hash]*wire.BlockHeader), diff --git a/client/asset/dcr/wallet.go b/client/asset/dcr/wallet.go index 74e7ab5f5a..112971bfd4 100644 --- a/client/asset/dcr/wallet.go +++ b/client/asset/dcr/wallet.go @@ -10,6 +10,7 @@ import ( "decred.org/dcrdex/client/asset" "decred.org/dcrdex/dex" walletjson "decred.org/dcrwallet/v3/rpc/jsonrpc/types" + "decred.org/dcrwallet/v3/wallet" "github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/dcrec/secp256k1/v4" @@ -144,20 +145,20 @@ type Wallet interface { PeerCount(ctx context.Context) (uint32, error) // AddressPrivKey fetches the privkey for the specified address. AddressPrivKey(ctx context.Context, address stdaddr.Address) (*secp256k1.PrivateKey, error) - // StakeDiff gets the stake difficulty. - StakeDiff(ctx context.Context) (dcrutil.Amount, error) // PurchaseTickets purchases n tickets. vspHost and vspPubKey only // needed for internal wallets. - PurchaseTickets(ctx context.Context, n int, vspHost, vspPubKey string) ([]string, error) + PurchaseTickets(ctx context.Context, n int, vspHost, vspPubKey string) ([]*asset.Ticket, error) // Tickets returns current active ticket hashes up until they are voted // or revoked. Includes unconfirmed tickets. Tickets(ctx context.Context) ([]*asset.Ticket, error) // VotingPreferences returns current voting preferences. - VotingPreferences(ctx context.Context) ([]*walletjson.VoteChoice, []*walletjson.TSpendPolicyResult, []*walletjson.TreasuryPolicyResult, error) + VotingPreferences(ctx context.Context) ([]*walletjson.VoteChoice, []*asset.TBTreasurySpend, []*walletjson.TreasuryPolicyResult, error) // SetVotingPreferences sets preferences used when a ticket is chosen to // be voted on. SetVotingPreferences(ctx context.Context, choices, tspendPolicy, treasuryPolicy map[string]string) error + SetTxFee(ctx context.Context, feePerKB dcrutil.Amount) error Reconfigure(ctx context.Context, cfg *asset.WalletConfig, net dex.Network, currentAddress, depositAccount string) (restart bool, err error) + StakeInfo(ctx context.Context) (*wallet.StakeInfoData, error) } // WalletTransaction is a pared down version of walletjson.GetTransactionResult. @@ -190,6 +191,10 @@ type Mempooler interface { GetRawMempool(ctx context.Context) ([]*chainhash.Hash, error) } +type ticketPager interface { + TicketPage(ctx context.Context, scanStart int32, n, skipN int) ([]*asset.Ticket, error) +} + // TxOutput defines properties of a transaction output, including the // details of the block containing the tx, if mined. type TxOutput struct { diff --git a/client/asset/interface.go b/client/asset/interface.go index 940f831bbf..a114c678a1 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -911,33 +911,63 @@ const ( // Ticket holds information about a decred ticket. type Ticket struct { - Ticket TicketTransaction `json:"ticket"` + Tx TicketTransaction `json:"tx"` Status TicketStatus `json:"status"` Spender string `json:"spender"` } -// Stances are vote choices. +// TBChoice is a possible agenda choice for a TicketBuyer. +type TBChoice struct { + ID string `json:"id"` + Description string `json:"description"` +} + +// TBAgenda is an agenda that the TicketBuyer can vote on. +type TBAgenda struct { + ID string `json:"id"` + Description string `json:"description"` + CurrentChoice string `json:"currentChoice"` + Choices []*TBChoice `json:"choices"` +} + +// TBTreasurySpend represents a treasury spend that the TicketBuyer can vote on. +type TBTreasurySpend struct { + Hash string `json:"hash"` + Value uint64 `json:"value"` + CurrentPolicy string `json:"currentPolicy"` +} + +// Stances are current policy preferences for the TicketBuyer. type Stances struct { - VoteChoices []*dcrwalletjson.VoteChoice `json:"voteChoices"` - TSpendPolicy []*dcrwalletjson.TSpendPolicyResult `json:"tSpendPolicy"` - TreasuryPolicy []*dcrwalletjson.TreasuryPolicyResult `json:"treasuryPolicy"` + Agendas []*TBAgenda `json:"agendas"` + TreasurySpends []*TBTreasurySpend `json:"tspends"` + TreasuryKeys []*dcrwalletjson.TreasuryPolicyResult `json:"treasuryKeys"` } // VotingServiceProvider is information about a voting service provider. type VotingServiceProvider struct { - URL string `json:"host"` + URL string `json:"url"` Network dex.Network `json:"network"` Launched uint64 `json:"launched"` // milliseconds LastUpdated uint64 `json:"lastUpdated"` // milliseconds - APIVersions []uint32 `json:"apiVersions"` + APIVersions []int64 `json:"apiVersions"` FeePercentage float64 `json:"feePercentage"` Closed bool `json:"closed"` - Voting uint64 `json:"voting"` - Voted uint64 `json:"voted"` - Revoked uint64 `json:"revoked"` + Voting int64 `json:"voting"` + Voted int64 `json:"voted"` + Revoked int64 `json:"revoked"` VSPDVersion string `json:"vspdVersion"` - BlockHeight uint64 `json:"blockHeight"` - NetShare float64 `json:"netShare"` + BlockHeight uint32 `json:"blockHeight"` + NetShare float32 `json:"netShare"` +} + +// TicketStats sums up some statistics for historical staking data for a +// TicketBuyer. +type TicketStats struct { + TotalRewards uint64 `json:"totalRewards"` + TicketCount uint32 `json:"ticketCount"` + Votes uint32 `json:"votes"` + Revokes uint32 `json:"revokes"` } // TicketStakingStatus holds various stake information from the wallet. @@ -945,6 +975,8 @@ type TicketStakingStatus struct { // TicketPrice is the current price of one ticket. Also known as the // stake difficulty. TicketPrice uint64 `json:"ticketPrice"` + // VotingSubsidy is the current reward for a vote. + VotingSubsidy uint64 `json:"votingSubsidy"` // VSP is the currently set VSP address and fee. VSP string `json:"vsp"` // IsRPC will be true if this is an RPC wallet, in which case we can't @@ -955,6 +987,8 @@ type TicketStakingStatus struct { Tickets []*Ticket `json:"tickets"` // Stances returns current voting preferences. Stances Stances `json:"stances"` + // Stats is statistical info about staking history. + Stats TicketStats `json:"stats"` } // TicketBuyer is a wallet that can participate in decred staking. @@ -969,12 +1003,18 @@ type TicketBuyer interface { SetVSP(addr string) error // PurchaseTickets purchases n amount of tickets. Returns the purchased // ticket hashes if successful. - PurchaseTickets(n int) ([]string, error) + PurchaseTickets(n int, feeSuggestion uint64) ([]*Ticket, 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 // ListVSPs lists known available voting service providers. ListVSPs() ([]*VotingServiceProvider, error) + // TicketPage fetches a page of tickets within a range of block numbers with + // a target page size and optional offset. scanStart is the block in which + // to start the scan. The scan progresses in reverse block number order, + // starting at scanStart and going to progressively lower blocks. scanStart + // can be set to -1 to indicate the current chain tip. + TicketPage(scanStart int32, n, skipN int) ([]*Ticket, error) } // Bond is the fidelity bond info generated for a certain account ID, amount, diff --git a/client/core/core.go b/client/core/core.go index bed01b2e25..981d71c829 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -10568,7 +10568,7 @@ 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) ([]string, error) { +func (c *Core) PurchaseTickets(assetID uint32, pw []byte, n int) ([]*asset.Ticket, error) { wallet, tb, err := c.stakingWallet(assetID) if err != nil { return nil, err @@ -10582,7 +10582,7 @@ func (c *Core) PurchaseTickets(assetID uint32, pw []byte, n int) ([]string, erro if err != nil { return nil, err } - hashes, err := tb.PurchaseTickets(n) + tickets, err := tb.PurchaseTickets(n, c.feeSuggestionAny(assetID)) if err != nil { return nil, err } @@ -10590,7 +10590,7 @@ func (c *Core) PurchaseTickets(assetID uint32, pw []byte, n int) ([]string, erro // TODO: Send tickets bought notification. //subject, details := c.formatDetails(TopicSendSuccess, sentValue, unbip(assetID), address, coin) //c.notify(newSendNote(TopicSendSuccess, subject, details, db.Success)) - return hashes, nil + return tickets, nil } // SetVotingPreferences sets default voting settings for all active tickets and @@ -10604,3 +10604,25 @@ func (c *Core) SetVotingPreferences(assetID uint32, choices, tSpendPolicy, } return tb.SetVotingPreferences(choices, tSpendPolicy, treasuryPolicy) } + +// ListVSPs lists known available voting service providers. +func (c *Core) ListVSPs(assetID uint32) ([]*asset.VotingServiceProvider, error) { + _, tb, err := c.stakingWallet(assetID) + if err != nil { + return nil, err + } + return tb.ListVSPs() +} + +// TicketPage fetches a page of TicketBuyer tickets within a range of block +// numbers with a target page size and optional offset. scanStart it the block +// in which to start the scan. The scan progresses in reverse block number +// order, starting at scanStart and going to progressively lower blocks. +// scanStart can be set to -1 to indicate the current chain tip. +func (c *Core) TicketPage(assetID uint32, scanStart int32, n, skipN int) ([]*asset.Ticket, error) { + _, tb, err := c.stakingWallet(assetID) + if err != nil { + return nil, err + } + return tb.TicketPage(scanStart, n, skipN) +} diff --git a/client/rpcserver/handlers.go b/client/rpcserver/handlers.go index b98c246eb8..00f55d1727 100644 --- a/client/rpcserver/handlers.go +++ b/client/rpcserver/handlers.go @@ -987,13 +987,18 @@ func handlePurchaseTickets(s *RPCServer, params *RawParams) *msgjson.ResponsePay } defer form.appPass.Clear() - hashes, err := s.core.PurchaseTickets(form.assetID, form.appPass, form.num) + tickets, err := s.core.PurchaseTickets(form.assetID, form.appPass, form.num) if 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) } @@ -1673,7 +1678,7 @@ an spv wallet and enables options to view and set the vsp. tickets (array): An array of ticket objects. [ { - ticket (obj): Ticket transaction data. + tx (obj): Ticket transaction data. { hash (string): The ticket hash as hex. ticketPrice (int): The amount paid for the ticket in atoms. @@ -1681,30 +1686,31 @@ an spv wallet and enables options to view and set the vsp. stamp (int): The UNIX time the ticket was purchased. blockHeight (int): The block number the ticket was mined. }, - status: (int) The ticket status. 0: unknown, 1: unmined, 2: immature, 3: live, -4: voted, 5: missed, 6:expired, 7: unspent, 8: revoked. + status: (int) The ticket status. 0: unknown, 1: unmined, 2: immature, 3: live, + 4: voted, 5: missed, 6:expired, 7: unspent, 8: revoked. spender (string): The transaction that votes on or revokes the ticket if available. }, ],... stances (obj): Voting policies. { - voteChoices (array): An array of consensus vote choices. + agendas (array): An array of consensus vote choices. [ { - agendaid (string): The agenda ID, - agendadescription (string): A description of the agenda being voted on. - choiceid (string): The current choice. - choicedescription (string): A description of the chosen choice. + id (string): The agenda ID, + description (string): A description of the agenda being voted on. + currentChoice (string): Your current choice. + choices ([{id: "string", description: "string"}, ...]): A description of the available choices. }, ],... - tSpendPolicy (array): An array of TSpend policies. + tspends (array): An array of TSpend policies. [ { hash (string): The TSpend txid., - policy (string): The policy. + value (int): The total value send in the tspend., + currentValue (string): The policy. }, ],... - treasuryPolicy (array): An array of treasury policies. + treasuryKeys (array): An array of treasury policies. [ { key (string): The pubkey of the tspend creator. diff --git a/client/rpcserver/handlers_test.go b/client/rpcserver/handlers_test.go index bb8f747ef3..b418d29f30 100644 --- a/client/rpcserver/handlers_test.go +++ b/client/rpcserver/handlers_test.go @@ -1371,7 +1371,7 @@ func TestPurchaseTickets(t *testing.T) { }} for _, test := range tests { tc := &TCore{ - purchseTickets: test.purchaseTickets, + purchaseTickets: test.purchaseTickets, purchaseTicketsErr: test.purchaseTicketsErr, } r := &RPCServer{core: tc} diff --git a/client/rpcserver/rpcserver.go b/client/rpcserver/rpcserver.go index 67b78f8888..e7e33b33af 100644 --- a/client/rpcserver/rpcserver.go +++ b/client/rpcserver/rpcserver.go @@ -89,7 +89,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) ([]string, error) + PurchaseTickets(assetID uint32, pw []byte, n int) ([]*asset.Ticket, 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 2c43e1dcb6..ebba93dca2 100644 --- a/client/rpcserver/rpcserver_test.go +++ b/client/rpcserver/rpcserver_test.go @@ -68,7 +68,7 @@ type TCore struct { archivedRecords int deleteArchivedRecordsErr error setVSPErr error - purchseTickets []string + purchaseTickets []string purchaseTicketsErr error stakeStatus *asset.TicketStakingStatus stakeStatusErr error @@ -184,8 +184,16 @@ 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) ([]string, error) { - return c.purchseTickets, c.purchaseTicketsErr +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) StakeStatus(assetID uint32) (*asset.TicketStakingStatus, error) { return c.stakeStatus, c.stakeStatusErr diff --git a/client/webserver/api.go b/client/webserver/api.go index db02ea41f4..a00d859d23 100644 --- a/client/webserver/api.go +++ b/client/webserver/api.go @@ -1616,6 +1616,129 @@ func (s *WebServer) apiSendShielded(w http.ResponseWriter, r *http.Request) { writeJSON(w, simpleAck(), s.indent) } +func (s *WebServer) apiStakeStatus(w http.ResponseWriter, r *http.Request) { + var assetID uint32 + if !readPost(w, r, &assetID) { + return + } + status, err := s.core.StakeStatus(assetID) + if err != nil { + s.writeAPIError(w, fmt.Errorf("error fetching stake status for asset ID %d: %w", assetID, err)) + return + } + writeJSON(w, &struct { + OK bool `json:"ok"` + Status *asset.TicketStakingStatus `json:"status"` + }{ + OK: true, + Status: status, + }, s.indent) +} + +func (s *WebServer) apiSetVSP(w http.ResponseWriter, r *http.Request) { + var req struct { + AssetID uint32 `json:"assetID"` + URL string `json:"url"` + } + if !readPost(w, r, &req) { + return + } + if err := s.core.SetVSP(req.AssetID, req.URL); err != nil { + s.writeAPIError(w, fmt.Errorf("error settings vsp to %q for asset ID %d: %w", req.URL, req.AssetID, err)) + return + } + writeJSON(w, simpleAck(), s.indent) +} + +func (s *WebServer) apiPurchaseTickets(w http.ResponseWriter, r *http.Request) { + var req struct { + AssetID uint32 `json:"assetID"` + N int `json:"n"` + AppPW encode.PassBytes `json:"appPW"` + } + if !readPost(w, r, &req) { + return + } + appPW, err := s.resolvePass(req.AppPW, r) + defer zero(appPW) + if err != nil { + s.writeAPIError(w, fmt.Errorf("password error: %w", err)) + return + } + tickets, err := s.core.PurchaseTickets(req.AssetID, appPW, req.N) + if 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) +} + +func (s *WebServer) apiSetVotingPreferences(w http.ResponseWriter, r *http.Request) { + var req struct { + AssetID uint32 `json:"assetID"` + Choices map[string]string `json:"choices"` + TSpendPolicy map[string]string `json:"tSpendPolicy"` + TreasuryPolicy map[string]string `json:"treasuryPolicy"` + } + if !readPost(w, r, &req) { + return + } + if err := s.core.SetVotingPreferences(req.AssetID, req.Choices, req.TSpendPolicy, req.TreasuryPolicy); err != nil { + s.writeAPIError(w, fmt.Errorf("error setting voting preferences for asset ID %d: %w", req.AssetID, err)) + return + } + writeJSON(w, simpleAck(), s.indent) +} + +func (s *WebServer) apiListVSPs(w http.ResponseWriter, r *http.Request) { + var assetID uint32 + if !readPost(w, r, &assetID) { + return + } + vsps, err := s.core.ListVSPs(assetID) + if err != nil { + s.writeAPIError(w, fmt.Errorf("error listing VSPs for asset ID %d: %w", assetID, err)) + return + } + writeJSON(w, &struct { + OK bool `json:"ok"` + VSPs []*asset.VotingServiceProvider `json:"vsps"` + }{ + OK: true, + VSPs: vsps, + }, s.indent) +} + +func (s *WebServer) apiTicketPage(w http.ResponseWriter, r *http.Request) { + var req struct { + AssetID uint32 `json:"assetID"` + ScanStart int32 `json:"scanStart"` + N int `json:"n"` + SkipN int `json:"skipN"` + } + if !readPost(w, r, &req) { + return + } + tickets, err := s.core.TicketPage(req.AssetID, req.ScanStart, req.N, req.SkipN) + if err != nil { + s.writeAPIError(w, fmt.Errorf("error retrieving ticket page for %d: %w", req.AssetID, err)) + return + } + writeJSON(w, &struct { + OK bool `json:"ok"` + Tickets []*asset.Ticket `json:"tickets"` + }{ + OK: true, + Tickets: tickets, + }, s.indent) +} + // writeAPIError logs the formatted error and sends a standardResponse with the // error message. func (s *WebServer) writeAPIError(w http.ResponseWriter, err error) { diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index 28c7d4c32c..f659ed5e3c 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -1981,6 +1981,30 @@ func (c *TCore) ApproveTokenFee(assetID uint32, version uint32, approval bool) ( return 0, nil } +func (c *TCore) StakeStatus(assetID uint32) (*asset.TicketStakingStatus, error) { + return nil, nil +} + +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) SetVotingPreferences(assetID uint32, choices, tSpendPolicy, treasuryPolicy map[string]string) error { + return nil +} + +func (c *TCore) ListVSPs(assetID uint32) ([]*asset.VotingServiceProvider, error) { + return nil, nil +} + +func (c *TCore) TicketPage(assetID uint32, scanStart int32, n, skipN int) ([]*asset.Ticket, error) { + return nil, nil +} + func TestServer(t *testing.T) { // Register dummy drivers for unimplemented assets. asset.Register(22, &TDriver{}) // mona diff --git a/client/webserver/site/src/css/icons.scss b/client/webserver/site/src/css/icons.scss index 6b6604cd68..0f6b00b4e3 100644 --- a/client/webserver/site/src/css/icons.scss +++ b/client/webserver/site/src/css/icons.scss @@ -159,3 +159,7 @@ .ico-edit::before { content: "\e917"; } + +.ico-ticket::before { + content: "\e918"; +} diff --git a/client/webserver/site/src/css/main.scss b/client/webserver/site/src/css/main.scss index a625f1c71c..ed395a96e8 100644 --- a/client/webserver/site/src/css/main.scss +++ b/client/webserver/site/src/css/main.scss @@ -167,6 +167,11 @@ header.maintop a:hover, cursor: pointer; } +.hashwrap { + word-break: break-all; + user-select: all; +} + #header .hoverbright:hover { color: #222; } diff --git a/client/webserver/site/src/css/wallets.scss b/client/webserver/site/src/css/wallets.scss index abb0980079..9dd5ee9157 100644 --- a/client/webserver/site/src/css/wallets.scss +++ b/client/webserver/site/src/css/wallets.scss @@ -193,6 +193,48 @@ @extend .table; @extend .table-striped; } + + #stakingBox { + border-width: 1px; + border-style: solid none; + border-color: $light_border_color; + } + + table#vspPickerTable, + table#ticketHistoryTable { + border: 1px solid $light_border_color; + + th, + tr:not(:last-child) { + border-bottom: 1px solid $light_border_color; + } + + th, + td { + padding: 5px 15px 5px 10px; + } + + td:not(:first-child) { + text-align: right; + border-left: solid; + border-left-width: 1px; + border-color: $light_border_color; + } + } + + #purchaserInput { + width: 4em; + } + + #vspDisplay { + #vspEditIcon { + display: none; + } + + &:hover #vspEditIcon { + display: inline; + } + } } @include media-breakpoint-up(xxl) { diff --git a/client/webserver/site/src/css/wallets_dark.scss b/client/webserver/site/src/css/wallets_dark.scss index fbd3aaeb96..91c60d313c 100644 --- a/client/webserver/site/src/css/wallets_dark.scss +++ b/client/webserver/site/src/css/wallets_dark.scss @@ -19,5 +19,23 @@ body.dark { border-top: 1px solid $dark_border_color; } } + + #stakingBox { + border-color: $dark_border_color; + } + + table#vspPickerTable, + table#ticketHistoryTable { + border: 1px solid $dark_border_color; + + th, + tr:not(:last-child) { + border-bottom: 1px solid $dark_border_color; + } + + td:not(:first-child) { + border-color: $dark_border_color; + } + } } } diff --git a/client/webserver/site/src/font/icomoon.svg b/client/webserver/site/src/font/icomoon.svg index 0f0f410336..18e2598197 100644 --- a/client/webserver/site/src/font/icomoon.svg +++ b/client/webserver/site/src/font/icomoon.svg @@ -38,4 +38,5 @@ + diff --git a/client/webserver/site/src/font/icomoon.ttf b/client/webserver/site/src/font/icomoon.ttf index 1aa4da6877c406ca80b082b9f75b1b159c3c8023..c97a76dfe15cc5149d5ecb17c2169ec17a60c75e 100644 GIT binary patch delta 2134 zcmZ`*-HRJl6uSOj&iCcl9E803#G zTsrmIDE;=@4TN@F0L8J@(bYAyOm2hB92yr#t7mQ>Q~m_`5gc-3?b78dkSJWiNq966 z$36S_!AS64#?Vuk<00OHd+7JO$`!KvM znyDn_Cy4=V^-^XV^ji=PbQ%kr&`dQ@n8$d9c4X1Wp^-Jj5IKzc^Zh8v(qh`)%qcK7}VF1T_VwZ;hjDD~=r#_uK6wh_YP*E?jHxFlEX0wCZ65_^WLc@~%Q3{6S&55F@$;|Kq1v?_~vQ<1l&K)dT3NLaKV~Q~+ zFev;9oVK&%?Y1Zy{5~{5!Q?GU{s{*22ukjL~#<>yh zMKCZ4g_;p8C-HKi*9w9R=&k%Yh{V_YqN&#=k=!aTgBmbUKTA;LR8aKM5t#no@ zqt58z(DfyuT#tfEr!2@A2f&r%LpNnw7qq)3T3`$9b&uG#Dx1I%z0tRAD4#Fr6}bfe z{0{fJ8Cgsd0A6W^0W6bXQMMVtv5zvcoZh?xla{w84P9d+Cb9=KqWT+NhH{0p{kC|=b>~<4XPRIGTbZyqLj;?9xS+y4LvWYD#vI6 zL}C0(mI9;7h{AA|Hmm!uxQ<1zB$qQB@Pv_`(#59X6$Fucrmu8?D4mt={(OV(T{mT+ z_U_`v<0rI*-;v70S13YH{#at)z1e%??(_gRHy%x27Id&SHr|{uH%`vH>KC7uzAQbQ iDrg05zqY8cUjgiQLz8@l_A+NgHb%9|y`N^j#(x6`0RVge delta 298 zcmZp0|6)u!Q3lAbNlz>; z0MY_L{tO_^k)BhT))#qUE|9-~fkFIEMrvY;Quyx#1_sF*puAZIP=LdbB?8Fb0_3Y? z^yDQ9=8Vdd?c4ps~n1lV1#02m@wf>B#_!F%x%a&D z?&l=-{ry`GPe@kHi{1B*KULN<;JX=HN2NkqvOYjq~1@_kKA) zxPrDv@UCRDc>comb6|VSasES`CvR!rZk!#Q;kfD+V7fT#^7D%uVC1-60=(C7Dv|K| z#^%*)V4H{c4{+Wf^w$fQP6O_hUEhJ>IL_fW*KceNuED_pZmyZfeV4vRZ_$tFCmh#veX`i;_Fjg&V_T9uMQqENBc#zHQGl;uP)}k6 zByqh>h>Oc!H|hWgyt3o~1DFmFc-kyA$+%CJLRN1N(bwzNfevgs(k3i5^>VmlJR+xK zvyRI;cAo%q)afpEJ4qTAVs`|G#4s$$4kVjdcE^e=C$eC>6HVC0(ZfkFhG85uSdat^ z?hp{<6k35jX4#P(9us%r$b`iSz?MiDvoNklDGPEnvS`z-P@{3xdg-CtC#KN?85AqT z@(t5)QxYiNZWhQaumcuJIOMdzSxS?T;5D?Qh{3QzX34Oy9G5we;bL|e2HVBD16H3@ zks%10xX8M_4(qW_6n7#v>_(H2aMzBJbmB!8N307YfqPs}g1EuzNfs=IC1KxS(*Z0c zj-U<*C}U{*3&I39p`AP#R&iG&LPcF1esDwvWpkeC0InNlyZ4f_7@~c(iL>P zK*5U2f?{ij==sB%of9N};v5pfiwK2}$m_cc{vKOL%k~8x2{#{Rnw|0zYLxp6?mlC@ z30%>$?lzPE>ufwmM{NwPwz$qQY-yg(ymR zSuEG5ETi1A?Gn5yvlT%G)HpWEzaRsbn6H@OS`x2?MkOyQfZmRu2PUBsXqHi#FnMJ` z0W)AAe+*FKRaF%U^`^ak)%5kE#)~{J3i*mLrZp>hL4q@RRnKtycbSzUu~k8&P+WBu zl1X#%Rp`)9>&<~VXstI7tT)$7gXZ8t-wR~1INJ$J&7!Cf5`tDv^t~ZkJ+RJCte_Uw zZXI%5fXP(`GPAl7; zhN@wUsiK2E7JLcBQj&Rb2u3UwG#?*$Vdc|)({ul#K(|PBXOBNsOJ(wZoBe1k7Vw*U zy@h_+%#KH~+|Qg~8s=V?U3^mw{QYNOiB6=IA& io;f+AJt=%%crcaM^ZG%3MTc+f^cO~re)@I!KKUEoiv*Sc delta 348 zcmX@(e!xIl~hCg&y=FfcG?0EKHnc<<7^Pty~N zL1IsUd=4lUNYANE1B$(2U=Y6p!hMk!=4PZOrZ6x_o&c&b17W4`-w7E&K?VjXAeTV} zggFdZA~JGIDu7}jeh&yYF*4oE$xjBV^OM>D6z~AybfKJ8xrr4(i=}=5`3hhh%kVNU zF*lWgL0ST6c^e4N^y2(okY8K^^vwdeI%Xh?nfb$H9>#7)@yRn7wRsnc^@(+fiHQj@ zFf%X$eJi^8DdQ8N&1a?AS!HzcowVcmZN4&avjFumFx;1qehQ-}e^4-IRGzG%SUh>Y gVz3yO3|9zO2X_beKJHuG@4%s`% diff --git a/client/webserver/site/src/html/bodybuilder.tmpl b/client/webserver/site/src/html/bodybuilder.tmpl index 5eb7e51d27..025470f024 100644 --- a/client/webserver/site/src/html/bodybuilder.tmpl +++ b/client/webserver/site/src/html/bodybuilder.tmpl @@ -9,7 +9,7 @@ {{.Title}} - + -
+
- +
- +
@@ -67,23 +67,23 @@
- +
- + - + - + - + - + - + - +
[[[Status]]][[[:title:locked]]][[[:title:locked]]]
[[[Status]]][[[:title:ready]]][[[:title:ready]]]
[[[Status]]][[[:title:off]]][[[:title:off]]]
[[[Status]]][[[Disabled]]][[[Disabled]]]
@@ -102,15 +102,15 @@
[[[Wallet Type]]]
[[[Peer Count]]]
[[[Sync Progress]]]
@@ -124,7 +124,87 @@
-
+ + {{- /* STAKING */ -}} +
+
+
+ Staking +
+
+
+
+
Active tickets
+
+
+
+
Tickets bought
+
+
+
+
Total rewards
+
+
+
+
Votes cast
+
+
+
+
VSP
+
+ + +
+
+
+ +
+
+
+
+ + Set Votes +
+
+
+ agendas + treasury spends +
+
+
+
+
+ Staking unavailable for RPC wallets using SPV +
+ {{- /* PURCHASE TICKETS */ -}} +
+
+ Purchase Tickets +
+
+ Tickets +
+
+
+
+
+
+ Ticket Price + + + +
+
+
+ Vote Reward + + + +
+
+
+
+
{{- /* END STAKING */ -}} {{/* END WALLET DETAILS */}} {{- /* MARKETS OVERVIEW */ -}} @@ -602,6 +682,160 @@
+ {{- /* PICK A VOTING SERVICE PROVIDER */ -}} +
+
Select a Voting Service Provider
+
+ + + + + + + + + + + + + + + +
URLFee Rate (%)Live Tickets
{{/* TODO: ADD MANUAL INPUT */}} +
+ + {{- /* PURCHASE TICKETS */ -}} +
+
+
Purchase Tickets
+
+
+ Current Price +
+ a +
+
+ Available Balance +
+ a +
+
+
+
+
+ How Many? + +
+
+ App Password + +
+
+ +
+
+
+
+ + {{- /* TICKET HISTORY */ -}} +
+
+
Ticket History
+ + + + + + + + + + + + + + + + + +
AgePriceStatusTicket
+ + + + +
+
+ + + +
+
No tickets to show
+
+ + {{- /* SET VOTES */ -}} +
+
+ + {{- /* AGENDAS */ -}} +
Agendas
+
+
+
+
+
+
+
+ + +
+
+
+
+ + {{- /* TREASURY SPENDS */ -}} +
Treasury Spends
+
+
+
+
+
+
+
+
+
+ No + +
+
+ Yes + +
+
+
+
+ + {{- /* TREASURY KEYS */ -}} +
Treasury Keys
+
+
+
+
+
+
+
+ No + +
+
+ Yes + +
+
+
+
+ +
+
{{template "bottom"}} diff --git a/client/webserver/site/src/js/locales.ts b/client/webserver/site/src/js/locales.ts index d7993623bf..22acbbdf79 100644 --- a/client/webserver/site/src/js/locales.ts +++ b/client/webserver/site/src/js/locales.ts @@ -131,6 +131,16 @@ export const ID_LOCKED_ORDER_BAL_MSG = 'LOCKED_ORDER_BAL_MSG' export const ID_CREATING_WALLETS = 'CREATING_WALLETS' export const ID_ADDING_SERVERS = 'ADDING_SERVER' export const ID_WALLET_RECOVERY_SUPPORT_MSG = 'WALLET_RECOVERY_SUPPORT_MSG' +export const ID_TICKETS_PURCHASED = 'TICKETS_PURCHASED' +export const ID_TICKET_STATUS_UNKNOWN = 'TICKET_STATUS_UNKNOWN' +export const ID_TICKET_STATUS_UNMINED = 'TICKET_STATUS_UNMINED' +export const ID_TICKET_STATUS_IMMATURE = 'TICKET_STATUS_IMMATURE' +export const ID_TICKET_STATUS_LIVE = 'TICKET_STATUS_LIVE' +export const ID_TICKET_STATUS_VOTED = 'TICKET_STATUS_VOTED' +export const ID_TICKET_STATUS_MISSED = 'TICKET_STATUS_MISSED' +export const ID_TICKET_STATUS_EXPIRED = 'TICKET_STATUS_EXPIRED' +export const ID_TICKET_STATUS_UNSPENT = 'TICKET_STATUS_UNSPENT' +export const ID_TICKET_STATUS_REVOKED = 'TICKET_STATUS_REVOKED' export const enUS: Locale = { [ID_NO_PASS_ERROR_MSG]: 'password cannot be empty', @@ -264,7 +274,17 @@ export const enUS: Locale = { [ID_LOCKED_ORDER_BAL_MSG]: 'Funds locked in unmatched orders', [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_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_TICKET_STATUS_UNKNOWN]: 'unknown', + [ID_TICKET_STATUS_UNMINED]: 'unmined', + [ID_TICKET_STATUS_IMMATURE]: 'immature', + [ID_TICKET_STATUS_LIVE]: 'live', + [ID_TICKET_STATUS_VOTED]: 'voted', + [ID_TICKET_STATUS_MISSED]: 'missed', + [ID_TICKET_STATUS_EXPIRED]: 'expired', + [ID_TICKET_STATUS_UNSPENT]: 'unspent', + [ID_TICKET_STATUS_REVOKED]: 'revoked' } export const ptBR: Locale = { diff --git a/client/webserver/site/src/js/order.ts b/client/webserver/site/src/js/order.ts index 84c67458d8..ca2e966528 100644 --- a/client/webserver/site/src/js/order.ts +++ b/client/webserver/site/src/js/order.ts @@ -15,9 +15,9 @@ import { } from './registry' import { setOptionTemplates } from './opts' -const Mainnet = 0 -const Testnet = 1 -// const Regtest = 3 +export const Mainnet = 0 +export const Testnet = 1 +export const Simnet = 2 // lockTimeMakerMs must match the value returned from LockTimeMaker func in dexc. const lockTimeMakerMs = 20 * 60 * 60 * 1000 @@ -609,11 +609,18 @@ export const CoinExplorers: Record strin 42: { // dcr [Mainnet]: (cid: string) => { const [txid, vout] = cid.split(':') - return `https://explorer.dcrdata.org/tx/${txid}/out/${vout}` + if (vout !== undefined) return `https://explorer.dcrdata.org/tx/${txid}/out/${vout}` + return `https://explorer.dcrdata.org/tx/${txid}` }, [Testnet]: (cid: string) => { const [txid, vout] = cid.split(':') - return `https://testnet.dcrdata.org/tx/${txid}/out/${vout}` + if (vout !== undefined) return `https://testnet.dcrdata.org/tx/${txid}/out/${vout}` + return `https://testnet.dcrdata.org/tx/${txid}` + }, + [Simnet]: (cid: string) => { + const [txid, vout] = cid.split(':') + if (vout !== undefined) return `http://127.0.0.1:17779/tx/${txid}/out/${vout}` + return `https://127.0.0.1:17779/tx/${txid}` } }, 0: { // btc diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts index 9687d16c4e..2009fa8ec0 100644 --- a/client/webserver/site/src/js/registry.ts +++ b/client/webserver/site/src/js/registry.ts @@ -427,6 +427,7 @@ export interface PageElement extends HTMLElement { checked?: boolean href?: string htmlFor?: string + name?: string } export interface BooleanConfig { @@ -603,6 +604,84 @@ export interface WalletPeer { connected: boolean } +export interface TicketTransaction { + hash: string + ticketPrice: number + fees: number + stamp: number + blockHeight: number +} + +export interface Ticket { + tx: TicketTransaction + status: number + spender: string +} + +export interface TBChoice { + id: string + description: string +} + +export interface TBAgenda { + id: string + description: string + currentChoice: string + choices: TBChoice[] +} + +export interface TKeyPolicyResult { + key: string + policy: string + ticket?: string +} + +export interface TBTreasurySpend { + hash: string + value: number + currentPolicy: string +} + +export interface Stances { + agendas: TBAgenda[] + tspends: TBTreasurySpend[] + treasuryKeys: TKeyPolicyResult[] +} + +export interface TicketStats{ + totalRewards: number + ticketCount: number + votes: number + revokes: number +} + +export interface TicketStakingStatus { + ticketPrice: number + votingSubsidy: number + vsp: string + isRPC: boolean + tickets: Ticket[] + stances: Stances + stats: TicketStats +} + +// VotingServiceProvider is information about a voting service provider. +export interface VotingServiceProvider { + url: string + network: number + launched: number + lastUpdated: number + apiVersions: number[] + feePercentage: number + closed: boolean + voting: number + voted: number + revoked: number + vspdVersion: string + blockHeight: number + netShare: number +} + export interface Application { assets: Record seedGenTime: number diff --git a/client/webserver/site/src/js/wallets.ts b/client/webserver/site/src/js/wallets.ts index 27d0565c89..a05d8909ce 100644 --- a/client/webserver/site/src/js/wallets.ts +++ b/client/webserver/site/src/js/wallets.ts @@ -28,7 +28,12 @@ import { WalletPeer, ApprovalStatus, CustomBalance, - WalletState + WalletState, + UnitInfo, + TicketStakingStatus, + VotingServiceProvider, + Ticket, + TicketStats } from './registry' import { CoinExplorers } from './order' @@ -41,9 +46,35 @@ const traitRestorer = 1 << 8 const traitTxFeeEstimator = 1 << 9 const traitPeerManager = 1 << 10 const traitTokenApprover = 1 << 13 +const traitTicketBuyer = 1 << 15 const traitsExtraOpts = traitLogFiler & traitRecoverer & traitRestorer & traitRescanner & traitPeerManager & traitTokenApprover +export const ticketStatusUnknown = 0 +export const ticketStatusUnmined = 1 +export const ticketStatusImmature = 2 +export const ticketStatusLive = 3 +export const ticketStatusVoted = 4 +export const ticketStatusMissed = 5 +export const ticketStatusExpired = 6 +export const ticketStatusUnspent = 7 +export const ticketStatusRevoked = 8 + +export const ticketStatusTranslationKeys = [ + intl.ID_TICKET_STATUS_UNKNOWN, + intl.ID_TICKET_STATUS_UNMINED, + intl.ID_TICKET_STATUS_IMMATURE, + intl.ID_TICKET_STATUS_LIVE, + intl.ID_TICKET_STATUS_VOTED, + intl.ID_TICKET_STATUS_MISSED, + intl.ID_TICKET_STATUS_EXPIRED, + intl.ID_TICKET_STATUS_UNSPENT, + intl.ID_TICKET_STATUS_REVOKED +] + +const ticketPageSize = 10 +const scanStartMempool = -1 + interface ReconfigRequest { assetID: number walletType: string @@ -70,6 +101,12 @@ interface AssetButton { bttn: PageElement } +interface TicketPagination { + number: number + history: Ticket[] + scanned: boolean // Reached the end of history. All tickets cached. +} + let net = 0 export default class WalletsPage extends BasePage { @@ -91,8 +128,10 @@ export default class WalletsPage extends BasePage { currentForm: PageElement restoreInfoCard: HTMLElement selectedAssetID: number + stakeStatus: TicketStakingStatus maxSend: number unapprovingTokenVersion: number + ticketPage: TicketPagination constructor (body: HTMLElement) { super() @@ -112,7 +151,9 @@ export default class WalletsPage extends BasePage { this.selectedAssetID = -1 Doc.cleanTemplates( - page.iconSelectTmpl, page.balanceDetailRow, page.recentOrderTmpl + page.iconSelectTmpl, page.balanceDetailRow, page.recentOrderTmpl, page.vspRowTmpl, + page.ticketHistoryRowTmpl, page.votingChoiceTmpl, page.votingAgendaTmpl, page.tspendTmpl, + page.tkeyTmpl ) Doc.bind(page.createWallet, 'click', () => this.showNewWallet(this.selectedAssetID)) @@ -176,6 +217,15 @@ export default class WalletsPage extends BasePage { Doc.bind(page.addPeerSubmit, 'click', async () => { this.submitAddPeer() }) Doc.bind(page.unapproveTokenAllowance, 'click', async () => { this.showUnapproveTokenAllowanceTableForm() }) Doc.bind(page.unapproveTokenSubmit, 'click', async () => { this.submitUnapproveTokenAllowance() }) + Doc.bind(page.showVSPs, 'click', () => { this.showVSPPicker() }) + Doc.bind(page.vspDisplay, 'click', () => { this.showVSPPicker() }) + Doc.bind(page.purchaseTicketsBttn, 'click', () => { this.showPurchaseTicketsDialog() }) + bindForm(page.purchaseTicketsForm, page.purchaserSubmit, () => { this.purchaseTickets() }) + Doc.bind(page.purchaserInput, 'change', () => { this.purchaserInputChanged() }) + Doc.bind(page.ticketHistory, 'click', () => { this.showTicketHistory() }) + Doc.bind(page.ticketHistoryNextPage, 'click', () => { this.nextTicketPage() }) + Doc.bind(page.ticketHistoryPrevPage, 'click', () => { this.prevTicketPage() }) + Doc.bind(page.setVotes, 'click', () => { this.showSetVotesDialog() }) // New deposit address button. this.depositAddrForm = new DepositAddress(page.deposit) @@ -245,6 +295,13 @@ export default class WalletsPage extends BasePage { if (this.animation) this.animation.stop() } + async safePost (path: string, args: any): Promise { + const assetID = this.selectedAssetID + const res = await postJSON(path, args) + if (assetID !== this.selectedAssetID) throw Error('asset changed during request. aborting') + return res + } + // stepSend makes a request to get an estimated fee and displays the confirm // send form. async stepSend () { @@ -769,6 +826,7 @@ export default class WalletsPage extends BasePage { this.updateDisplayedAsset(assetID) this.showAvailableMarkets(assetID) this.showRecentActivity(assetID) + this.updateTicketBuyer(assetID) } updateDisplayedAsset (assetID: number) { @@ -806,6 +864,342 @@ export default class WalletsPage extends BasePage { page.walletDetailsBox.classList.remove('invisible') } + async updateTicketBuyer (assetID: number) { + this.ticketPage = { + number: 0, + history: [], + scanned: false + } + const { wallet, unitInfo: ui } = app().assets[assetID] + const page = this.page + Doc.hide( + page.stakingBox, page.pickVSP, page.stakingSummary, page.stakingErr, + page.vspDisplayBox, page.ticketPriceBox, page.purchaseTicketsBox, + page.stakingRpcSpvMsg + ) + if (!wallet?.running || (wallet.traits & traitTicketBuyer) === 0) return + Doc.show(page.stakingBox) + const loaded = app().loading(page.stakingBox) + const res = await this.safePost('/api/stakestatus', assetID) + loaded() + if (!app().checkResponse(res)) { + // Look for common error for RPC + SPV wallet. + if (res.msg.includes('disconnected from consensus RPC')) { + Doc.show(page.stakingRpcSpvMsg) + return + } + Doc.show(page.stakingErr) + page.stakingErr.textContent = res.msg + return + } + Doc.show(page.stakingSummary, page.ticketPriceBox) + const stakeStatus = res.status as TicketStakingStatus + this.stakeStatus = stakeStatus + page.ticketPrice.textContent = Doc.formatFourSigFigs(stakeStatus.ticketPrice / ui.conventional.conversionFactor) + page.votingSubsidy.textContent = Doc.formatFourSigFigs(stakeStatus.votingSubsidy / ui.conventional.conversionFactor) + page.stakingAgendaCount.textContent = String(stakeStatus.stances.agendas.length) + page.stakingTspendCount.textContent = String(stakeStatus.stances.tspends.length) + page.purchaserCurrentPrice.textContent = Doc.formatFourSigFigs(stakeStatus.ticketPrice / ui.conventional.conversionFactor) + page.purchaserBal.textContent = Doc.formatCoinValue(wallet.balance.available, ui) + this.updateTicketStats(stakeStatus.stats, ui) + this.setVSPViz(stakeStatus.vsp) + } + + setVSPViz (vsp: string) { + const { page, stakeStatus } = this + Doc.hide(page.vspDisplayBox) + if (vsp) { + Doc.show(page.vspDisplayBox, page.purchaseTicketsBox) + Doc.hide(page.pickVSP) + page.vspURL.textContent = vsp + return + } + Doc.setVis(!stakeStatus.isRPC, page.pickVSP) + Doc.setVis(stakeStatus.isRPC, page.purchaseTicketsBox) + } + + updateTicketStats (stats: TicketStats, ui: UnitInfo) { + const { page, stakeStatus } = this + const liveTicketCount = stakeStatus.tickets.filter((tkt: Ticket) => tkt.status <= ticketStatusLive && tkt.status >= ticketStatusUnmined).length + page.stakingTicketCount.textContent = String(liveTicketCount) + page.totalTicketCount.textContent = String(stats.ticketCount) + page.totalTicketRewards.textContent = Doc.formatFourSigFigs(stats.totalRewards / ui.conventional.conversionFactor) + page.totalTicketVotes.textContent = String(stats.votes) + } + + async showVSPPicker () { + const assetID = this.selectedAssetID + const page = this.page + this.showForm(page.vspPicker) + Doc.empty(page.vspPickerList) + Doc.hide(page.stakingErr) + const loaded = app().loading(page.vspPicker) + const res = await this.safePost('/api/listvsps', assetID) + loaded() + if (!app().checkResponse(res)) { + Doc.show(page.stakingErr) + page.stakingErr.textContent = res.msg + return + } + const vsps = res.vsps as VotingServiceProvider[] + for (const vsp of vsps) { + const row = page.vspRowTmpl.cloneNode(true) as PageElement + page.vspPickerList.appendChild(row) + const tmpl = Doc.parseTemplate(row) + tmpl.url.textContent = vsp.url + tmpl.feeRate.textContent = vsp.feePercentage.toFixed(2) + tmpl.voting.textContent = String(vsp.voting) + Doc.bind(row, 'click', () => { + Doc.hide(page.stakingErr) + this.setVSP(assetID, vsp) + }) + } + } + + showPurchaseTicketsDialog () { + const page = this.page + page.purchaserInput.value = '' + page.purchaserAppPW.value = '' + Doc.hide(page.purchaserErr) + Doc.setVis(!State.passwordIsCached(), page.purchaserAppPWBox) + this.showForm(this.page.purchaseTicketsForm) + } + + purchaserInputChanged () { + const page = this.page + const n = parseInt(page.purchaserInput.value || '0') + if (n <= 1) { + page.purchaserInput.value = '1' + return + } + page.purchaserInput.value = String(n) + } + + async purchaseTickets () { + const { page, selectedAssetID: assetID, stakeStatus } = this + // DRAFT NOTE: The user will get an actual ticket count somewhere in the + // range 1 <= tickets_purchased <= n. See notes in + // (*spvWallet).PurchaseTickets. + // How do we handle this at the UI. Or do we handle it all in the backend + // somehow? + const n = parseInt(page.purchaserInput.value || '0') + if (n < 1) return + // TODO: Add confirmation dialog. + const loaded = app().loading(page.purchaseTicketsForm) + const res = await this.safePost('/api/purchasetickets', { assetID, n, appPW: page.purchaserAppPW.value || '' }) + loaded() + if (!app().checkResponse(res)) { + page.purchaserErr.textContent = res.msg + Doc.show(page.purchaserErr) + return + } + + 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) })) + } + + async setVSP (assetID: number, vsp: VotingServiceProvider) { + this.closePopups() + const page = this.page + const loaded = app().loading(page.stakingBox) + const res = await this.safePost('/api/setvsp', { assetID, url: vsp.url }) + loaded() + if (!app().checkResponse(res)) { + Doc.show(page.stakingErr) + page.stakingErr.textContent = res.msg + return + } + this.setVSPViz(vsp.url) + } + + pageOfTickets (pgNum: number) { + const { stakeStatus, ticketPage } = this + let startOffset = pgNum * ticketPageSize + const pageOfTickets: Ticket[] = [] + if (startOffset < stakeStatus.tickets.length) { + pageOfTickets.push(...stakeStatus.tickets.slice(startOffset, startOffset + ticketPageSize)) + if (pageOfTickets.length < ticketPageSize) { + const need = ticketPageSize - pageOfTickets.length + pageOfTickets.push(...ticketPage.history.slice(0, need)) + } + } else { + startOffset -= stakeStatus.tickets.length + pageOfTickets.push(...ticketPage.history.slice(startOffset, startOffset + ticketPageSize)) + } + return pageOfTickets + } + + displayTicketPage (pageNumber: number, pageOfTickets: Ticket[]) { + const { page, selectedAssetID: assetID } = this + const ui = app().unitInfo(assetID) + const coinLink = CoinExplorers[assetID][app().user.net] + Doc.empty(page.ticketHistoryRows) + page.ticketHistoryPage.textContent = String(pageNumber) + for (const { tx, status } of pageOfTickets) { + const tr = page.ticketHistoryRowTmpl.cloneNode(true) as PageElement + page.ticketHistoryRows.appendChild(tr) + const tmpl = Doc.parseTemplate(tr) + tmpl.age.textContent = Doc.timeSince(tx.stamp * 1000) + tmpl.price.textContent = Doc.formatFullPrecision(tx.ticketPrice, ui) + tmpl.status.textContent = intl.prep(ticketStatusTranslationKeys[status]) + tmpl.hashStart.textContent = tx.hash.slice(0, 6) + tmpl.hashEnd.textContent = tx.hash.slice(-6) + Doc.bind(tmpl.detailsLink, 'click', () => window.open(coinLink(tx.hash), '_blank')) + } + } + + async ticketPageN (pageNumber: number) { + const { page, stakeStatus, ticketPage, selectedAssetID: assetID } = this + const pageOfTickets = this.pageOfTickets(pageNumber) + if (pageOfTickets.length < ticketPageSize && !ticketPage.scanned) { + const n = ticketPageSize - pageOfTickets.length + const lastList = ticketPage.history.length > 0 ? ticketPage.history : stakeStatus.tickets + const scanStart = lastList.length > 0 ? lastList[lastList.length - 1].tx.blockHeight : scanStartMempool + const skipN = lastList.filter((tkt: Ticket) => tkt.tx.blockHeight === scanStart).length + const loaded = app().loading(page.ticketHistoryForm) + const res = await this.safePost('/api/ticketpage', { assetID, scanStart, n, skipN }) + loaded() + if (!app().checkResponse(res)) { + console.error('error fetching ticket page', res.msg) + return + } + this.ticketPage.history.push(...res.tickets) + pageOfTickets.push(...res.tickets) + if (res.tickets.length < n) this.ticketPage.scanned = true + } + + const totalTix = stakeStatus.tickets.length + ticketPage.history.length + Doc.setVis(totalTix >= ticketPageSize, page.ticketHistoryPagination) + Doc.setVis(totalTix > 0, page.ticketHistoryTable) + Doc.setVis(totalTix === 0, page.noTicketsMessage) + if (pageOfTickets.length === 0) { + // Probably ended with a page of size ticketPageSize, so didn't know we + // had hit the end until the user clicked the arrow and we went looking + // for the next. Would be good to figure out a way to hide the arrow in + // that case. + Doc.hide(page.ticketHistoryNextPage) + return + } + this.displayTicketPage(pageNumber, pageOfTickets) + ticketPage.number = pageNumber + const atEnd = pageNumber * ticketPageSize + pageOfTickets.length === totalTix + Doc.setVis(!atEnd || !ticketPage.scanned, page.ticketHistoryNextPage) + Doc.setVis(pageNumber > 0, page.ticketHistoryPrevPage) + } + + async showTicketHistory () { + this.showForm(this.page.ticketHistoryForm) + await this.ticketPageN(this.ticketPage.number) + } + + async nextTicketPage () { + await this.ticketPageN(this.ticketPage.number + 1) + } + + async prevTicketPage () { + await this.ticketPageN(this.ticketPage.number - 1) + } + + showSetVotesDialog () { + const { page, stakeStatus, selectedAssetID: assetID } = this + const ui = app().unitInfo(assetID) + Doc.hide(page.votingFormErr) + const coinLink = CoinExplorers[assetID][app().user.net] + const upperCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1) + + const setVotes = async (req: any) => { + Doc.hide(page.votingFormErr) + const loaded = app().loading(page.votingForm) + const res = await this.safePost('/api/setvotes', req) + loaded() + if (!app().checkResponse(res)) { + Doc.show(page.votingFormErr) + page.votingFormErr.textContent = res.msg + throw Error(res.msg) + } + } + + const setAgendaChoice = async (agendaID: string, choiceID: string) => { + await setVotes({ assetID, choices: { [agendaID]: choiceID } }) + for (const agenda of stakeStatus.stances.agendas) if (agenda.id === agendaID) agenda.currentChoice = choiceID + } + + Doc.empty(page.votingAgendas) + for (const agenda of stakeStatus.stances.agendas) { + const div = page.votingAgendaTmpl.cloneNode(true) as PageElement + page.votingAgendas.appendChild(div) + const tmpl = Doc.parseTemplate(div) + tmpl.description.textContent = agenda.description + for (const choice of agenda.choices) { + if (choice.id === 'abstain') continue + const div = page.votingChoiceTmpl.cloneNode(true) as PageElement + tmpl.choices.appendChild(div) + const choiceTmpl = Doc.parseTemplate(div) + choiceTmpl.id.textContent = upperCase(choice.id) + choiceTmpl.id.dataset.tooltip = choice.description + choiceTmpl.radio.value = choice.id + choiceTmpl.radio.name = agenda.id + Doc.bind(choiceTmpl.radio, 'change', () => { + if (!choiceTmpl.radio.checked) return + setAgendaChoice(agenda.id, choice.id) + }) + if (choice.id === agenda.currentChoice) choiceTmpl.radio.checked = true + } + app().bindTooltips(tmpl.choices) + } + + const setTspendVote = async (txHash: string, policyID: string) => { + await setVotes({ assetID, tSpendPolicy: { [txHash]: policyID } }) + for (const tspend of stakeStatus.stances.tspends) if (tspend.hash === txHash) tspend.currentPolicy = policyID + } + + Doc.empty(page.votingTspends) + for (const tspend of stakeStatus.stances.tspends) { + const div = page.tspendTmpl.cloneNode(true) as PageElement + page.votingTspends.appendChild(div) + const tmpl = Doc.parseTemplate(div) + for (const opt of [tmpl.yes, tmpl.no]) { + opt.name = tspend.hash + if (tspend.currentPolicy === opt.value) opt.checked = true + Doc.bind(opt, 'change', () => { + if (!opt.checked) return + setTspendVote(tspend.hash, opt.value ?? '') + }) + } + if (tspend.value > 0) tmpl.value.textContent = Doc.formatFourSigFigs(tspend.value / ui.conventional.conversionFactor) + else Doc.hide(tmpl.value) + tmpl.hash.textContent = tspend.hash + Doc.bind(tmpl.explorerLink, 'click', () => window.open(coinLink(tspend.hash), '_blank')) + } + + const setTKeyPolicy = async (key: string, policy: string) => { + await setVotes({ assetID, treasuryPolicy: { [key]: policy } }) + for (const tkey of stakeStatus.stances.treasuryKeys) if (tkey.key === key) tkey.policy = policy + } + + Doc.empty(page.votingTKeys) + for (const keyPolicy of stakeStatus.stances.treasuryKeys) { + const div = page.tkeyTmpl.cloneNode(true) as PageElement + page.votingTKeys.appendChild(div) + const tmpl = Doc.parseTemplate(div) + for (const opt of [tmpl.yes, tmpl.no]) { + opt.name = keyPolicy.key + if (keyPolicy.policy === opt.value) opt.checked = true + Doc.bind(opt, 'change', () => { + if (!opt.checked) return + setTKeyPolicy(keyPolicy.key, opt.value ?? '') + }) + } + tmpl.key.textContent = keyPolicy.key + } + + this.showForm(page.votingForm) + } + updateDisplayedAssetBalance (): void { const page = this.page const { wallet, unitInfo: ui, symbol, id: assetID } = app().assets[this.selectedAssetID] @@ -858,6 +1252,7 @@ export default class WalletsPage extends BasePage { if (bal.locked) balCategory = lockedBal(balCategory) addSubBalance(balCategory, bal.amt, tooltipMsg) } + page.purchaserBal.textContent = Doc.formatFourSigFigs(bal.available / ui.conventional.conversionFactor) app().bindTooltips(page.balanceDetailBox) } @@ -1310,7 +1705,7 @@ export default class WalletsPage extends BasePage { walletType: walletType } if (this.changeWalletPW) req.newWalletPW = page.newPW.value - const res = await postJSON('/api/reconfigurewallet', req) + const res = await this.safePost('/api/reconfigurewallet', req) page.appPW.value = '' page.newPW.value = '' loaded() @@ -1319,6 +1714,7 @@ export default class WalletsPage extends BasePage { return } this.assetUpdated(assetID, page.reconfigForm, intl.prep(intl.ID_RECONFIG_SUCCESS)) + this.updateTicketBuyer(assetID) } /* lock instructs the API to lock the wallet. */ diff --git a/client/webserver/webserver.go b/client/webserver/webserver.go index 49650710ad..0873500bf9 100644 --- a/client/webserver/webserver.go +++ b/client/webserver/webserver.go @@ -161,6 +161,12 @@ type clientCore interface { ApproveToken(appPW []byte, assetID uint32, dexAddr string, onConrim func()) (string, error) UnapproveToken(appPW []byte, assetID uint32, version uint32) (string, error) 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) + 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) } // genCertPair generates a key/cert pair to the paths provided. @@ -513,6 +519,13 @@ func New(cfg *Config) (*WebServer, error) { apiAuth.Post("/shieldfunds", s.apiShieldFunds) apiAuth.Post("/unshieldfunds", s.apiUnshieldFunds) apiAuth.Post("/sendshielded", s.apiSendShielded) + + apiAuth.Post("/stakestatus", s.apiStakeStatus) + apiAuth.Post("/setvsp", s.apiSetVSP) + apiAuth.Post("/purchasetickets", s.apiPurchaseTickets) + apiAuth.Post("/setvotes", s.apiSetVotingPreferences) + apiAuth.Post("/listvsps", s.apiListVSPs) + apiAuth.Post("/ticketpage", s.apiTicketPage) }) }) diff --git a/client/webserver/webserver_test.go b/client/webserver/webserver_test.go index 69309a0391..944515dfaa 100644 --- a/client/webserver/webserver_test.go +++ b/client/webserver/webserver_test.go @@ -305,6 +305,29 @@ func (c *TCore) UnapproveToken(appPW []byte, assetID uint32, version uint32) (st func (c *TCore) ApproveTokenFee(assetID uint32, version uint32, approval bool) (uint64, error) { return 0, nil } +func (c *TCore) StakeStatus(assetID uint32) (*asset.TicketStakingStatus, error) { + return nil, nil +} + +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) SetVotingPreferences(assetID uint32, choices, tSpendPolicy, treasuryPolicy map[string]string) error { + return nil +} + +func (c *TCore) ListVSPs(assetID uint32) ([]*asset.VotingServiceProvider, error) { + return nil, nil +} + +func (c *TCore) TicketPage(assetID uint32, scanStart int32, n, skipN int) ([]*asset.Ticket, error) { + return nil, nil +} type TWriter struct { b []byte