diff --git a/client/asset/dcr/config.go b/client/asset/dcr/config.go index 8bcfa2e66e..4c75aa4bd9 100644 --- a/client/asset/dcr/config.go +++ b/client/asset/dcr/config.go @@ -40,6 +40,7 @@ type walletConfig struct { RedeemConfTarget uint64 `ini:"redeemconftarget"` ActivelyUsed bool `ini:"special_activelyUsed"` //injected by core ApiFeeFallback bool `ini:"apifeefallback"` + VSPURL string `ini:"vspurl"` } type rpcConfig struct { diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 0cdc968584..19affd900b 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "crypto/sha256" + "encoding/base64" "encoding/binary" "encoding/hex" "encoding/json" @@ -15,6 +16,8 @@ import ( "io" "math" "net/http" + neturl "net/url" + "os" "path/filepath" "sort" "strconv" @@ -44,6 +47,7 @@ import ( "github.com/decred/dcrd/txscript/v4/stdaddr" "github.com/decred/dcrd/txscript/v4/stdscript" "github.com/decred/dcrd/wire" + vspdjson "github.com/decred/vspd/types" ) const ( @@ -94,6 +98,8 @@ const ( // monitored until this number of confirms is reached. Two to make sure // the block containing the redeem is stakeholder-approved requiredRedeemConfirms = 2 + + vspFileName = "vsp.json" ) var ( @@ -557,6 +563,14 @@ type mempoolRedeem struct { firstSeen time.Time } +// vsp holds info needed for purchasing tickets from a vsp. PubKey is from the +// vsp and is used for verifying communications. +type vsp struct { + URL string `json:"url"` + FeePercentage float64 `json:"feepercent"` + PubKey string `json:"pubkey"` +} + // ExchangeWallet is a wallet backend for Decred. The backend is how the DEX // client app communicates with the Decred blockchain and wallet. ExchangeWallet // satisfies the dex.Wallet interface. @@ -588,6 +602,8 @@ type ExchangeWallet struct { tipChange func(error) lastPeerCount uint32 peersChange func(uint32, error) + dir string + walletType string oracleFeesMtx sync.Mutex oracleFees map[uint64]feeStamped // conf target => fee rate @@ -609,6 +625,10 @@ type ExchangeWallet struct { // TODO: Consider persisting mempool redeems on file. mempoolRedeemsMtx sync.RWMutex mempoolRedeems map[[32]byte]*mempoolRedeem // keyed by secret hash + + vspV atomic.Value // *vsp + + connected atomic.Bool } func (dcr *ExchangeWallet) config() *exchangeWalletConfig { @@ -686,6 +706,7 @@ var _ asset.LiveReconfigurer = (*ExchangeWallet)(nil) var _ asset.TxFeeEstimator = (*ExchangeWallet)(nil) var _ asset.Bonder = (*ExchangeWallet)(nil) var _ asset.Authenticator = (*ExchangeWallet)(nil) +var _ asset.TicketBuyer = (*ExchangeWallet)(nil) type block struct { height int64 @@ -827,6 +848,11 @@ func unconnectedWallet(cfg *asset.WalletConfig, dcrCfg *walletConfig, chainParam return nil, err } + dir := filepath.Join(cfg.DataDir, chainParams.Name) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("unable to create wallet dir: %v", err) + } + w := &ExchangeWallet{ log: logger, chainParams: chainParams, @@ -838,6 +864,19 @@ func unconnectedWallet(cfg *asset.WalletConfig, dcrCfg *walletConfig, chainParam externalTxCache: make(map[chainhash.Hash]*externalTx), oracleFees: make(map[uint64]feeStamped), mempoolRedeems: make(map[[32]byte]*mempoolRedeem), + dir: dir, + walletType: cfg.Type, + } + + if b, err := os.ReadFile(filepath.Join(dir, vspFileName)); err == nil { + var v vsp + err = json.Unmarshal(b, &v) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal vsp file: %v", err) + } + w.vspV.Store(&v) + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("unable to read vsp file: %v", err) } w.cfgV.Store(walletCfg) @@ -925,6 +964,7 @@ func (dcr *ExchangeWallet) Connect(ctx context.Context) (*sync.WaitGroup, error) } success = true // All good, don't disconnect the wallet when this method returns. + dcr.connected.Store(true) // NotifyOnTipChange will return false if the wallet does not support // tip change notification. We'll use dcr.monitorBlocks below if so. @@ -5134,6 +5174,125 @@ func (dcr *ExchangeWallet) EstimateSendTxFee(address string, sendAmount, feeRate return finalFee, isValidAddress, nil } +func (dcr *ExchangeWallet) isSPV() bool { + return dcr.walletType == walletTypeSPV +} + +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) + if err != nil { + return nil, err + } + isRPC := !dcr.isSPV() + var vspURL string + if !isRPC { + if v := dcr.vspV.Load(); v != nil { + 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) + if err != nil { + return nil, fmt.Errorf("error retrieving stances: %w", err) + } + return &asset.TicketStakingStatus{ + TicketPrice: uint64(sdiff), + VSP: vspURL, + IsRPC: isRPC, + Tickets: tickets, + Stances: asset.Stances{ + VoteChoices: voteChoices, + TSpendPolicy: tSpendPolicy, + TreasuryPolicy: treasuryPolicy, + }, + }, nil +} + +func vspInfo(url string) (*vspdjson.VspInfoResponse, error) { + suffix := "/api/v3/vspinfo" + path, err := neturl.JoinPath(url, suffix) + if err != nil { + return nil, err + } + resp, err := http.Get(path) + if err != nil { + return nil, fmt.Errorf("http get error: %v", err) + } + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var info vspdjson.VspInfoResponse + err = json.Unmarshal(b, &info) + if err != nil { + return nil, err + } + return &info, nil +} + +// SetVSP sets the VSP provider. Ability to set can be checked with StakeStatus +// first. Only non-RPC (internal) wallets can be set. Part of the +// asset.TicketBuyer interface. +func (dcr *ExchangeWallet) SetVSP(url string) error { + if !dcr.isSPV() { + return errors.New("cannot set vsp for external wallet") + } + info, err := vspInfo(url) + if err != nil { + return err + } + v := vsp{ + URL: url, + PubKey: base64.StdEncoding.EncodeToString(info.PubKey), + FeePercentage: info.FeePercentage, + } + b, err := json.Marshal(&v) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dcr.dir, vspFileName), b, 0666); err != nil { + return err + } + dcr.vspV.Store(&v) + return nil +} + +// PurchaseTickets purchases n number of tickets. Part of the asset.TicketBuyer +// interface. +func (dcr *ExchangeWallet) PurchaseTickets(n int) ([]string, error) { + if n < 1 { + return nil, nil + } + if !dcr.connected.Load() { + return nil, errors.New("not connected, login first") + } + if !dcr.isSPV() { + return dcr.wallet.PurchaseTickets(dcr.ctx, n, "", "") + } + v := dcr.vspV.Load() + if v == nil { + return nil, errors.New("no vsp set") + } + vInfo := v.(*vsp) + return dcr.wallet.PurchaseTickets(dcr.ctx, n, vInfo.URL, vInfo.PubKey) +} + +// SetVotingPreferences sets the vote choices for all active tickets and future +// tickets. Nil maps can be provided for no change. Part of the +// asset.TicketBuyer interface. +func (dcr *ExchangeWallet) SetVotingPreferences(choices map[string]string, tspendPolicy map[string]string, treasuryPolicy map[string]string) error { + if !dcr.connected.Load() { + return errors.New("not connected, login first") + } + return dcr.wallet.SetVotingPreferences(dcr.ctx, choices, tspendPolicy, treasuryPolicy) +} + 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 003fcff6c1..037963ef2e 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -518,6 +518,28 @@ func (c *tRPCClient) Disconnected() bool { return c.disconnected } +func (c *tRPCClient) GetStakeInfo(ctx context.Context) (*walletjson.GetStakeInfoResult, error) { + return nil, 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 +} + +func (c *tRPCClient) GetTickets(ctx context.Context, includeImmature bool) ([]*chainhash.Hash, error) { + return nil, nil +} + +func (c *tRPCClient) GetVoteChoices(ctx context.Context) (*walletjson.GetVoteChoicesResult, error) { + return nil, nil +} + +func (c *tRPCClient) SetVoteChoice(ctx context.Context, agendaID, choiceID string) error { + return nil +} + func (c *tRPCClient) RawRequest(_ context.Context, method string, params []json.RawMessage) (json.RawMessage, error) { if rr, found := c.rawRes[method]; found { return rr, c.rawErr[method] // err probably should be nil, but respect the config diff --git a/client/asset/dcr/rpcwallet.go b/client/asset/dcr/rpcwallet.go index eea7e63eed..6a8cb5a45e 100644 --- a/client/asset/dcr/rpcwallet.go +++ b/client/asset/dcr/rpcwallet.go @@ -138,6 +138,13 @@ type rpcClient interface { RawRequest(ctx context.Context, method string, params []json.RawMessage) (json.RawMessage, error) WalletInfo(ctx context.Context) (*walletjson.WalletInfoResult, error) ValidateAddress(ctx context.Context, address stdaddr.Address) (*walletjson.ValidateAddressWalletResult, error) + GetStakeInfo(ctx context.Context) (*walletjson.GetStakeInfoResult, error) + 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) + GetTickets(ctx context.Context, includeImmature bool) ([]*chainhash.Hash, error) + GetVoteChoices(ctx context.Context) (*walletjson.GetVoteChoicesResult, error) + SetVoteChoice(ctx context.Context, agendaID, choiceID string) error } // newRPCWallet creates an rpcClient and uses it to construct a new instance @@ -864,6 +871,145 @@ 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) + if err != nil { + return 0, err + } + amt, err := dcrutil.NewAmount(si.Difficulty) + if err != nil { + return 0, err + } + return amt, 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 +} + +// Tickets returns active tickets. +func (w *rpcWallet) Tickets(ctx context.Context) ([]*asset.Ticket, error) { + const includeImmature = true + // GetTickets only works for clients with a dcrd backend. + hashes, err := w.rpcClient.GetTickets(ctx, includeImmature) + if err != nil { + return nil, err + } + tickets := make([]*asset.Ticket, 0, len(hashes)) + for _, h := range hashes { + tx, err := w.client().GetTransaction(ctx, h) + if err != nil { + w.log.Errorf("GetTransaction error for ticket %s: %v", h, err) + continue + } + msgTx, err := msgTxFromHex(tx.Hex) + if err != nil { + w.log.Errorf("Error decoding ticket %s tx hex: %v", h, err) + continue + } + + if len(msgTx.TxOut) < 1 { + w.log.Errorf("No outputs for ticket %s", h) + continue + } + + feeAmt, _ := dcrutil.NewAmount(tx.Fee) + + tickets = append(tickets, &asset.Ticket{ + Ticket: asset.TicketTransaction{ + Hash: h.String(), + TicketPrice: uint64(msgTx.TxOut[0].Value), + Fees: uint64(feeAmt), + Stamp: uint64(tx.Time), + BlockHeight: tx.BlockIndex, + }, + // The walletjson.GetTransactionResult returned from GetTransaction + // actually has a TicketStatus string field, but it doesn't appear + // to ever be populated by dcrwallet. + // Status: somehowConvertFromString(tx.TicketStatus), + + // Not sure how to get the spender through RPC. + // Spender: ?, + }) + } + + return tickets, nil +} + +// VotingPreferences returns current wallet voting preferences. +func (w *rpcWallet) VotingPreferences(ctx context.Context) ([]*walletjson.VoteChoice, []*walletjson.TSpendPolicyResult, []*walletjson.TreasuryPolicyResult, error) { + // Get consensus vote choices. + choices, err := w.rpcClient.GetVoteChoices(ctx) + if err != nil { + return nil, nil, nil, fmt.Errorf("unable to get vote choices: %v", err) + } + voteChoices := make([]*walletjson.VoteChoice, len(choices.Choices)) + for i, v := range choices.Choices { + vc := v + voteChoices[i] = &vc + } + // Get tspend voting policy. + const tSpendPolicyMethod = "tspendpolicy" + var tSpendRes []walletjson.TSpendPolicyResult + err = w.rpcClientRawRequest(ctx, tSpendPolicyMethod, nil, &tSpendRes) + 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 + } + // Get treasury voting policy. + const treasuryPolicyMethod = "treasurypolicy" + var treasuryRes []walletjson.TreasuryPolicyResult + err = w.rpcClientRawRequest(ctx, treasuryPolicyMethod, nil, &treasuryRes) + if err != nil { + return nil, nil, nil, fmt.Errorf("unable to get treasury policy: %v", err) + } + treasuryPolicy := make([]*walletjson.TreasuryPolicyResult, len(treasuryRes)) + for i, v := range treasuryRes { + tp := v + treasuryPolicy[i] = &tp + } + return voteChoices, tSpendPolicy, treasuryPolicy, nil +} + +// SetVotingPreferences sets voting preferences. +// +// NOTE: Will fail for communication problems with VSPs unlike internal wallets. +func (w *rpcWallet) SetVotingPreferences(ctx context.Context, choices, tSpendPolicy, + treasuryPolicy map[string]string) error { + for k, v := range choices { + if err := w.rpcClient.SetVoteChoice(ctx, k, v); err != nil { + return fmt.Errorf("unable to set vote choice: %v", err) + } + } + const setTSpendPolicyMethod = "settspendpolicy" + for k, v := range tSpendPolicy { + if err := w.rpcClientRawRequest(ctx, setTSpendPolicyMethod, anylist{k, v}, nil); err != nil { + return fmt.Errorf("unable to set tspend policy: %v", err) + } + } + const setTreasuryPolicyMethod = "settreasurypolicy" + for k, v := range treasuryPolicy { + if err := w.rpcClientRawRequest(ctx, setTreasuryPolicyMethod, anylist{k, v}, nil); err != nil { + return fmt.Errorf("unable to set treasury policy: %v", err) + } + } + return nil +} + // 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 074e6d30a8..803d7305fe 100644 --- a/client/asset/dcr/simnet_test.go +++ b/client/asset/dcr/simnet_test.go @@ -16,6 +16,8 @@ import ( "bytes" "context" "crypto/sha256" + "encoding/hex" + "math" "math/rand" "os" "os/exec" @@ -27,14 +29,20 @@ import ( "decred.org/dcrdex/client/asset" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/config" + "decred.org/dcrdex/dex/encode" dexdcr "decred.org/dcrdex/dex/networks/dcr" + "github.com/davecgh/go-spew/spew" + "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/decred/dcrd/dcrutil/v4" + "github.com/decred/dcrd/wire" ) const ( alphaAddress = "SsWKp7wtdTZYabYFYSc9cnxhwFEjA5g4pFc" betaAddress = "Ssge52jCzbixgFC736RSTrwAnvH3a4hcPRX" + gammaSeed = "1285a47d6a59f9c548b2a72c2c34a2de97967bede3844090102bbba76707fe9d" + vspAddr = "http://127.0.0.1:19591" ) var ( @@ -58,18 +66,21 @@ func mineAlpha() error { return exec.Command("tmux", "send-keys", "-t", "dcr-harness:0", "./mine-alpha 1", "C-m").Run() } -func tBackend(t *testing.T, name string, blkFunc func(string, error)) (*ExchangeWallet, *dex.ConnectionMaster) { +func tBackend(t *testing.T, name string, isInternal bool, blkFunc func(string, error)) (*ExchangeWallet, *dex.ConnectionMaster) { t.Helper() user, err := user.Current() if err != nil { t.Fatalf("error getting current user: %v", err) } - cfgPath := filepath.Join(user.HomeDir, "dextest", "dcr", name, name+".conf") - settings, err := config.Parse(cfgPath) - if err != nil { - t.Fatalf("error reading config options: %v", err) + settings := make(map[string]string) + if !isInternal { + cfgPath := filepath.Join(user.HomeDir, "dextest", "dcr", name, name+".conf") + var err error + settings, err = config.Parse(cfgPath) + if err != nil { + t.Fatalf("error reading config options: %v", err) + } } - settings["account"] = "default" walletCfg := &asset.WalletConfig{ Settings: settings, TipChange: func(err error) { @@ -79,6 +90,16 @@ func tBackend(t *testing.T, name string, blkFunc func(string, error)) (*Exchange t.Logf("peer count = %d, err = %v", num, err) }, } + if isInternal { + seed, err := hex.DecodeString(gammaSeed) + if err != nil { + t.Fatal(err) + } + dataDir := t.TempDir() + createSPVWallet(walletPassword, seed, dataDir, 0, 0, chaincfg.SimNetParams()) + walletCfg.Type = walletTypeSPV + walletCfg.DataDir = dataDir + } var backend asset.Wallet backend, err = NewWallet(walletCfg, tLogger, dex.Simnet) if err != nil { @@ -89,6 +110,23 @@ func tBackend(t *testing.T, name string, blkFunc func(string, error)) (*Exchange if err != nil { t.Fatalf("error connecting backend: %v", err) } + if isInternal { + i := 0 + for { + synced, _, err := backend.SyncStatus() + if err != nil { + t.Fatal(err) + } + if synced { + break + } + if i == 5 { + t.Fatal("spv wallet not synced after 5 seconds") + } + i++ + time.Sleep(time.Second) + } + } return backend.(*ExchangeWallet), cm } @@ -103,17 +141,27 @@ func newTestRig(t *testing.T, blkFunc func(string, error)) *testRig { backends: make(map[string]*ExchangeWallet), connectionMasters: make(map[string]*dex.ConnectionMaster, 3), } - rig.backends["alpha"], rig.connectionMasters["alpha"] = tBackend(t, "alpha", blkFunc) - rig.backends["beta"], rig.connectionMasters["beta"] = tBackend(t, "beta", blkFunc) + rig.backends["alpha"], rig.connectionMasters["alpha"] = tBackend(t, "alpha", false, blkFunc) + rig.backends["beta"], rig.connectionMasters["beta"] = tBackend(t, "beta", false, blkFunc) + rig.backends["gamma"], rig.connectionMasters["gamma"] = tBackend(t, "gamma", true, blkFunc) return rig } +// alpha is an external wallet connected to dcrd. func (rig *testRig) alpha() *ExchangeWallet { return rig.backends["alpha"] } + +// beta is an external spv wallet. func (rig *testRig) beta() *ExchangeWallet { return rig.backends["beta"] } + +// gamma is an internal spv wallet. +func (rig *testRig) gamma() *ExchangeWallet { + return rig.backends["gamma"] +} + func (rig *testRig) close(t *testing.T) { t.Helper() for name, cm := range rig.connectionMasters { @@ -531,3 +579,145 @@ func runTest(t *testing.T, splitTx bool) { t.Fatalf("error locking wallet: %v", err) } } + +func TestTickets(t *testing.T) { + rig := newTestRig(t, func(name string, err error) { + tLogger.Infof("%s has reported a new block, error = %v", name, err) + }) + defer rig.close(t) + tLogger.Info("Testing ticket methods with the alpha rig.") + testTickets(t, false, rig.alpha()) + // TODO: beta wallet is spv but has no vsp in its config. Add it and + // test here. + tLogger.Info("Testing ticket methods with the gamma rig.") + testTickets(t, true, rig.gamma()) +} + +func testTickets(t *testing.T, isInternal bool, ew *ExchangeWallet) { + + // Test StakeStatus. + ss, err := ew.StakeStatus() + if err != nil { + t.Fatalf("unable to get stake status: %v", err) + } + tLogger.Info("The following are stake status before setting vsp or purchasing tickets.") + spew.Dump(ss) + + // Test SetVSP. + err = ew.SetVSP(vspAddr) + if isInternal { + if err != nil { + t.Fatalf("unexpected error setting vsp for internal wallet: %v", err) + } + } else { + if err == nil { + t.Fatal("expected error setting vsp for external wallet") + } + } + + // Test PurchaseTickets. + if err := ew.Unlock(walletPassword); err != nil { + t.Fatalf("unable to unlock wallet: %v", err) + } + tickets, err := ew.PurchaseTickets(3) + if err != nil { + t.Fatalf("error purchasing tickets: %v", err) + } + tLogger.Infof("Purchased the following tickets: %v", tickets) + + var currentDeployments []chaincfg.ConsensusDeployment + var bestVer uint32 + for ver, deps := range ew.chainParams.Deployments { + if bestVer == 0 || ver > bestVer { + currentDeployments = deps + bestVer = ver + } + } + + choices := make(map[string]string) + for _, d := range currentDeployments { + choices[d.Vote.Id] = d.Vote.Choices[rand.Int()%len(d.Vote.Choices)].Id + } + + aPubKey := "034a43df1b95bf1b0dd77b53b8d880d4a5f47376fb036a5be5c1f3ba8d12ef65d7" + bPubKey := "02028dd2bbabbcd262c3e2326ae27a61fcc935beb2bbc2f0f76f3f5025ba4a3c5d" + + treasuryPolicy := map[string]string{ + aPubKey: "yes", + bPubKey: "no", + } + + var tspendPolicy map[string]string + if spvw, is := ew.wallet.(*spvWallet); is { + if dcrw, is := spvw.dcrWallet.(*extendedWallet); is { + txIn := &wire.TxIn{SignatureScript: encode.RandomBytes(66 + secp256k1.PubKeyBytesLenCompressed)} + tspendA := &wire.MsgTx{Expiry: math.MaxUint32 - 1, TxIn: []*wire.TxIn{txIn}} + tspendB := &wire.MsgTx{Expiry: math.MaxUint32 - 2, TxIn: []*wire.TxIn{txIn}} + txHashA, txHashB := tspendA.TxHash(), tspendB.TxHash() + tspendPolicy = map[string]string{ + txHashA.String(): "yes", + txHashB.String(): "no", + } + for _, tx := range []*wire.MsgTx{tspendA, tspendB} { + if err := dcrw.AddTSpend(*tx); err != nil { + t.Fatalf("Error adding tspend: %v", err) + } + } + } + } + + // Test SetVotingPreferences. + if err := ew.SetVotingPreferences(choices, tspendPolicy, treasuryPolicy); err != nil { + t.Fatalf("Error setting voting preferences: %v", err) + } + + // Test StakeStatus again. + ss, err = ew.StakeStatus() + if err != nil { + t.Fatalf("Unable to get stake status: %v", err) + } + 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)) + } + + for _, reportedChoice := range ss.Stances.VoteChoices { + choiceID, found := choices[reportedChoice.AgendaID] + if !found { + t.Fatalf("unknown agenda %s", reportedChoice.AgendaID) + } + if reportedChoice.ChoiceID != choiceID { + t.Fatalf("wrong choice reported. expected %s, got %s", choiceID, reportedChoice.ChoiceID) + } + } + + if len(ss.Stances.TreasuryPolicy) != len(treasuryPolicy) { + t.Fatalf("wrong number of treasury keys. expected %d, got %d", len(treasuryPolicy), len(ss.Stances.TreasuryPolicy)) + } + + for _, tp := range ss.Stances.TreasuryPolicy { + policy, found := treasuryPolicy[tp.Key] + if !found { + t.Fatalf("unknown treasury key %s", tp.Key) + } + if tp.Policy != policy { + t.Fatalf("wrong policy reported. expected %s, got %s", policy, tp.Policy) + } + } + + if len(ss.Stances.TSpendPolicy) != len(tspendPolicy) { + t.Fatalf("wrong number of tspends. expected %d, got %d", len(tspendPolicy), len(ss.Stances.TSpendPolicy)) + } + + for _, p := range ss.Stances.TSpendPolicy { + policy, found := tspendPolicy[p.Hash] + if !found { + t.Fatalf("unknown tspend tx %s", p.Hash) + } + if p.Policy != policy { + t.Fatalf("wrong policy reported. expected %s, got %s", policy, p.Policy) + } + } +} diff --git a/client/asset/dcr/spv.go b/client/asset/dcr/spv.go index 3eb01fa0c1..1801dea686 100644 --- a/client/asset/dcr/spv.go +++ b/client/asset/dcr/spv.go @@ -5,6 +5,7 @@ package dcr import ( "context" + "encoding/base64" "encoding/hex" "errors" "fmt" @@ -40,6 +41,7 @@ import ( "github.com/decred/dcrd/txscript/v4/stdaddr" "github.com/decred/dcrd/wire" "github.com/decred/slog" + vspclient "github.com/decred/vspd/client/v2" "github.com/jrick/logrotate/rotator" ) @@ -63,14 +65,11 @@ type dcrWallet interface { AccountBalance(ctx context.Context, account uint32, confirms int32) (wallet.Balances, error) LockedOutpoints(ctx context.Context, accountName string) ([]chainjson.TransactionInput, error) ListUnspent(ctx context.Context, minconf, maxconf int32, addresses map[string]struct{}, accountName string) ([]*walletjson.ListUnspentResult, error) - UnlockOutpoint(txHash *chainhash.Hash, index uint32) LockOutpoint(txHash *chainhash.Hash, index uint32) ListTransactionDetails(ctx context.Context, txHash *chainhash.Hash) ([]walletjson.ListTransactionsResult, error) MainChainTip(ctx context.Context) (hash chainhash.Hash, height int32) NewExternalAddress(ctx context.Context, account uint32, callOpts ...wallet.NextAddressCallOption) (stdaddr.Address, error) NewInternalAddress(ctx context.Context, account uint32, callOpts ...wallet.NextAddressCallOption) (stdaddr.Address, error) - SignTransaction(ctx context.Context, tx *wire.MsgTx, hashType txscript.SigHashType, additionalPrevScripts map[wire.OutPoint][]byte, - additionalKeysByAddress map[string]*dcrutil.WIF, p2shRedeemScriptsByAddress map[string][]byte) ([]wallet.SignatureError, error) PublishTransaction(ctx context.Context, tx *wire.MsgTx, n wallet.NetworkBackend) (*chainhash.Hash, error) BlockHeader(ctx context.Context, blockHash *chainhash.Hash) (*wire.BlockHeader, error) BlockInMainChain(ctx context.Context, hash *chainhash.Hash) (haveBlock, invalidated bool, err error) @@ -82,6 +81,18 @@ type dcrWallet interface { LoadPrivateKey(ctx context.Context, addr stdaddr.Address) (key *secp256k1.PrivateKey, zero func(), err error) TxDetails(ctx context.Context, txHash *chainhash.Hash) (*udb.TxDetails, error) GetTransactionsByHashes(ctx context.Context, txHashes []*chainhash.Hash) (txs []*wire.MsgTx, notFound []*wire.InvVect, err error) + StakeInfo(ctx context.Context) (*wallet.StakeInfoData, error) + PurchaseTickets(ctx context.Context, n wallet.NetworkBackend, req *wallet.PurchaseTicketsRequest) (*wallet.PurchaseTicketsResponse, error) + ForUnspentUnexpiredTickets(ctx context.Context, f func(hash *chainhash.Hash) error) error + GetTickets(ctx context.Context, f func([]*wallet.TicketSummary, *wire.BlockHeader) (bool, error), startBlock, endBlock *wallet.BlockIdentifier) error + TreasuryKeyPolicies() []wallet.TreasuryKeyPolicy + GetAllTSpends(ctx context.Context) []*wire.MsgTx + TSpendPolicy(tspendHash, ticketHash *chainhash.Hash) stake.TreasuryVoteT + VSPHostForTicket(ctx context.Context, ticketHash *chainhash.Hash) (string, error) + 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 + vspclient.Wallet // TODO: Rescan and DiscoverActiveAddresses can be used for a Rescanner. } @@ -823,6 +834,283 @@ 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 +} + +func newVSPClient(w vspclient.Wallet, vspHost, vspPubKey string, log dex.Logger) (*vspclient.AutoClient, error) { + return vspclient.New(vspclient.Config{ + URL: vspHost, + PubKey: vspPubKey, + Dialer: new(net.Dialer).DialContext, + Wallet: w, + Policy: &vspclient.Policy{ + MaxFee: 0.2e8, + FeeAcct: 0, + ChangeAcct: 0, + }, + }, log) +} + +// 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) { + vspClient, err := newVSPClient(w.dcrWallet, vspHost, vspPubKey, w.log.SubLogger("VSP")) + if err != nil { + return nil, err + } + request := &wallet.PurchaseTicketsRequest{ + Count: n, + MinConf: 1, + VSPFeePaymentProcess: vspClient.Process, + VSPFeeProcess: vspClient.FeePercentage, + // TODO: CSPP/mixing + } + res, err := w.dcrWallet.PurchaseTickets(ctx, w.spv, request) + if err != nil { + return nil, err + } + hashes := res.TicketHashes + hashStrs := make([]string, len(hashes)) + for i := range hashes { + hashStrs[i] = hashes[i].String() + } + return hashStrs, err +} + +// Tickets returns current active tickets. +func (w *spvWallet) Tickets(ctx context.Context) ([]*asset.Ticket, error) { + return w.ticketsInRange(ctx, 0, 0) +} + +func (w *spvWallet) ticketsInRange(ctx context.Context, fromHeight, toHeight int32) ([]*asset.Ticket, error) { + params := w.ChainParams() + + tickets := make([]*asset.Ticket, 0) + + 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 ticketSummary.Ticket == nil || len(ticketSummary.Ticket.MyOutputs) < 1 { + w.log.Errorf("No zeroth output") + } + + var blockHeight int64 = -1 + if hdr != nil { + blockHeight = int64(hdr.Height) + } + + 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 + } + return tickets, nil +} + +// VotingPreferences returns current voting preferences. +func (w *spvWallet) VotingPreferences(ctx context.Context) ([]*walletjson.VoteChoice, []*walletjson.TSpendPolicyResult, []*walletjson.TreasuryPolicyResult, error) { + _, agendas := wallet.CurrentAgendas(w.chainParams) + + choices, _, err := w.dcrWallet.AgendaChoices(ctx, nil) + if err != nil { + return nil, nil, nil, fmt.Errorf("unable to get agenda choices: %v", err) + } + + voteChoices := make([]*walletjson.VoteChoice, len(choices)) + + for i := range choices { + voteChoices[i] = &walletjson.VoteChoice{ + AgendaID: choices[i].AgendaID, + AgendaDescription: agendas[i].Vote.Description, + ChoiceID: choices[i].ChoiceID, + } + for j := range agendas[i].Vote.Choices { + if choices[i].ChoiceID == agendas[i].Vote.Choices[j].Id { + voteChoices[i].ChoiceDescription = agendas[i].Vote.Choices[j].Description + break + } + } + } + policyToStr := func(p stake.TreasuryVoteT) string { + var policy string + switch p { + case stake.TreasuryVoteYes: + policy = "yes" + case stake.TreasuryVoteNo: + policy = "no" + } + return policy + } + tspends := w.dcrWallet.GetAllTSpends(ctx) + tSpendPolicy := make([]*walletjson.TSpendPolicyResult, 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), + } + tSpendPolicy = append(tSpendPolicy, &r) + } + + policies := w.dcrWallet.TreasuryKeyPolicies() + treasuryPolicy := make([]*walletjson.TreasuryPolicyResult, 0, len(policies)) + for i := range policies { + r := walletjson.TreasuryPolicyResult{ + Key: hex.EncodeToString(policies[i].PiKey), + Policy: policyToStr(policies[i].Policy), + } + if policies[i].Ticket != nil { + r.Ticket = policies[i].Ticket.String() + } + treasuryPolicy = append(treasuryPolicy, &r) + } + + return voteChoices, tSpendPolicy, treasuryPolicy, nil +} + +// SetVotingPreferences sets voting preferences for the wallet and for vsps with +// active tickets. +func (w *spvWallet) SetVotingPreferences(ctx context.Context, choices, tspendPolicy, + treasuryPolicy map[string]string) error { + // Set the consensus vote choices for the wallet. + agendaChoices := make([]wallet.AgendaChoice, 0, len(choices)) + for k, v := range choices { + choice := wallet.AgendaChoice{ + AgendaID: k, + ChoiceID: v, + } + agendaChoices = append(agendaChoices, choice) + } + if len(agendaChoices) > 0 { + _, err := w.SetAgendaChoices(ctx, nil, agendaChoices...) + if err != nil { + return err + } + } + strToPolicy := func(s, t string) (stake.TreasuryVoteT, error) { + var policy stake.TreasuryVoteT + switch s { + case "abstain", "invalid", "": + policy = stake.TreasuryVoteInvalid + case "yes": + policy = stake.TreasuryVoteYes + case "no": + policy = stake.TreasuryVoteNo + default: + return 0, fmt.Errorf("unknown %s policy %q", t, s) + } + return policy, nil + } + // Set the tspend policy for the wallet. + for k, v := range tspendPolicy { + if len(k) != chainhash.MaxHashStringSize { + return fmt.Errorf("invalid tspend hash length, expected %d got %d", + chainhash.MaxHashStringSize, len(k)) + } + hash, err := chainhash.NewHashFromStr(k) + if err != nil { + return fmt.Errorf("invalid hash %s: %v", k, err) + } + policy, err := strToPolicy(v, "tspend") + if err != nil { + return err + } + err = w.dcrWallet.SetTSpendPolicy(ctx, hash, policy, nil) + if err != nil { + return err + } + } + // Set the treasury policy for the wallet. + for k, v := range treasuryPolicy { + pikey, err := hex.DecodeString(k) + if err != nil { + return fmt.Errorf("unable to decode pi key %s: %v", k, err) + } + if len(pikey) != secp256k1.PubKeyBytesLenCompressed { + return fmt.Errorf("treasury key %s must be 33 bytes", k) + } + policy, err := strToPolicy(v, "treasury") + if err != nil { + return err + } + err = w.dcrWallet.SetTreasuryKeyPolicy(ctx, pikey, policy, nil) + if err != nil { + return err + } + } + clientCache := make(map[string]*vspclient.AutoClient) + // Set voting preferences for VSPs. Continuing for all errors. + return w.dcrWallet.ForUnspentUnexpiredTickets(ctx, func(hash *chainhash.Hash) error { + vspHost, err := w.dcrWallet.VSPHostForTicket(ctx, hash) + if err != nil { + if errors.Is(err, walleterrors.NotExist) { + w.log.Warnf("ticket %s is not associated with a VSP", hash) + return nil + } + w.log.Warnf("unable to get VSP associated with ticket %s: %v", hash, err) + return nil + } + vspClient, have := clientCache[vspHost] + if !have { + info, err := vspInfo(vspHost) + if err != nil { + w.log.Warnf("unable to get info from vsp at %s for ticket %s: %v", vspHost, hash, err) + return nil + } + vspPubKey := base64.StdEncoding.EncodeToString(info.PubKey) + vspClient, err = newVSPClient(w.dcrWallet, vspHost, vspPubKey, w.log.SubLogger("VSP")) + if err != nil { + w.log.Warnf("unable to load vsp at %s for ticket %s: %v", vspHost, hash, err) + return nil + } + } + // Never return errors here, so all tickets are tried. + // The first error will be returned to the user. + err = vspClient.SetVoteChoice(ctx, hash, choices, tspendPolicy, treasuryPolicy) + if err != nil { + w.log.Warnf("unable to set vote for vsp at %s for ticket %s: %v", vspHost, hash, err) + } + 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) { diff --git a/client/asset/dcr/spv_test.go b/client/asset/dcr/spv_test.go index 7fa033d4b4..4d3922fc08 100644 --- a/client/asset/dcr/spv_test.go +++ b/client/asset/dcr/spv_test.go @@ -18,6 +18,7 @@ import ( "decred.org/dcrwallet/v3/wallet/udb" "github.com/decred/dcrd/blockchain/stake/v5" "github.com/decred/dcrd/chaincfg/chainhash" + "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/decred/dcrd/dcrutil/v4" "github.com/decred/dcrd/gcs/v4" @@ -28,7 +29,7 @@ import ( ) type tDcrWallet struct { - wallet.NetworkBackend + spvSyncer knownAddr wallet.KnownAddress knownAddrErr error txsByHash []*wire.MsgTx @@ -192,6 +193,146 @@ func (w *tDcrWallet) GetTransactionsByHashes(ctx context.Context, txHashes []*ch return w.txsByHash, nil, w.txsByHashErr } +func (w *tDcrWallet) StakeInfo(ctx context.Context) (*wallet.StakeInfoData, error) { + return nil, nil +} + +func (w *tDcrWallet) PurchaseTickets(context.Context, wallet.NetworkBackend, *wallet.PurchaseTicketsRequest) (*wallet.PurchaseTicketsResponse, error) { + return nil, nil +} + +func (w *tDcrWallet) ForUnspentUnexpiredTickets(ctx context.Context, f func(hash *chainhash.Hash) error) error { + return nil +} + +func (w *tDcrWallet) GetTickets(ctx context.Context, f func([]*wallet.TicketSummary, *wire.BlockHeader) (bool, error), startBlock, endBlock *wallet.BlockIdentifier) error { + return nil +} + +func (w *tDcrWallet) AgendaChoices(ctx context.Context, ticketHash *chainhash.Hash) (choices wallet.AgendaChoices, voteBits uint16, err error) { + return nil, 0, nil +} + +func (w *tDcrWallet) TreasuryKeyPolicies() []wallet.TreasuryKeyPolicy { + return nil +} + +func (w *tDcrWallet) GetAllTSpends(ctx context.Context) []*wire.MsgTx { + return nil +} + +func (w *tDcrWallet) TSpendPolicy(tspendHash, ticketHash *chainhash.Hash) stake.TreasuryVoteT { + return 0 +} + +func (w *tDcrWallet) VSPHostForTicket(ctx context.Context, ticketHash *chainhash.Hash) (string, error) { + return "", nil +} + +func (w *tDcrWallet) SetAgendaChoices(ctx context.Context, ticketHash *chainhash.Hash, choices ...wallet.AgendaChoice) (voteBits uint16, err error) { + return 0, nil +} + +func (w *tDcrWallet) SetTSpendPolicy(ctx context.Context, tspendHash *chainhash.Hash, policy stake.TreasuryVoteT, ticketHash *chainhash.Hash) error { + return nil +} + +func (w *tDcrWallet) SetTreasuryKeyPolicy(ctx context.Context, pikey []byte, policy stake.TreasuryVoteT, ticketHash *chainhash.Hash) error { + return nil +} + +func (w *tDcrWallet) Spender(ctx context.Context, out *wire.OutPoint) (*wire.MsgTx, uint32, error) { + return nil, 0, nil +} + +func (w *tDcrWallet) ChainParams() *chaincfg.Params { + return nil +} + +func (w *tDcrWallet) TxBlock(ctx context.Context, hash *chainhash.Hash) (chainhash.Hash, int32, error) { + return chainhash.Hash{}, 0, nil +} + +func (w *tDcrWallet) DumpWIFPrivateKey(ctx context.Context, addr stdaddr.Address) (string, error) { + return "", nil +} + +func (w *tDcrWallet) VSPFeeHashForTicket(ctx context.Context, ticketHash *chainhash.Hash) (chainhash.Hash, error) { + return chainhash.Hash{}, nil +} + +func (w *tDcrWallet) UpdateVspTicketFeeToStarted(ctx context.Context, ticketHash, feeHash *chainhash.Hash, host string, pubkey []byte) error { + return nil +} + +func (w *tDcrWallet) ReserveOutputsForAmount(ctx context.Context, account uint32, amount dcrutil.Amount, minconf int32) ([]wallet.Input, error) { + return nil, nil +} + +func (w *tDcrWallet) NewChangeAddress(ctx context.Context, account uint32) (stdaddr.Address, error) { + return nil, nil +} + +func (w *tDcrWallet) RelayFee() dcrutil.Amount { + return 0 +} + +func (w *tDcrWallet) SetPublished(ctx context.Context, hash *chainhash.Hash, published bool) error { + return nil +} + +func (w *tDcrWallet) AddTransaction(ctx context.Context, tx *wire.MsgTx, blockHash *chainhash.Hash) error { + return nil +} + +func (w *tDcrWallet) UpdateVspTicketFeeToPaid(ctx context.Context, ticketHash, feeHash *chainhash.Hash, host string, pubkey []byte) error { + return nil +} + +func (w *tDcrWallet) NetworkBackend() (wallet.NetworkBackend, error) { + return nil, nil +} + +func (w *tDcrWallet) RevokeTickets(ctx context.Context, rpcCaller wallet.Caller) error { + return nil +} + +func (w *tDcrWallet) UpdateVspTicketFeeToErrored(ctx context.Context, ticketHash *chainhash.Hash, host string, pubkey []byte) error { + return nil +} + +func (w *tDcrWallet) TSpendPolicyForTicket(ticketHash *chainhash.Hash) map[string]string { + return nil +} + +func (w *tDcrWallet) TreasuryKeyPolicyForTicket(ticketHash *chainhash.Hash) map[string]string { + return nil +} + +func (w *tDcrWallet) AbandonTransaction(ctx context.Context, hash *chainhash.Hash) error { + return nil +} + +func (w *tDcrWallet) TxConfirms(ctx context.Context, hash *chainhash.Hash) (int32, error) { + return 0, nil +} + +func (w *tDcrWallet) IsVSPTicketConfirmed(ctx context.Context, ticketHash *chainhash.Hash) (bool, error) { + return false, nil +} + +func (w *tDcrWallet) UpdateVspTicketFeeToConfirmed(ctx context.Context, ticketHash, feeHash *chainhash.Hash, host string, pubkey []byte) error { + return nil +} + +func (w *tDcrWallet) VSPTicketInfo(ctx context.Context, ticketHash *chainhash.Hash) (*wallet.VSPTicket, error) { + return nil, nil +} + +func (w *tDcrWallet) SignMessage(ctx context.Context, msg string, addr stdaddr.Address) (sig []byte, err error) { + return 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 665e70b22c..74e7ab5f5a 100644 --- a/client/asset/dcr/wallet.go +++ b/client/asset/dcr/wallet.go @@ -13,6 +13,7 @@ import ( "github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/decred/dcrd/dcrutil/v4" chainjson "github.com/decred/dcrd/rpc/jsonrpc/types/v4" "github.com/decred/dcrd/txscript/v4/stdaddr" "github.com/decred/dcrd/wire" @@ -143,7 +144,19 @@ 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) + // 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) + // SetVotingPreferences sets preferences used when a ticket is chosen to + // be voted on. + SetVotingPreferences(ctx context.Context, choices, tspendPolicy, treasuryPolicy map[string]string) error Reconfigure(ctx context.Context, cfg *asset.WalletConfig, net dex.Network, currentAddress, depositAccount string) (restart bool, err error) } diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index 5ead25cae7..d3e5f0c6b2 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -89,7 +89,7 @@ const ( // see DecodeCoinID func for details. coinIDTakerFoundMakerRedemption = "TakerFoundMakerRedemption:" - // maxTxFeeGwei is the default max amout of eth that can be used in one + // maxTxFeeGwei is the default max amount of eth that can be used in one // transaction. This is set by the host in the case of providers. The // internal node currently has no max but also cannot be used since the // merge. diff --git a/client/asset/interface.go b/client/asset/interface.go index 5172074e50..5d5b5930ad 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -8,6 +8,7 @@ import ( "time" "decred.org/dcrdex/dex" + dcrwalletjson "decred.org/dcrwallet/v3/rpc/jsonrpc/types" "github.com/decred/dcrd/dcrec/secp256k1/v4" ) @@ -31,6 +32,7 @@ const ( WalletTraitShielded // The wallet is ShieldedWallet (e.g. ZCash) WalletTraitTokenApprover // The wallet is a TokenApprover WalletTraitAccountLocker // The wallet must have enough balance for redemptions before a trade. + WalletTraitTicketBuyer // The wallet can participate in decred staking. ) // IsRescanner tests if the WalletTrait has the WalletTraitRescanner bit set. @@ -123,6 +125,12 @@ func (wt WalletTrait) IsAccountLocker() bool { return wt&WalletTraitAccountLocker != 0 } +// IsTicketBuyer tests if the WalletTrait has the WalletTraitTicketBuyer bit set, +// which indicates the wallet implements the TicketBuyer interface. +func (wt WalletTrait) IsTicketBuyer() bool { + return wt&WalletTraitTicketBuyer != 0 +} + // DetermineWalletTraits returns the WalletTrait bitset for the provided Wallet. func DetermineWalletTraits(w Wallet) (t WalletTrait) { if _, is := w.(Rescanner); is { @@ -170,7 +178,9 @@ func DetermineWalletTraits(w Wallet) (t WalletTrait) { if _, is := w.(AccountLocker); is { t |= WalletTraitAccountLocker } - + if _, is := w.(TicketBuyer); is { + t |= WalletTraitTicketBuyer + } return t } @@ -872,6 +882,80 @@ type TokenApprover interface { ApprovalFee(assetVer uint32, approval bool) (uint64, error) } +// TicketTransaction represents a ticket transaction. +type TicketTransaction struct { + Hash string `json:"hash"` + TicketPrice uint64 `json:"ticketPrice"` + Fees uint64 `json:"fees"` + Stamp uint64 `json:"stamp"` + BlockHeight int64 `json:"blockHeight"` +} + +// TicketStatus from dcrwallet. +type TicketStatus uint + +// Copy of wallet.TicketStatus +const ( + TicketStatusUnknown TicketStatus = iota + TicketStatusUnmined + TicketStatusImmature + TicketStatusLive + TicketStatusVoted + TicketStatusMissed + TicketStatusExpired + TicketStatusUnspent + TicketStatusRevoked +) + +// Ticket holds information about a decred ticket. +type Ticket struct { + Ticket TicketTransaction `json:"ticket"` + Status TicketStatus `json:"status"` + Spender string `json:"spender"` +} + +// Stances are vote choices. +type Stances struct { + VoteChoices []*dcrwalletjson.VoteChoice `json:"voteChoices"` + TSpendPolicy []*dcrwalletjson.TSpendPolicyResult `json:"tSpendPolicy"` + TreasuryPolicy []*dcrwalletjson.TreasuryPolicyResult `json:"treasuryPolicy"` +} + +// TicketStakingStatus holds various stake information from the wallet. +type TicketStakingStatus struct { + // TicketPrice is the current price of one ticket. Also known as the + // stake difficulty. + TicketPrice uint64 `json:"ticketPrice"` + // 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 + // set a new VSP and some other information may not be available. + IsRPC bool `json:"isRPC"` + // Tickets returns current active tickets up until they are voted or + // revoked. Includes unconfirmed tickets. + Tickets []*Ticket `json:"tickets"` + // Stances returns current voting preferences. + Stances Stances `json:"stances"` +} + +// TicketBuyer is a wallet that can participate in decred staking. +// +// TODO: Consider adding (*AutoClient).ProcessUnprocessedTickets/ProcessManagedTickets +// to be used when restoring wallet from seed. +type TicketBuyer interface { + // StakeStatus returns current staking statuses such as currently owned + // tickets, ticket price, and current voting preferences. + 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) ([]string, 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 +} + // Bond is the fidelity bond info generated for a certain account ID, amount, // and lock time. These data are intended for the "post bond" request, in which // the server pre-validates the unsigned transaction, the client then publishes diff --git a/client/cmd/dexc-desktop/go.mod b/client/cmd/dexc-desktop/go.mod index cf07a77a2e..8feb18dbae 100644 --- a/client/cmd/dexc-desktop/go.mod +++ b/client/cmd/dexc-desktop/go.mod @@ -76,6 +76,9 @@ require ( github.com/decred/dcrd/wire v1.6.0 // indirect github.com/decred/go-socks v1.1.0 // indirect github.com/decred/slog v1.2.0 // indirect + github.com/decred/vspd/client/v2 v2.1.0 // indirect + github.com/decred/vspd/types v1.1.0 // indirect + github.com/decred/vspd/types/v2 v2.0.0 // indirect github.com/dgraph-io/badger v1.6.2 // indirect github.com/dgraph-io/ristretto v0.0.2 // indirect github.com/dustin/go-humanize v1.0.0 // indirect diff --git a/client/cmd/dexc-desktop/go.sum b/client/cmd/dexc-desktop/go.sum index 39dcc246ec..9f18e1f3ad 100644 --- a/client/cmd/dexc-desktop/go.sum +++ b/client/cmd/dexc-desktop/go.sum @@ -314,6 +314,12 @@ github.com/decred/go-socks v1.1.0 h1:dnENcc0KIqQo3HSXdgboXAHgqsCIutkqq6ntQjYtm2U github.com/decred/go-socks v1.1.0/go.mod h1:sDhHqkZH0X4JjSa02oYOGhcGHYp12FsY1jQ/meV8md0= github.com/decred/slog v1.2.0 h1:soHAxV52B54Di3WtKLfPum9OFfWqwtf/ygf9njdfnPM= github.com/decred/slog v1.2.0/go.mod h1:kVXlGnt6DHy2fV5OjSeuvCJ0OmlmTF6LFpEPMu/fOY0= +github.com/decred/vspd/client/v2 v2.1.0 h1:RzwmM/FCvpJDskNMeqeJ8UNnlR7kLCl3JlG8iZiLbG0= +github.com/decred/vspd/client/v2 v2.1.0/go.mod h1:r/CtdQF7TmuoIaFuanHtUMYYlQxWgRBGapdn4b+Bouc= +github.com/decred/vspd/types v1.1.0 h1:hTeqQwgRUN2FGIbuCIdyzBejKV+jxKrmEIcLKxpsB1g= +github.com/decred/vspd/types v1.1.0/go.mod h1:THsO8aBSwWBq6ZsIG25cNqbkNb+EEASXzLhFvODVc0s= +github.com/decred/vspd/types/v2 v2.0.0 h1:FaPA+W4OOMRWK+Vk4fyyYdXoVLRMMRQsxzsnSjJjOnI= +github.com/decred/vspd/types/v2 v2.0.0/go.mod h1:2xnNqedkt9GuL+pK8uIzDxqYxFlwLRflYFJH64b76n0= github.com/denis-tingajkin/go-header v0.4.2/go.mod h1:eLRHAVXzE5atsKAnNRDB90WHCFFnBUn4RN0nRcs1LJA= github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f/go.mod h1:xH/i4TFMt8koVQZ6WFms69WAsDWr2XsYL3Hkl7jkoLE= github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4= diff --git a/dex/testing/dcr/create-vspd.sh b/dex/testing/dcr/create-vspd.sh new file mode 100755 index 0000000000..4d681e1b4f --- /dev/null +++ b/dex/testing/dcr/create-vspd.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Script for creating dcr vspd, dcr harness and wallets should be running before executing. +set -e + +# The following are required script arguments +TMUX_WIN_ID=$1 +PORT=$2 +FEE_XPUB=$3 + +VSPD_DIR="${NODES_ROOT}/vspd" + +git clone -b client/v2.1.0 https://github.com/decred/vspd ${VSPD_DIR} + +DCRD_PORT="${ALPHA_NODE_RPC_PORT}" +DCRD_CERT="${NODES_ROOT}/alpha/rpc.cert" +USER="${RPC_USER}" +PASS="${RPC_PASS}" +WALLET_PORT="${VSPD_WALLET_RPC_PORT}" +DCRWALLET_RPC_PORT="${ALPHA_WALLET_RPC_PORT}" + +WALLET_CERT="${NODES_ROOT}/vspdwallet/rpc.cert" + +# vspd config +cat > "${VSPD_DIR}/vspd.conf" <> "${WALLET_DIR}/${NAME}.conf" +cat >> "${WALLET_DIR}/${NAME}.conf" <> "${WALLET_DIR}/${NAME}.conf" <> "${WALLET_DIR}/${NAME}.conf" fi diff --git a/dex/testing/dcr/harness.sh b/dex/testing/dcr/harness.sh index f31bf8c239..3d7938f98e 100755 --- a/dex/testing/dcr/harness.sh +++ b/dex/testing/dcr/harness.sh @@ -15,7 +15,7 @@ export BETA_NODE_RPC_PORT="19571" ALPHA_WALLET_SEED="b280922d2cffda44648346412c5ec97f429938105003730414f10b01e1402eac" ALPHA_MINING_ADDR="SsXciQNTo3HuV5tX3yy4hXndRWgLMRVC7Ah" -ALPHA_WALLET_RPC_PORT="19562" +export ALPHA_WALLET_RPC_PORT="19562" ALPHA_WALLET_HTTPPROF_PORT="19563" # The alpha wallet clone uses the same seed as the alpha wallet. @@ -34,6 +34,14 @@ TRADING_WALLET2_SEED="3db72efa55b9e6cce9c27dde9bea848c6199004f9b1ae2add3b0438949 TRADING_WALLET2_ADDRESS="SsYW5LPmGCvvHuWok8U9FQu1kotv8LpvoEt" TRADING_WALLET2_PORT="19582" +VSPD_WALLET_SEED="2db72efa55b9e6cce9c27dde9bea848c6199004f9b1ae2add3b04389495edb9c" +export VSPD_WALLET_RPC_PORT="19590" + +# Address for testing with an internal wallet in client/assets/dcr +VSP_HARNESS_TEST_ADDR="SsXsicdfL1jB2Rzu2UA7P2B9gnpqA9YGypw" + +VSPD_PORT="19591" + # WAIT can be used in a send-keys call along with a `wait-for donedcr` command to # wait for process termination. WAIT="; tmux wait-for -S donedcr" @@ -44,7 +52,7 @@ export NODES_ROOT export SHELL=$(which bash) if [ -d "${NODES_ROOT}" ]; then - rm -R "${NODES_ROOT}" + rm -fR "${NODES_ROOT}" fi mkdir -p "${NODES_ROOT}/alpha" mkdir -p "${NODES_ROOT}/beta" @@ -147,7 +155,7 @@ chmod +x "${NODES_ROOT}/harness-ctl/mine-beta" # alpha wallet clone does not need to purchase tickets or do anything else # really other than just voting on tickets purchased with the alpha wallet. cat > "${NODES_ROOT}/harness-ctl/clone-w-alpha" <