From be0e3d7618fe12efa88fc768fca82b0bfd93069e Mon Sep 17 00:00:00 2001 From: JoeGruff Date: Thu, 30 Mar 2023 11:31:49 +0900 Subject: [PATCH] client/dcr: Add staking methods. --- client/asset/dcr/dcr.go | 154 +++ client/asset/dcr/dcr_test.go | 22 + client/asset/dcr/rpcwallet.go | 99 ++ client/asset/dcr/simnet_test.go | 150 ++- client/asset/dcr/spv.go | 231 +++- client/asset/dcr/spv_test.go | 44 + client/asset/dcr/vsp/client.go | 112 ++ client/asset/dcr/vsp/errors.go | 21 + client/asset/dcr/vsp/feepayment.go | 1037 +++++++++++++++++ client/asset/dcr/vsp/loader.go | 24 + client/asset/dcr/vsp/log.go | 19 + client/asset/dcr/vsp/prng.go | 89 ++ client/asset/dcr/vsp/vsp.go | 448 +++++++ client/asset/dcr/wallet.go | 13 +- client/asset/interface.go | 37 + dex/testing/dcr/create-vspd.sh | 55 + dex/testing/dcr/create-wallet.sh | 13 +- dex/testing/dcr/harness.sh | 56 +- dex/testing/dcr/vspdtemplates/admin.html | 213 ++++ dex/testing/dcr/vspdtemplates/footer.html | 27 + dex/testing/dcr/vspdtemplates/header.html | 76 ++ dex/testing/dcr/vspdtemplates/homepage.html | 35 + dex/testing/dcr/vspdtemplates/login.html | 16 + .../vspdtemplates/ticket-search-result.html | 199 ++++ dex/testing/dcr/vspdtemplates/vsp-stats.html | 40 + dex/testing/loadbot/go.mod | 1 + dex/testing/loadbot/go.sum | 2 + go.mod | 3 + go.sum | 2 + 29 files changed, 3210 insertions(+), 28 deletions(-) create mode 100644 client/asset/dcr/vsp/client.go create mode 100644 client/asset/dcr/vsp/errors.go create mode 100644 client/asset/dcr/vsp/feepayment.go create mode 100644 client/asset/dcr/vsp/loader.go create mode 100644 client/asset/dcr/vsp/log.go create mode 100644 client/asset/dcr/vsp/prng.go create mode 100644 client/asset/dcr/vsp/vsp.go create mode 100755 dex/testing/dcr/create-vspd.sh create mode 100644 dex/testing/dcr/vspdtemplates/admin.html create mode 100644 dex/testing/dcr/vspdtemplates/footer.html create mode 100644 dex/testing/dcr/vspdtemplates/header.html create mode 100644 dex/testing/dcr/vspdtemplates/homepage.html create mode 100644 dex/testing/dcr/vspdtemplates/login.html create mode 100644 dex/testing/dcr/vspdtemplates/ticket-search-result.html create mode 100644 dex/testing/dcr/vspdtemplates/vsp-stats.html diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 94d7c3acce..11d322bf54 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 ( @@ -88,6 +92,8 @@ const ( // freshFeeAge is the expiry age for cached fee rates of external origin, // past which fetchFeeFromOracle should be used to refresh the rate. freshFeeAge = time.Minute + + vspFileName = "vsp.json" ) var ( @@ -537,6 +543,14 @@ type exchangeWalletConfig struct { apiFeeFallback bool } +// vsp holds info needed for purchasing tickes from a vsp. pub 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. @@ -568,6 +582,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 @@ -585,6 +601,9 @@ type ExchangeWallet struct { externalTxMtx sync.RWMutex externalTxCache map[chainhash.Hash]*externalTx + + vspV atomic.Value // *vsp + vspInfo func(url string) (*vspdjson.VspInfoResponse, error) } func (dcr *ExchangeWallet) config() *exchangeWalletConfig { @@ -661,6 +680,7 @@ var _ asset.Withdrawer = (*ExchangeWallet)(nil) var _ asset.LiveReconfigurer = (*ExchangeWallet)(nil) var _ asset.TxFeeEstimator = (*ExchangeWallet)(nil) var _ asset.Bonder = (*ExchangeWallet)(nil) +var _ asset.TicketBuyer = (*ExchangeWallet)(nil) type block struct { height int64 @@ -802,6 +822,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, err + } + w := &ExchangeWallet{ log: logger, chainParams: chainParams, @@ -812,6 +837,22 @@ func unconnectedWallet(cfg *asset.WalletConfig, dcrCfg *walletConfig, chainParam findRedemptionQueue: make(map[outPoint]*findRedemptionReq), externalTxCache: make(map[chainhash.Hash]*externalTx), oracleFees: make(map[uint64]feeStamped), + dir: dir, + vspInfo: vspInfo, + walletType: cfg.Type, + } + + if b, err := os.ReadFile(filepath.Join(dir, vspFileName)); err != nil { + if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + } else { + var v vsp + err = json.Unmarshal(b, &v) + if err != nil { + return nil, err + } + w.vspV.Store(v) } w.cfgV.Store(walletCfg) @@ -4523,6 +4564,119 @@ func (dcr *ExchangeWallet) EstimateSendTxFee(address string, sendAmount, feeRate return finalFee, isValidAddress, nil } +func (dcr *ExchangeWallet) isInternal() bool { + return dcr.walletType == walletTypeSPV +} + +// TicketPrice is the current price of one ticket. Also known as the stake +// difficulty. Part of the asset.TicketBuyer interface. +func (dcr *ExchangeWallet) TicketPrice() (uint64, error) { + sdiff, err := dcr.wallet.StakeDiff(dcr.ctx) + if err != nil { + return 0, err + } + return uint64(sdiff), nil +} + +// VSP returns the currently set VSP address and fee. +func (dcr *ExchangeWallet) VSP() (addr string, feePercentage float64, err error) { + if !dcr.isInternal() { + return "", 0.0, errors.New("unable to get external VSP stats") + } + v := dcr.vspV.Load().(*vsp) + if v == nil { + return "", 0.0, errors.New("no vsp set") + } + return v.Url, v.FeePercentage, nil +} + +// CanSetVSP returns whether the VSP can be changed. It cannot for rpcwallets +// but can for internal. Part of the asset.TicketBuyer interface. +func (dcr *ExchangeWallet) CanSetVSP() bool { + // Cannot set for rpcwallets. + return dcr.isInternal() +} + +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 should be checked with CanSetVSP +// first. Part of the asset.TicketBuyer interface. +func (dcr *ExchangeWallet) SetVSP(url string) error { + if !dcr.isInternal() { + return errors.New("cannot set vsp for external wallet") + } + info, err := dcr.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 amout of tickets. Ability to purchase should be +// checked with CanPurchaseTickets. Part of the asset.TicketBuyer interface. +func (dcr *ExchangeWallet) PurchaseTickets(n int) error { + if !dcr.isInternal() { + return dcr.wallet.PurchaseTickets(dcr.ctx, n, "", "") + } + v := dcr.vspV.Load().(*vsp) + if v == nil { + return errors.New("no vsp set") + } + return dcr.wallet.PurchaseTickets(dcr.ctx, n, v.Url, v.PubKey) +} + +// Tickets returns current active tickets up until they are able to be spent. +// Includes unconfirmed tickets. Part of the asset.TicketBuyer interface. +func (dcr *ExchangeWallet) Tickets() ([]string, error) { + return dcr.wallet.Tickets(dcr.ctx) +} + +// VotingPreferences returns current voting preferences. Part of the +// asset.TicketBuyer interface. +func (dcr *ExchangeWallet) VotingPreferences() ([]*walletjson.VoteChoice, []*walletjson.TSpendPolicyResult, []*walletjson.TreasuryPolicyResult, error) { + return dcr.wallet.VotingPreferences(dcr.ctx) +} + +// SetVotingPreferences sets default voting settings 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 { + return dcr.wallet.SetVotingPreferences(dcr.ctx, choices, tspendPolicy, treasuryPolicy, dcr.vspInfo) +} + 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 cf442fa5cd..0e929f462c 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -511,6 +511,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 3eca00305b..30f147fc57 100644 --- a/client/asset/dcr/rpcwallet.go +++ b/client/asset/dcr/rpcwallet.go @@ -31,6 +31,7 @@ import ( "github.com/decred/dcrd/rpcclient/v7" "github.com/decred/dcrd/txscript/v4/stdaddr" "github.com/decred/dcrd/wire" + vspdjson "github.com/decred/vspd/types" ) var ( @@ -138,6 +139,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 +872,97 @@ func (w *rpcWallet) AddressPrivKey(ctx context.Context, address stdaddr.Address) return &priv, nil } +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 +} + +func (w *rpcWallet) PurchaseTickets(ctx context.Context, n int, _, _ string) error { + _, 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*/) + return err +} + +func (w *rpcWallet) Tickets(ctx context.Context) ([]string, 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 + } + hashStrs := make([]string, 0, len(hashes)) + for i := range hashes { + hashStrs = append(hashStrs, hashes[i].String()) + } + return hashStrs, nil +} + +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 { + voteChoices[i] = &v + } + // 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 { + tSpendPolicy[i] = &v + } + // 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 { + treasuryPolicy[i] = &v + } + return voteChoices, tSpendPolicy, treasuryPolicy, nil +} + +// NOTE: Will fail for communication problems with VSPs unlike internal wallets. +func (w *rpcWallet) SetVotingPreferences(ctx context.Context, choices, tSpendPolicy, + treasuryPolicy map[string]string, _ func(url string) (*vspdjson.VspInfoResponse, error)) 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 8cba71e126..8a3e560bbf 100644 --- a/client/asset/dcr/simnet_test.go +++ b/client/asset/dcr/simnet_test.go @@ -16,6 +16,7 @@ import ( "bytes" "context" "crypto/sha256" + "encoding/hex" "math/rand" "os" "os/exec" @@ -28,6 +29,7 @@ import ( "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/config" dexdcr "decred.org/dcrdex/dex/networks/dcr" + "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/decred/dcrd/dcrutil/v4" ) @@ -35,6 +37,8 @@ import ( const ( alphaAddress = "SsWKp7wtdTZYabYFYSc9cnxhwFEjA5g4pFc" betaAddress = "Ssge52jCzbixgFC736RSTrwAnvH3a4hcPRX" + gammaSeed = "1285a47d6a59f9c548b2a72c2c34a2de97967bede3844090102bbba76707fe9d" + vspAddr = "http://127.0.0.1:19591" ) var ( @@ -58,18 +62,22 @@ 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" } - settings["account"] = "default" walletCfg := &asset.WalletConfig{ Settings: settings, TipChange: func(err error) { @@ -79,6 +87,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 +107,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 +138,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 +576,92 @@ 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 vps 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) { + // Testing isInternal helper method. + if isInternal != ew.isInternal() { + t.Fatalf("expected isInternal %v but got %v", isInternal, ew.isInternal()) + } + + // Testing TicketPrice. + tp, err := ew.TicketPrice() + if err != nil { + t.Fatalf("ticket price error: %v", err) + } + tLogger.Infof("Ticket price is %d atoms", tp) + + // Testing CanSetVSP. + if ew.CanSetVSP() != isInternal { + t.Fatal("can set vsp not same as is internal") + } + + // Testing SetVSP. + err = ew.SetVSP(vspAddr) + if isInternal { + if err != nil { + t.Fatalf("unexected error setting spv for internal wallet: %v", err) + } + } else { + if err == nil { + t.Fatal("exected error setting spv for external wallet") + } + } + + // Test getting VSP. + addr, feePercent, err := ew.VSP() + if isInternal { + if err != nil { + t.Fatalf("unexected error setting spv for internal wallet: %v", err) + } + if addr != vspAddr { + t.Fatalf("wanted vsp addr %s but got %s", vspAddr, addr) + } + if feePercent != 2.0 { + t.Fatalf("wanted vsp fee %v but got %v", 2.0, feePercent) + } + } else { + if err == nil { + t.Fatal("exected error setting spv for external wallet") + } + } + + // Testing PurchaseTickets. + if err := ew.Unlock(walletPassword); err != nil { + t.Fatalf("unable to unlock wallet: %v", err) + } + if err = ew.PurchaseTickets(3); err != nil { + t.Fatalf("error purchasing tickets: %v", err) + } + + // Test getting tickets. + hashes, err := ew.Tickets() + if err != nil { + t.Fatalf("error getting tickets: %v", err) + } + tLogger.Infof("Ticket hashes are %v", hashes) + + // Test getting VotingPreferences. + vc, tspolicy, tpolicy, err := ew.VotingPreferences() + if err != nil { + t.Fatalf("error getting voting preferences: %v", err) + } + tLogger.Infof("Vote choices %v\nTSpend policy %v\nTreasury policy %v", vc, tspolicy, tpolicy) + + // Testing SetVotingPreferences. + if err := ew.SetVotingPreferences(nil, nil, nil); err != nil { + t.Fatalf("error setting voting preferences: %v", err) + } +} diff --git a/client/asset/dcr/spv.go b/client/asset/dcr/spv.go index 9e4e9abd3e..656e368cfc 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" @@ -19,6 +20,7 @@ import ( "time" "decred.org/dcrdex/client/asset" + loadvsp "decred.org/dcrdex/client/asset/dcr/vsp" "decred.org/dcrdex/dex" "decred.org/dcrwallet/v2/chain" walleterrors "decred.org/dcrwallet/v2/errors" @@ -40,6 +42,7 @@ import ( "github.com/decred/dcrd/txscript/v4/stdaddr" "github.com/decred/dcrd/wire" "github.com/decred/slog" + vspdjson "github.com/decred/vspd/types" "github.com/jrick/logrotate/rotator" ) @@ -63,14 +66,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 +82,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) + AgendaChoices(ctx context.Context, ticketHash *chainhash.Hash) (choices []wallet.AgendaChoice, voteBits uint16, err 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 + 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 + loadvsp.WalletFetcher // TODO: Rescan and DiscoverActiveAddresses can be used for a Rescanner. } @@ -823,6 +835,219 @@ func (w *spvWallet) AddressPrivKey(ctx context.Context, addr stdaddr.Address) (* return privKey, err } +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 (w *spvWallet) PurchaseTickets(ctx context.Context, n int, vspHost, vspPubKey string) error { + vspClient, err := loadvsp.NewVSPClient(w.dcrWallet, vspHost, vspPubKey, 0, 0, 0.2e8, new(net.Dialer).DialContext) + if err != nil { + return err + } + request := &wallet.PurchaseTicketsRequest{ + Count: n, + MinConf: 1, + VSPFeePaymentProcess: vspClient.Process, + VSPFeeProcess: vspClient.FeePercentage, + // TODO: CSPP/mixing + } + _, err = w.dcrWallet.PurchaseTickets(ctx, w.spv, request) + if err != nil { + return err + } + return nil + //ticketsTx := ticketsResponse.Tickets + ///splitTx := ticketsResponse.SplitTx + + // If dontSignTx is false, we return the TicketHashes of the published txs. + // if !dontSignTx { + // hashes := ticketsResponse.TicketHashes + // hashStrs := make([]string, len(hashes)) + // for i := range hashes { + // hashStrs[i] = hashes[i].String() + // } + // + // return hashStrs, err + // } +} + +func (w *spvWallet) Tickets(ctx context.Context) ([]string, error) { + var hashStrs []string + f := func(hash *chainhash.Hash) error { + hashStrs = append(hashStrs, hash.String()) + return nil + } + if err := w.dcrWallet.ForUnspentUnexpiredTickets(ctx, f); err != nil { + return nil, err + } + return hashStrs, nil +} + +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 +} + +func (w *spvWallet) SetVotingPreferences(ctx context.Context, choices, tspendPolicy, + treasuryPolicy map[string]string, vspInfo func(url string) (*vspdjson.VspInfoResponse, error)) error { + // Set the consensus vote choices for the wallet. + agendaChoices := make([]wallet.AgendaChoice, 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 + } + } + // 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 + } + 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 := loadvsp.NewVSPClient(w.dcrWallet, vspHost, vspPubKey, 0, 0, 0.2e8, new(net.Dialer).DialContext) + 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, agendaChoices, 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 1c8bd72a06..6d3f174066 100644 --- a/client/asset/dcr/spv_test.go +++ b/client/asset/dcr/spv_test.go @@ -192,6 +192,50 @@ 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) AgendaChoices(ctx context.Context, ticketHash *chainhash.Hash) (choices []wallet.AgendaChoice, 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 tNewSpvWallet() (*spvWallet, *tDcrWallet) { dcrw := &tDcrWallet{ blockHeader: make(map[chainhash.Hash]*wire.BlockHeader), diff --git a/client/asset/dcr/vsp/client.go b/client/asset/dcr/vsp/client.go new file mode 100644 index 0000000000..a31be61d80 --- /dev/null +++ b/client/asset/dcr/vsp/client.go @@ -0,0 +1,112 @@ +package vsp + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/decred/dcrd/txscript/v4/stdaddr" +) + +type client struct { + http.Client + url string + pub []byte + sign func(context.Context, string, stdaddr.Address) ([]byte, error) +} + +type signer interface { + SignMessage(ctx context.Context, message string, address stdaddr.Address) ([]byte, error) +} + +func newClient(url string, pub []byte, s signer) *client { + return &client{url: url, pub: pub, sign: s.SignMessage} +} + +type BadRequestError struct { + HTTPStatus int `json:"-"` + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *BadRequestError) Error() string { return e.Message } + +func (c *client) post(ctx context.Context, path string, addr stdaddr.Address, resp, req interface{}) error { + return c.do(ctx, "POST", path, addr, resp, req) +} + +func (c *client) get(ctx context.Context, path string, resp interface{}) error { + return c.do(ctx, "GET", path, nil, resp, nil) +} + +func (c *client) do(ctx context.Context, method, path string, addr stdaddr.Address, resp, req interface{}) error { + var reqBody io.Reader + var sig []byte + if method == "POST" { + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + sig, err = c.sign(ctx, string(body), addr) + if err != nil { + return fmt.Errorf("sign request: %w", err) + } + reqBody = bytes.NewReader(body) + } + httpReq, err := http.NewRequestWithContext(ctx, method, c.url+path, reqBody) + if err != nil { + return fmt.Errorf("new request: %w", err) + } + if sig != nil { + httpReq.Header.Set("VSP-Client-Signature", base64.StdEncoding.EncodeToString(sig)) + } + reply, err := c.Do(httpReq) + if err != nil { + return fmt.Errorf("%s %s: %w", method, httpReq.URL.String(), err) + } + defer reply.Body.Close() + + status := reply.StatusCode + is200 := status == 200 + is4xx := status >= 400 && status <= 499 + if !(is200 || is4xx) { + return fmt.Errorf("%s %s: http %v %s", method, httpReq.URL.String(), + status, http.StatusText(status)) + } + sigBase64 := reply.Header.Get("VSP-Server-Signature") + if sigBase64 == "" { + return fmt.Errorf("cannot authenticate server: no signature") + } + sig, err = base64.StdEncoding.DecodeString(sigBase64) + if err != nil { + return fmt.Errorf("cannot authenticate server: %w", err) + } + respBody, err := io.ReadAll(reply.Body) + if err != nil { + return fmt.Errorf("read response body: %w", err) + } + if !ed25519.Verify(c.pub, respBody, sig) { + return fmt.Errorf("cannot authenticate server: invalid signature") + } + var apiError *BadRequestError + if is4xx { + apiError = new(BadRequestError) + resp = apiError + } + if resp != nil { + err = json.Unmarshal(respBody, resp) + if err != nil { + return fmt.Errorf("unmarshal respose body: %w", err) + } + } + if apiError != nil { + apiError.HTTPStatus = status + return apiError + } + return nil +} diff --git a/client/asset/dcr/vsp/errors.go b/client/asset/dcr/vsp/errors.go new file mode 100644 index 0000000000..64c2e13b68 --- /dev/null +++ b/client/asset/dcr/vsp/errors.go @@ -0,0 +1,21 @@ +package vsp + +const ( + codeBadRequest = iota + codeInternalErr + codeVspClosed + codeFeeAlreadyReceived + codeInvalidFeeTx + codeFeeTooSmall + codeUnknownTicket + codeTicketCannotVote + codeFeeExpired + codeInvalidVoteChoices + codeBadSignature + codeInvalidPrivKey + codeFeeNotReceived + codeInvalidTicket + codeCannotBroadcastTicket + codeCannotBroadcastFee + codeCannotBroadcastFeeUnknownOutputs +) diff --git a/client/asset/dcr/vsp/feepayment.go b/client/asset/dcr/vsp/feepayment.go new file mode 100644 index 0000000000..a2b8b20e1c --- /dev/null +++ b/client/asset/dcr/vsp/feepayment.go @@ -0,0 +1,1037 @@ +package vsp + +import ( + "bytes" + "context" + cryptorand "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "sync" + "time" + + "decred.org/dcrwallet/v2/errors" + "decred.org/dcrwallet/v2/rpc/client/dcrd" + "decred.org/dcrwallet/v2/wallet" + "decred.org/dcrwallet/v2/wallet/txrules" + "decred.org/dcrwallet/v2/wallet/txsizes" + "github.com/decred/dcrd/blockchain/stake/v4" + "github.com/decred/dcrd/chaincfg/chainhash" + "github.com/decred/dcrd/chaincfg/v3" + "github.com/decred/dcrd/dcrutil/v4" + "github.com/decred/dcrd/txscript/v4" + "github.com/decred/dcrd/txscript/v4/stdaddr" + "github.com/decred/dcrd/txscript/v4/stdscript" + "github.com/decred/dcrd/wire" +) + +var prng lockedRand + +type lockedRand struct { + mu sync.Mutex + rand *Source +} + +func (r *lockedRand) int63n(n int64) int64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.rand.Int63n(n) +} + +// duration returns a random time.Duration in [0,d) with uniform distribution. +func (r *lockedRand) duration(d time.Duration) time.Duration { + return time.Duration(r.int63n(int64(d))) +} + +func (r *lockedRand) coinflip() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.rand.Uint32n(2) == 0 +} + +func init() { + source, err := RandSource(cryptorand.Reader) + if err != nil { + panic(err) + } + prng = lockedRand{ + rand: source, + } +} + +var ( + errStopped = errors.New("fee processing stopped") + errNotSolo = errors.New("not a solo ticket") +) + +// A random amount of delay (between zero and these jitter constants) is added +// before performing some background action with the VSP. The delay is reduced +// when a ticket is currently live, as it may be called to vote any time. +const ( + immatureJitter = time.Hour + liveJitter = 5 * time.Minute + unminedJitter = 2 * time.Minute +) + +type feePayment struct { + client *Client + ctx context.Context + + // Set at feepayment creation and never changes + ticketHash chainhash.Hash + commitmentAddr stdaddr.StakeAddress + votingAddr stdaddr.StakeAddress + policy Policy + + // Requires locking for all access outside of Client.feePayment + mu sync.Mutex + votingKey string + ticketLive int32 + ticketExpires int32 + fee dcrutil.Amount + feeAddr stdaddr.Address + feeHash chainhash.Hash + feeTx *wire.MsgTx + state state + err error + + timerMu sync.Mutex + timer *time.Timer +} + +type state uint32 + +const ( + _ state = iota + unprocessed + feePublished + _ // ... + ticketSpent +) + +func parseTicket(ticket *wire.MsgTx, params *chaincfg.Params) ( + votingAddr, commitmentAddr stdaddr.StakeAddress, err error) { + fail := func(err error) (_, _ stdaddr.StakeAddress, _ error) { + return nil, nil, err + } + if !stake.IsSStx(ticket) { + return fail(fmt.Errorf("%v is not a ticket", ticket)) + } + _, addrs := stdscript.ExtractAddrs(ticket.TxOut[0].Version, ticket.TxOut[0].PkScript, params) + if len(addrs) != 1 { + return fail(fmt.Errorf("cannot parse voting addr")) + } + switch addr := addrs[0].(type) { + case stdaddr.StakeAddress: + votingAddr = addr + default: + return fail(fmt.Errorf("address cannot be used for voting rights: %v", err)) + } + commitmentAddr, err = stake.AddrFromSStxPkScrCommitment(ticket.TxOut[1].PkScript, params) + if err != nil { + return fail(fmt.Errorf("cannot parse commitment address: %w", err)) + } + return +} + +func (fp *feePayment) ticketSpent() bool { + ctx := fp.ctx + ticketOut := wire.OutPoint{Hash: fp.ticketHash, Index: 0, Tree: 1} + _, _, err := fp.client.Wallet.Spender(ctx, &ticketOut) + return err == nil +} + +func (fp *feePayment) ticketExpired() bool { + ctx := fp.ctx + w := fp.client.Wallet + _, tipHeight := w.MainChainTip(ctx) + + fp.mu.Lock() + expires := fp.ticketExpires + fp.mu.Unlock() + + return expires > 0 && tipHeight >= expires +} + +func (fp *feePayment) removedExpiredOrSpent() bool { + var reason string + switch { + case fp.ticketExpired(): + reason = "expired" + case fp.ticketSpent(): + reason = "spent" + } + if reason != "" { + fp.remove(reason) + // nothing scheduled + return true + } + return false +} + +func (fp *feePayment) remove(reason string) { + fp.stop() + log.Infof("ticket %v is %s; removing from VSP client", &fp.ticketHash, reason) + fp.client.mu.Lock() + delete(fp.client.jobs, fp.ticketHash) + fp.client.mu.Unlock() +} + +// feePayment returns an existing managed fee payment, or creates and begins +// processing a fee payment for a ticket. +func (c *Client) feePayment(ticketHash *chainhash.Hash, policy Policy, paidConfirmed bool) (fp *feePayment) { + c.mu.Lock() + fp = c.jobs[*ticketHash] + c.mu.Unlock() + if fp != nil { + return fp + } + + defer func() { + if fp == nil { + return + } + var schedule bool + c.mu.Lock() + fp2 := c.jobs[*ticketHash] + if fp2 != nil { + fp.stop() + fp = fp2 + } else { + c.jobs[*ticketHash] = fp + schedule = true + } + c.mu.Unlock() + if schedule { + fp.schedule("reconcile payment", fp.reconcilePayment) + } + }() + + ctx := context.Background() + w := c.Wallet + params := w.ChainParams() + + fp = &feePayment{ + client: c, + ctx: ctx, + ticketHash: *ticketHash, + policy: policy, + } + + // No VSP interaction is required for spent tickets. + if fp.ticketSpent() { + fp.state = ticketSpent + return fp + } + + ticket, err := c.tx(ctx, ticketHash) + if err != nil { + log.Warnf("no ticket found for %v", ticketHash) + return nil + } + + _, ticketHeight, err := w.TxBlock(ctx, ticketHash) + if err != nil { + // This is not expected to ever error, as the ticket was fetched + // from the wallet in the above call. + log.Errorf("failed to query block which mines ticket: %v", err) + return nil + } + if ticketHeight >= 2 { + // Note the off-by-one; this is correct. Tickets become live + // one block after the params would indicate. + fp.ticketLive = ticketHeight + int32(params.TicketMaturity) + 1 + fp.ticketExpires = fp.ticketLive + int32(params.TicketExpiry) + } + + fp.votingAddr, fp.commitmentAddr, err = parseTicket(ticket, params) + if err != nil { + log.Errorf("%v is not a ticket: %v", ticketHash, err) + return nil + } + // Try to access the voting key, ignore error unless the wallet is + // locked. + fp.votingKey, err = w.DumpWIFPrivateKey(ctx, fp.votingAddr) + if err != nil && !errors.Is(err, errors.Locked) { + log.Errorf("no voting key for ticket %v: %v", ticketHash, err) + return nil + } + feeHash, err := w.VSPFeeHashForTicket(ctx, ticketHash) + if err != nil { + // caller must schedule next method, as paying the fee may + // require using provided transaction inputs. + return fp + } + + fee, err := c.tx(ctx, &feeHash) + if err != nil { + // A fee hash is recorded for this ticket, but was not found in + // the wallet. This should not happen and may require manual + // intervention. + // + // XXX should check ticketinfo and see if fee is not paid. if + // possible, update it with a new fee. + fp.err = fmt.Errorf("fee transaction not found in wallet: %w", err) + return fp + } + + fp.feeTx = fee + fp.feeHash = feeHash + + // If database has been updated to paid or confirmed status, we can forgo + // this step. + if !paidConfirmed { + err = w.UpdateVspTicketFeeToStarted(ctx, ticketHash, &feeHash, c.client.url, c.client.pub) + if err != nil { + return fp + } + + fp.state = unprocessed // XXX fee created, but perhaps not submitted with vsp. + fp.fee = -1 // XXX fee amount (not needed anymore?) + } + return fp +} + +func (c *Client) tx(ctx context.Context, hash *chainhash.Hash) (*wire.MsgTx, error) { + txs, _, err := c.Wallet.GetTransactionsByHashes(ctx, []*chainhash.Hash{hash}) + if err != nil { + return nil, err + } + return txs[0], nil +} + +// Schedule a method to be executed. +// Any currently-scheduled method is replaced. +func (fp *feePayment) schedule(name string, method func() error) { + var delay time.Duration + if method != nil { + delay = fp.next() + } + + fp.timerMu.Lock() + defer fp.timerMu.Unlock() + if fp.timer != nil { + fp.timer.Stop() + fp.timer = nil + } + if method != nil { + log.Debugf("scheduling %q for ticket %s in %v", name, &fp.ticketHash, delay) + fp.timer = time.AfterFunc(delay, fp.task(name, method)) + } +} + +func (fp *feePayment) next() time.Duration { + w := fp.client.Wallet + params := w.ChainParams() + _, tipHeight := w.MainChainTip(fp.ctx) + + fp.mu.Lock() + ticketLive := fp.ticketLive + ticketExpires := fp.ticketExpires + fp.mu.Unlock() + + var jitter time.Duration + switch { + case tipHeight < ticketLive: // immature, mined ticket + blocksUntilLive := ticketExpires - tipHeight + jitter = params.TargetTimePerBlock * time.Duration(blocksUntilLive) + if jitter > immatureJitter { + jitter = immatureJitter + } + case tipHeight < ticketExpires: // live ticket + jitter = liveJitter + default: // unmined ticket + jitter = unminedJitter + } + + return prng.duration(jitter) +} + +// task returns a function running a feePayment method. +// If the method errors, the error is logged, and the payment is put +// in an errored state and may require manual processing. +func (fp *feePayment) task(name string, method func() error) func() { + return func() { + err := method() + fp.mu.Lock() + fp.err = err + fp.mu.Unlock() + if err != nil { + log.Errorf("ticket %v: %v: %v", &fp.ticketHash, name, err) + } + } +} + +func (fp *feePayment) stop() { + fp.schedule("", nil) +} + +func (fp *feePayment) receiveFeeAddress() error { + ctx := fp.ctx + w := fp.client.Wallet + params := w.ChainParams() + + // stop processing if ticket is expired or spent + if fp.removedExpiredOrSpent() { + // nothing scheduled + return errStopped + } + + // Fetch ticket and its parent transaction (typically, a split + // transaction). + ticket, err := fp.client.tx(ctx, &fp.ticketHash) + if err != nil { + return fmt.Errorf("failed to retrieve ticket: %w", err) + } + parentHash := &ticket.TxIn[0].PreviousOutPoint.Hash + parent, err := fp.client.tx(ctx, parentHash) + if err != nil { + return fmt.Errorf("failed to retrieve parent %v of ticket: %w", + parentHash, err) + } + + var response struct { + Timestamp int64 `json:"timestamp"` + FeeAddress string `json:"feeaddress"` + FeeAmount int64 `json:"feeamount"` + Request []byte `json:"request"` + } + requestBody, err := json.Marshal(&struct { + Timestamp int64 `json:"timestamp"` + TicketHash string `json:"tickethash"` + TicketHex json.Marshaler `json:"tickethex"` + ParentHex json.Marshaler `json:"parenthex"` + }{ + Timestamp: time.Now().Unix(), + TicketHash: fp.ticketHash.String(), + TicketHex: txMarshaler(ticket), + ParentHex: txMarshaler(parent), + }) + if err != nil { + return err + } + err = fp.client.post(ctx, "/api/v3/feeaddress", fp.commitmentAddr, &response, + json.RawMessage(requestBody)) + if err != nil { + return err + } + + // verify initial request matches server + if !bytes.Equal(requestBody, response.Request) { + return fmt.Errorf("server response has differing request: %#v != %#v", + requestBody, response.Request) + } + + feeAmount := dcrutil.Amount(response.FeeAmount) + feeAddr, err := stdaddr.DecodeAddress(response.FeeAddress, params) + if err != nil { + return fmt.Errorf("server fee address invalid: %w", err) + } + + log.Infof("VSP requires fee %v", feeAmount) + if feeAmount > fp.policy.MaxFee { + return fmt.Errorf("server fee amount too high: %v > %v", + feeAmount, fp.policy.MaxFee) + } + + // XXX validate server timestamp? + + fp.mu.Lock() + fp.fee = feeAmount + fp.feeAddr = feeAddr + fp.mu.Unlock() + + return nil +} + +// makeFeeTx adds outputs to tx to pay a VSP fee, optionally adding inputs as +// well to fund the transaction if no input value is already provided in the +// transaction. +// +// If tx is nil, fp.feeTx may be assigned or modified, but the pointer will not +// be dereferenced. +func (fp *feePayment) makeFeeTx(tx *wire.MsgTx) error { + ctx := fp.ctx + w := fp.client.Wallet + + fp.mu.Lock() + fee := fp.fee + fpFeeTx := fp.feeTx + feeAddr := fp.feeAddr + fp.mu.Unlock() + + // The rest of this function will operate on the tx pointer, with fp.feeTx + // assigned to the result on success. + // Update tx to use the partially created fpFeeTx if any has been started. + // The transaction pointed to by the caller will be dereferenced and modified + // when non-nil. + if fpFeeTx != nil { + if tx != nil { + *tx = *fpFeeTx + } else { + tx = fpFeeTx + } + } + // Fee transaction with outputs is already finished. + if fpFeeTx != nil && len(fpFeeTx.TxOut) != 0 { + return nil + } + // When both transactions are nil, create a new empty transaction. + if tx == nil { + tx = wire.NewMsgTx() + } + + // XXX fp.fee == -1? + if fee == 0 { + err := fp.receiveFeeAddress() + if err != nil { + return err + } + fp.mu.Lock() + fee = fp.fee + feeAddr = fp.feeAddr + fp.mu.Unlock() + } + + // Reserve new outputs to pay the fee if outputs have not already been + // reserved. This will the the case for fee payments that were begun on + // already purchased tickets, where the caller did not ensure that fee + // outputs would already be reserved. + if len(tx.TxIn) == 0 { + const minconf = 1 + inputs, err := w.ReserveOutputsForAmount(ctx, fp.policy.FeeAcct, fee, minconf) + if err != nil { + return fmt.Errorf("unable to reserve enough output value to "+ + "pay VSP fee for ticket %v: %w", fp.ticketHash, err) + } + for _, in := range inputs { + tx.AddTxIn(wire.NewTxIn(&in.OutPoint, in.PrevOut.Value, nil)) + } + // The transaction will be added to the wallet in an unpublished + // state, so there is no need to leave the outputs locked. + defer func() { + for _, in := range inputs { + w.UnlockOutpoint(&in.OutPoint.Hash, in.OutPoint.Index) + } + }() + } + + var input int64 + for _, in := range tx.TxIn { + input += in.ValueIn + } + if input < int64(fee) { + err := fmt.Errorf("not enough input value to pay fee: %v < %v", + dcrutil.Amount(input), fee) + return err + } + + vers, feeScript := feeAddr.PaymentScript() + + addr, err := w.NewChangeAddress(ctx, fp.policy.ChangeAcct) + if err != nil { + log.Warnf("failed to get new change address: %v", err) + return err + } + var changeOut *wire.TxOut + switch addr := addr.(type) { + case wallet.Address: + vers, script := addr.PaymentScript() + changeOut = &wire.TxOut{PkScript: script, Version: vers} + default: + return fmt.Errorf("failed to convert '%T' to wallet.Address", addr) + } + + tx.TxOut = append(tx.TxOut[:0], &wire.TxOut{ + Value: int64(fee), + Version: vers, + PkScript: feeScript, + }) + feeRate := w.RelayFee() + scriptSizes := make([]int, len(tx.TxIn)) + for i := range scriptSizes { + scriptSizes[i] = txsizes.RedeemP2PKHSigScriptSize + } + est := txsizes.EstimateSerializeSize(scriptSizes, tx.TxOut, txsizes.P2PKHPkScriptSize) + change := input + change -= tx.TxOut[0].Value + change -= int64(txrules.FeeForSerializeSize(feeRate, est)) + if !txrules.IsDustAmount(dcrutil.Amount(change), txsizes.P2PKHPkScriptSize, feeRate) { + changeOut.Value = change + tx.TxOut = append(tx.TxOut, changeOut) + // randomize position + if prng.coinflip() { + tx.TxOut[0], tx.TxOut[1] = tx.TxOut[1], tx.TxOut[0] + } + } + + feeHash := tx.TxHash() + + // sign + sigErrs, err := w.SignTransaction(ctx, tx, txscript.SigHashAll, nil, nil, nil) + if err != nil || len(sigErrs) > 0 { + log.Errorf("failed to sign transaction: %v", err) + sigErrStr := "" + for _, sigErr := range sigErrs { + log.Errorf("\t%v", sigErr) + sigErrStr = fmt.Sprintf("\t%v", sigErr) + " " + } + if err != nil { + return err + } + return fmt.Errorf(sigErrStr) + } + + err = w.SetPublished(ctx, &feeHash, false) + if err != nil { + return err + } + err = w.AddTransaction(ctx, tx, nil) + if err != nil { + return err + } + err = w.UpdateVspTicketFeeToPaid(ctx, &fp.ticketHash, &feeHash, fp.client.url, fp.client.pub) + if err != nil { + return err + } + + fp.mu.Lock() + fp.feeTx = tx + fp.feeHash = feeHash + fp.mu.Unlock() + + // nothing scheduled + return nil +} + +type ticketStatus struct { + Timestamp int64 `json:"timestamp"` + TicketConfirmed bool `json:"ticketconfirmed"` + FeeTxStatus string `json:"feetxstatus"` + FeeTxHash string `json:"feetxhash"` + VoteChoices map[string]string `json:"votechoices"` + TSpendPolicy map[string]string `json:"tspendpolicy"` + TreasuryPolicy map[string]string `json:"treasurypolicy"` + Request []byte `json:"request"` +} + +func (c *Client) status(ctx context.Context, ticketHash *chainhash.Hash) (*ticketStatus, error) { + w := c.Wallet + params := w.ChainParams() + + ticketTx, err := c.tx(ctx, ticketHash) + if err != nil { + return nil, fmt.Errorf("failed to retrieve ticket %v: %w", ticketHash, err) + } + if len(ticketTx.TxOut) != 3 { + return nil, fmt.Errorf("ticket %v has multiple commitments: %w", ticketHash, errNotSolo) + } + + if !stake.IsSStx(ticketTx) { + return nil, fmt.Errorf("%v is not a ticket", ticketHash) + } + commitmentAddr, err := stake.AddrFromSStxPkScrCommitment(ticketTx.TxOut[1].PkScript, params) + if err != nil { + return nil, fmt.Errorf("failed to extract commitment address from %v: %w", + ticketHash, err) + } + + var resp ticketStatus + requestBody, err := json.Marshal(&struct { + TicketHash string `json:"tickethash"` + }{ + TicketHash: ticketHash.String(), + }) + if err != nil { + return nil, err + } + err = c.post(ctx, "/api/v3/ticketstatus", commitmentAddr, &resp, + json.RawMessage(requestBody)) + if err != nil { + return nil, err + } + + // verify initial request matches server + if !bytes.Equal(requestBody, resp.Request) { + log.Warnf("server response has differing request: %#v != %#v", + requestBody, resp.Request) + return nil, fmt.Errorf("server response contains differing request") + } + + // XXX validate server timestamp? + + return &resp, nil +} + +func (c *Client) setVoteChoices(ctx context.Context, ticketHash *chainhash.Hash, + choices []wallet.AgendaChoice, tspendPolicy map[string]string, treasuryPolicy map[string]string) error { + w := c.Wallet + params := w.ChainParams() + + ticketTx, err := c.tx(ctx, ticketHash) + if err != nil { + return fmt.Errorf("failed to retrieve ticket %v: %w", ticketHash, err) + } + + if !stake.IsSStx(ticketTx) { + return fmt.Errorf("%v is not a ticket", ticketHash) + } + if len(ticketTx.TxOut) != 3 { + return fmt.Errorf("ticket %v has multiple commitments: %w", ticketHash, errNotSolo) + } + + commitmentAddr, err := stake.AddrFromSStxPkScrCommitment(ticketTx.TxOut[1].PkScript, params) + if err != nil { + return fmt.Errorf("failed to extract commitment address from %v: %w", + ticketHash, err) + } + + agendaChoices := make(map[string]string, len(choices)) + + // Prepare agenda choice + for _, c := range choices { + agendaChoices[c.AgendaID] = c.ChoiceID + } + + var resp ticketStatus + requestBody, err := json.Marshal(&struct { + Timestamp int64 `json:"timestamp"` + TicketHash string `json:"tickethash"` + VoteChoices map[string]string `json:"votechoices"` + TSpendPolicy map[string]string `json:"tspendpolicy"` + TreasuryPolicy map[string]string `json:"treasurypolicy"` + }{ + Timestamp: time.Now().Unix(), + TicketHash: ticketHash.String(), + VoteChoices: agendaChoices, + TSpendPolicy: tspendPolicy, + TreasuryPolicy: treasuryPolicy, + }) + if err != nil { + return err + } + + err = c.post(ctx, "/api/v3/setvotechoices", commitmentAddr, &resp, + json.RawMessage(requestBody)) + if err != nil { + return err + } + + // verify initial request matches server + if !bytes.Equal(requestBody, resp.Request) { + log.Warnf("server response has differing request: %#v != %#v", + requestBody, resp.Request) + return fmt.Errorf("server response contains differing request") + } + + // XXX validate server timestamp? + + return nil +} + +func (fp *feePayment) reconcilePayment() error { + ctx := fp.ctx + w := fp.client.Wallet + + // stop processing if ticket is expired or spent + // XXX if ticket is no longer saved by wallet (because the tx expired, + // or was double spent, etc) remove the fee payment. + if fp.removedExpiredOrSpent() { + // nothing scheduled + return errStopped + } + + // A fee amount and address must have been created by this point. + // Ensure that the fee transaction can be created, otherwise reschedule + // this method until it is. There is no need to check the wallet for a + // fee transaction matching a known hash; this is performed when + // creating the feePayment. + fp.mu.Lock() + feeTx := fp.feeTx + fp.mu.Unlock() + if feeTx == nil || len(feeTx.TxOut) == 0 { + err := fp.makeFeeTx(nil) + if err != nil { + var apiErr *BadRequestError + if errors.As(err, &apiErr) && apiErr.Code == codeTicketCannotVote { + fp.remove("ticket cannot vote") + // Attempt to Revoke Tickets, we're not returning any errors here + // and just logging. + n, err := w.NetworkBackend() + if err != nil { + log.Errorf("unable to get network backend for revoking tickets %v", err) + } else { + if rpc, ok := n.(*dcrd.RPC); ok { + err := w.RevokeTickets(ctx, rpc) + if err != nil { + log.Errorf("cannot revoke vsp tickets %v", err) + } + } + } + } + return err + } + } + + // A fee address has been obtained, and the fee transaction has been + // created, but it is unknown if the VSP has received the fee and will + // vote using the ticket. + // + // If the fee is mined, then check the status of the ticket and payment + // with the VSP, to ensure that it has marked the fee payment as paid. + // + // If the fee is not mined, an API call with the VSP is used so it may + // receive and publish the transaction. A follow up on the ticket + // status is scheduled for some time in the future. + + err := fp.submitPayment() + fp.mu.Lock() + feeHash := fp.feeHash + fp.mu.Unlock() + var apiErr *BadRequestError + if errors.As(err, &apiErr) { + switch apiErr.Code { + case codeFeeAlreadyReceived: + err = w.SetPublished(ctx, &feeHash, true) + if err != nil { + return err + } + err = w.UpdateVspTicketFeeToPaid(ctx, &fp.ticketHash, &feeHash, fp.client.url, fp.client.pub) + if err != nil { + return err + } + err = nil + case codeInvalidFeeTx, codeCannotBroadcastFee: + err := w.UpdateVspTicketFeeToErrored(ctx, &fp.ticketHash, fp.client.url, fp.client.pub) + if err != nil { + return err + } + // Attempt to create a new fee transaction + fp.mu.Lock() + fp.feeHash = chainhash.Hash{} + fp.feeTx = nil + fp.mu.Unlock() + // err not nilled, so reconcile payment is rescheduled. + } + } + if err != nil { + // Nothing left to try except trying again. + fp.schedule("reconcile payment", fp.reconcilePayment) + return err + } + + err = w.UpdateVspTicketFeeToPaid(ctx, &fp.ticketHash, &feeHash, fp.client.url, fp.client.pub) + if err != nil { + return err + } + + // confirmPayment will remove the fee payment processing when the fee + // has reached sufficient confirmations, and reschedule itself if the + // fee is not confirmed yet. If the fee tx is ever removed from the + // wallet, this will schedule another reconcile. + return fp.confirmPayment() + + /* + // XXX? for each input, c.Wallet.UnlockOutpoint(&outpoint.Hash, outpoint.Index) + // xxx, or let the published tx replace the unpublished one, and unlock + // outpoints as it is processed. + + */ +} + +func (fp *feePayment) submitPayment() (err error) { + ctx := fp.ctx + w := fp.client.Wallet + + // stop processing if ticket is expired or spent + if fp.removedExpiredOrSpent() { + // nothing scheduled + return errStopped + } + + // submitting a payment requires the fee tx to already be created. + fp.mu.Lock() + feeTx := fp.feeTx + votingKey := fp.votingKey + fp.mu.Unlock() + if feeTx == nil { + feeTx = new(wire.MsgTx) + } + if len(feeTx.TxOut) == 0 { + err := fp.makeFeeTx(feeTx) + if err != nil { + return err + } + } + if votingKey == "" { + votingKey, err = w.DumpWIFPrivateKey(ctx, fp.votingAddr) + if err != nil { + return err + } + fp.mu.Lock() + fp.votingKey = votingKey + fp.mu.Unlock() + } + + // Retrieve voting preferences + voteChoices := make(map[string]string) + agendaChoices, _, err := w.AgendaChoices(ctx, &fp.ticketHash) + if err != nil { + return err + } + for _, agendaChoice := range agendaChoices { + voteChoices[agendaChoice.AgendaID] = agendaChoice.ChoiceID + } + + var payfeeResponse struct { + Timestamp int64 `json:"timestamp"` + Request []byte `json:"request"` + } + requestBody, err := json.Marshal(&struct { + Timestamp int64 `json:"timestamp"` + TicketHash string `json:"tickethash"` + FeeTx json.Marshaler `json:"feetx"` + VotingKey string `json:"votingkey"` + VoteChoices map[string]string `json:"votechoices"` + TSpendPolicy map[string]string `json:"tspendpolicy"` + TreasuryPolicy map[string]string `json:"treasurypolicy"` + }{ + Timestamp: time.Now().Unix(), + TicketHash: fp.ticketHash.String(), + FeeTx: txMarshaler(feeTx), + VotingKey: votingKey, + VoteChoices: voteChoices, + TSpendPolicy: w.TSpendPolicyForTicket(&fp.ticketHash), + TreasuryPolicy: w.TreasuryKeyPolicyForTicket(&fp.ticketHash), + }) + if err != nil { + return err + } + err = fp.client.post(ctx, "/api/v3/payfee", fp.commitmentAddr, + &payfeeResponse, json.RawMessage(requestBody)) + if err != nil { + var apiErr *BadRequestError + if errors.As(err, &apiErr) && apiErr.Code == codeFeeExpired { + // Fee has been expired, so abandon current feetx, set fp.feeTx + // to nil and retry submit payment to make a new fee tx. + feeHash := feeTx.TxHash() + err := w.AbandonTransaction(ctx, &feeHash) + if err != nil { + log.Errorf("error abandoning expired fee tx %v", err) + } + fp.feeTx = nil + } + return fmt.Errorf("payfee: %w", err) + } + + // Check for matching original request. + // This is signed by the VSP, and the signature + // has already been checked above. + if !bytes.Equal(requestBody, payfeeResponse.Request) { + return fmt.Errorf("server response has differing request: %#v != %#v", + requestBody, payfeeResponse.Request) + } + // TODO - validate server timestamp? + + log.Infof("successfully processed %v", fp.ticketHash) + return nil +} + +func (fp *feePayment) confirmPayment() (err error) { + ctx := fp.ctx + w := fp.client.Wallet + + // stop processing if ticket is expired or spent + if fp.removedExpiredOrSpent() { + // nothing scheduled + return errStopped + } + + defer func() { + if err != nil && !errors.Is(err, errStopped) { + fp.schedule("reconcile payment", fp.reconcilePayment) + } + }() + + status, err := fp.client.status(ctx, &fp.ticketHash) + // Suppress log if the wallet is currently locked. + if err != nil && !errors.Is(err, errors.Locked) { + log.Warnf("Rescheduling status check for %v: %v", &fp.ticketHash, err) + } + if err != nil { + // Stop processing if the status check cannot be performed, but + // a significant amount of confirmations are observed on the fee + // transaction. + // + // Otherwise, chedule another confirmation check, in case the + // status API can be performed at a later time or more + // confirmations are observed. + fp.mu.Lock() + feeHash := fp.feeHash + fp.mu.Unlock() + confs, err := w.TxConfirms(ctx, &feeHash) + if err != nil { + return err + } + if confs >= 6 { + fp.remove("confirmed") + err = w.UpdateVspTicketFeeToConfirmed(ctx, &fp.ticketHash, &feeHash, fp.client.url, fp.client.pub) + if err != nil { + return err + } + return nil + } + fp.schedule("confirm payment", fp.confirmPayment) + return nil + } + + switch status.FeeTxStatus { + case "received": + // VSP has received the fee tx but has not yet broadcast it. + // VSP will only broadcast the tx when ticket has 6+ confirmations. + fp.schedule("confirm payment", fp.confirmPayment) + return nil + case "broadcast": + log.Infof("VSP has successfully sent the fee tx for %v", &fp.ticketHash) + // Broadcasted, but not confirmed. + fp.schedule("confirm payment", fp.confirmPayment) + return nil + case "confirmed": + fp.remove("confirmed by VSP") + // nothing scheduled + err = w.UpdateVspTicketFeeToConfirmed(ctx, &fp.ticketHash, &fp.feeHash, fp.client.url, fp.client.pub) + if err != nil { + return err + } + return nil + case "error": + log.Warnf("VSP failed to broadcast feetx for %v -- restarting payment", + &fp.ticketHash) + fp.schedule("reconcile payment", fp.reconcilePayment) + return nil + default: + // XXX put in unknown state + log.Warnf("VSP responded with %v for %v", status.FeeTxStatus, + &fp.ticketHash) + } + + return nil +} + +type marshaler struct { + marshaled []byte + err error +} + +func (m *marshaler) MarshalJSON() ([]byte, error) { + return m.marshaled, m.err +} + +func txMarshaler(tx *wire.MsgTx) json.Marshaler { + var buf bytes.Buffer + buf.Grow(2 + tx.SerializeSize()*2) + buf.WriteByte('"') + err := tx.Serialize(hex.NewEncoder(&buf)) + buf.WriteByte('"') + return &marshaler{buf.Bytes(), err} +} diff --git a/client/asset/dcr/vsp/loader.go b/client/asset/dcr/vsp/loader.go new file mode 100644 index 0000000000..ef984d2bde --- /dev/null +++ b/client/asset/dcr/vsp/loader.go @@ -0,0 +1,24 @@ +package vsp + +import ( + "context" + "net" + + "github.com/decred/dcrd/dcrutil/v4" +) + +func NewVSPClient(wf WalletFetcher, vspHost, vspPubKey string, feeAcct, changeAcct uint32, + maxFee dcrutil.Amount, dialer func(ctx context.Context, network, addr string) (net.Conn, error)) (*Client, error) { + + return New(Config{ + URL: vspHost, + PubKey: vspPubKey, + Dialer: dialer, + Wallet: wf, + Policy: Policy{ + MaxFee: maxFee, + FeeAcct: feeAcct, + ChangeAcct: changeAcct, + }, + }) +} diff --git a/client/asset/dcr/vsp/log.go b/client/asset/dcr/vsp/log.go new file mode 100644 index 0000000000..25eb8f270e --- /dev/null +++ b/client/asset/dcr/vsp/log.go @@ -0,0 +1,19 @@ +// Copyright (c) 2016-2018 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package vsp + +import "github.com/decred/slog" + +// log is a logger that is initialized with no output filters. This +// means the package will not perform any logging by default until the caller +// requests it. +var log = slog.Disabled + +// UseLogger uses a specified Logger to output package logging info. +// This should be used in preference to SetLogWriter if the caller is also +// using slog. +func UseLogger(logger slog.Logger) { + log = logger +} diff --git a/client/asset/dcr/vsp/prng.go b/client/asset/dcr/vsp/prng.go new file mode 100644 index 0000000000..aa93abfe65 --- /dev/null +++ b/client/asset/dcr/vsp/prng.go @@ -0,0 +1,89 @@ +// Package uniformprng implements a uniform, cryptographically secure +// pseudo-random number generator. +package vsp + +import ( + "encoding/binary" + "io" + "math/bits" + + "golang.org/x/crypto/chacha20" +) + +// Source returns cryptographically-secure pseudorandom numbers with uniform +// distribution. +type Source struct { + buf [8]byte + cipher *chacha20.Cipher +} + +var nonce = make([]byte, chacha20.NonceSize) + +// NewSource seeds a Source from a 32-byte key. +func NewSource(seed *[32]byte) *Source { + cipher, _ := chacha20.NewUnauthenticatedCipher(seed[:], nonce) + return &Source{cipher: cipher} +} + +// RandSource creates a Source with seed randomness read from rand. +func RandSource(rand io.Reader) (*Source, error) { + seed := new([32]byte) + _, err := io.ReadFull(rand, seed[:]) + if err != nil { + return nil, err + } + return NewSource(seed), nil +} + +// Uint32 returns a pseudo-random uint32. +func (s *Source) Uint32() uint32 { + b := s.buf[:4] + for i := range b { + b[i] = 0 + } + s.cipher.XORKeyStream(b, b) + return binary.LittleEndian.Uint32(b) +} + +// Uint32n returns a pseudo-random uint32 in range [0,n) without modulo bias. +func (s *Source) Uint32n(n uint32) uint32 { + if n < 2 { + return 0 + } + n-- + mask := ^uint32(0) >> bits.LeadingZeros32(n) + for { + u := s.Uint32() & mask + if u <= n { + return u + } + } +} + +// Int63 returns a pseudo-random 63-bit positive integer as an int64 without +// modulo bias. +func (s *Source) Int63() int64 { + b := s.buf[:] + for i := range b { + b[i] = 0 + } + s.cipher.XORKeyStream(b, b) + return int64(binary.LittleEndian.Uint64(b) &^ (1 << 63)) +} + +// Int63n returns, as an int64, a pseudo-random 63-bit positive integer in [0,n) +// without modulo bias. +// It panics if n <= 0. +func (s *Source) Int63n(n int64) int64 { + if n <= 0 { + panic("invalid argument to Int63n") + } + n-- + mask := int64(^uint64(0) >> bits.LeadingZeros64(uint64(n))) + for { + i := s.Int63() & mask + if i <= n { + return i + } + } +} diff --git a/client/asset/dcr/vsp/vsp.go b/client/asset/dcr/vsp/vsp.go new file mode 100644 index 0000000000..9417e9895f --- /dev/null +++ b/client/asset/dcr/vsp/vsp.go @@ -0,0 +1,448 @@ +package vsp + +import ( + "context" + "encoding/base64" + "fmt" + "net" + "net/http" + "net/url" + "sync" + + "decred.org/dcrwallet/v2/errors" + "decred.org/dcrwallet/v2/wallet" + "decred.org/dcrwallet/v2/wallet/udb" + "github.com/decred/dcrd/chaincfg/chainhash" + "github.com/decred/dcrd/chaincfg/v3" + "github.com/decred/dcrd/dcrutil/v4" + "github.com/decred/dcrd/txscript/v4" + "github.com/decred/dcrd/txscript/v4/stdaddr" + "github.com/decred/dcrd/wire" +) + +type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error) + +type Policy struct { + MaxFee dcrutil.Amount + ChangeAcct uint32 // to derive fee addresses + FeeAcct uint32 // to pay fees from, if inputs are not provided to Process +} + +var _ WalletFetcher = (*wallet.Wallet)(nil) + +type WalletFetcher interface { + Spender(ctx context.Context, out *wire.OutPoint) (*wire.MsgTx, uint32, error) + MainChainTip(ctx context.Context) (hash chainhash.Hash, height int32) + ChainParams() *chaincfg.Params + TxBlock(ctx context.Context, hash *chainhash.Hash) (chainhash.Hash, int32, error) + DumpWIFPrivateKey(ctx context.Context, addr stdaddr.Address) (string, error) + VSPFeeHashForTicket(ctx context.Context, ticketHash *chainhash.Hash) (chainhash.Hash, error) + UpdateVspTicketFeeToStarted(ctx context.Context, ticketHash, feeHash *chainhash.Hash, host string, pubkey []byte) error + GetTransactionsByHashes(ctx context.Context, txHashes []*chainhash.Hash) (txs []*wire.MsgTx, notFound []*wire.InvVect, err error) + ReserveOutputsForAmount(ctx context.Context, account uint32, amount dcrutil.Amount, minconf int32) ([]wallet.Input, error) + UnlockOutpoint(txHash *chainhash.Hash, index uint32) + NewChangeAddress(ctx context.Context, account uint32) (stdaddr.Address, error) + RelayFee() dcrutil.Amount + 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) + SetPublished(ctx context.Context, hash *chainhash.Hash, published bool) error + AddTransaction(ctx context.Context, tx *wire.MsgTx, blockHash *chainhash.Hash) error + UpdateVspTicketFeeToPaid(ctx context.Context, ticketHash, feeHash *chainhash.Hash, host string, pubkey []byte) error + NetworkBackend() (wallet.NetworkBackend, error) + RevokeTickets(ctx context.Context, rpcCaller wallet.Caller) error + UpdateVspTicketFeeToErrored(ctx context.Context, ticketHash *chainhash.Hash, host string, pubkey []byte) error + AgendaChoices(ctx context.Context, ticketHash *chainhash.Hash) (choices []wallet.AgendaChoice, voteBits uint16, err error) + TSpendPolicyForTicket(ticketHash *chainhash.Hash) map[string]string + TreasuryKeyPolicyForTicket(ticketHash *chainhash.Hash) map[string]string + AbandonTransaction(ctx context.Context, hash *chainhash.Hash) error + TxConfirms(ctx context.Context, hash *chainhash.Hash) (int32, error) + ForUnspentUnexpiredTickets(ctx context.Context, f func(hash *chainhash.Hash) error) error + IsVSPTicketConfirmed(ctx context.Context, ticketHash *chainhash.Hash) (bool, error) + UpdateVspTicketFeeToConfirmed(ctx context.Context, ticketHash, feeHash *chainhash.Hash, host string, pubkey []byte) error + VSPTicketInfo(ctx context.Context, ticketHash *chainhash.Hash) (*wallet.VSPTicket, error) + SignMessage(ctx context.Context, msg string, addr stdaddr.Address) (sig []byte, err error) +} + +type Client struct { + Wallet WalletFetcher + Policy Policy + *client + + mu sync.Mutex + jobs map[chainhash.Hash]*feePayment +} + +type Config struct { + // URL specifies the base URL of the VSP + URL string + + // PubKey specifies the VSP's base64 encoded public key + PubKey string + + // Dialer specifies an optional dialer when connecting to the VSP. + Dialer DialFunc + + // Wallet specifies a loaded wallet. + Wallet WalletFetcher + + // Default policy for fee payments unless another is provided by the + // caller. + Policy Policy +} + +func New(cfg Config) (*Client, error) { + u, err := url.Parse(cfg.URL) + if err != nil { + return nil, err + } + pubKey, err := base64.StdEncoding.DecodeString(cfg.PubKey) + if err != nil { + return nil, err + } + if cfg.Wallet == nil { + return nil, fmt.Errorf("wallet option not set") + } + + client := newClient(u.String(), pubKey, cfg.Wallet) + client.Transport = &http.Transport{ + DialContext: cfg.Dialer, + } + + v := &Client{ + Wallet: cfg.Wallet, + Policy: cfg.Policy, + client: client, + jobs: make(map[chainhash.Hash]*feePayment), + } + return v, nil +} + +func (c *Client) FeePercentage(ctx context.Context) (float64, error) { + var resp struct { + FeePercentage float64 `json:"feepercentage"` + } + err := c.get(ctx, "/api/v3/vspinfo", &resp) + if err != nil { + return -1, err + } + return resp.FeePercentage, nil +} + +// ProcessUnprocessedTickets processes all tickets that don't currently have +// any association with a VSP. +func (c *Client) ProcessUnprocessedTickets(ctx context.Context, policy Policy) { + var wg sync.WaitGroup + c.Wallet.ForUnspentUnexpiredTickets(ctx, func(hash *chainhash.Hash) error { + // Skip tickets which have a fee tx already associated with + // them; they are already processed by some vsp. + _, err := c.Wallet.VSPFeeHashForTicket(ctx, hash) + if err == nil { + return nil + } + confirmed, err := c.Wallet.IsVSPTicketConfirmed(ctx, hash) + if err != nil && !errors.Is(err, errors.NotExist) { + log.Error(err) + return nil + } + + if confirmed { + return nil + } + + c.mu.Lock() + fp := c.jobs[*hash] + c.mu.Unlock() + if fp != nil { + // Already processing this ticket with the VSP. + return nil + } + + // Start processing in the background. + wg.Add(1) + go func() { + defer wg.Done() + err := c.Process(ctx, hash, nil) + if err != nil { + log.Error(err) + } + }() + + return nil + }) + wg.Wait() +} + +// ProcessTicket attempts to process a given ticket based on the hash provided. +func (c *Client) ProcessTicket(ctx context.Context, hash *chainhash.Hash) error { + err := c.Process(ctx, hash, nil) + if err != nil { + return err + } + return nil +} + +// ProcessManagedTickets discovers tickets which were previously registered with +// a VSP and begins syncing them in the background. This is used to recover VSP +// tracking after seed restores, and is only performed on unspent and unexpired +// tickets. +func (c *Client) ProcessManagedTickets(ctx context.Context, policy Policy) error { + err := c.Wallet.ForUnspentUnexpiredTickets(ctx, func(hash *chainhash.Hash) error { + // We only want to process tickets that haven't been confirmed yet. + confirmed, err := c.Wallet.IsVSPTicketConfirmed(ctx, hash) + if err != nil && !errors.Is(err, errors.NotExist) { + log.Error(err) + return nil + } + if confirmed { + return nil + } + c.mu.Lock() + _, ok := c.jobs[*hash] + c.mu.Unlock() + if ok { + // Already processing this ticket with the VSP. + return nil + } + + // Make ticketstatus api call and only continue if ticket is + // found managed by this vsp. The rest is the same codepath as + // for processing a new ticket. + status, err := c.status(ctx, hash) + if err != nil { + if errors.Is(err, errors.Locked) { + return err + } + return nil + } + + if status.FeeTxStatus == "confirmed" { + feeHash, err := chainhash.NewHashFromStr(status.FeeTxHash) + if err != nil { + return err + } + err = c.Wallet.UpdateVspTicketFeeToConfirmed(ctx, hash, feeHash, c.client.url, c.client.pub) + if err != nil { + return err + } + return nil + } else if status.FeeTxHash != "" { + feeHash, err := chainhash.NewHashFromStr(status.FeeTxHash) + if err != nil { + return err + } + err = c.Wallet.UpdateVspTicketFeeToPaid(ctx, hash, feeHash, c.client.url, c.client.pub) + if err != nil { + return err + } + _ = c.feePayment(hash, policy, true) + } else { + // Fee hasn't been paid at the provided VSP, so this should do that if needed. + _ = c.feePayment(hash, policy, false) + } + + return nil + }) + return err +} + +// Process begins processing a VSP fee payment for a ticket. If feeTx contains +// inputs, is used to pay the VSP fee. Otherwise, new inputs are selected and +// locked to prevent double spending the fee. +// +// feeTx must not be nil, but may point to an empty transaction, and is modified +// with the inputs and the fee and change outputs before returning without an +// error. The fee transaction is also recorded as unpublised in the wallet, and +// the fee hash is associated with the ticket. +func (c *Client) Process(ctx context.Context, ticketHash *chainhash.Hash, feeTx *wire.MsgTx) error { + return c.ProcessWithPolicy(ctx, ticketHash, feeTx, c.Policy) +} + +// ProcessWithPolicy is the same as Process but allows a fee payment policy to +// be specified, instead of using the client's default policy. +func (c *Client) ProcessWithPolicy(ctx context.Context, ticketHash *chainhash.Hash, feeTx *wire.MsgTx, + policy Policy) error { + + vspTicket, err := c.Wallet.VSPTicketInfo(ctx, ticketHash) + if err != nil && !errors.Is(err, errors.NotExist) { + return err + } + feeStatus := udb.VSPFeeProcessStarted // Will be used if the ticket isn't registered to the vsp yet. + if vspTicket != nil { + feeStatus = udb.FeeStatus(vspTicket.FeeTxStatus) + } + + switch feeStatus { + case udb.VSPFeeProcessStarted, udb.VSPFeeProcessErrored: + // If VSPTicket has been started or errored then attempt to create a new fee + // transaction, submit it then confirm. + fp := c.feePayment(ticketHash, policy, false) + if fp == nil { + err := c.Wallet.UpdateVspTicketFeeToErrored(ctx, ticketHash, c.client.url, c.client.pub) + if err != nil { + return err + } + return fmt.Errorf("fee payment cannot be processed") + } + fp.mu.Lock() + if fp.feeTx == nil { + fp.feeTx = feeTx + } + fp.mu.Unlock() + err := fp.receiveFeeAddress() + if err != nil { + err := c.Wallet.UpdateVspTicketFeeToErrored(ctx, ticketHash, c.client.url, c.client.pub) + if err != nil { + return err + } + // XXX, retry? (old Process retried) + // but this may not be necessary any longer as the parent of + // the ticket is always relayed to the vsp as well. + return err + } + err = fp.makeFeeTx(feeTx) + if err != nil { + err := c.Wallet.UpdateVspTicketFeeToErrored(ctx, ticketHash, c.client.url, c.client.pub) + if err != nil { + return err + } + return err + } + return fp.submitPayment() + case udb.VSPFeeProcessPaid: + // If a VSP ticket has been paid, but confirm payment. + if len(vspTicket.Host) > 0 && vspTicket.Host != c.client.url { + // Cannot confirm a paid ticket that is already with another VSP. + return fmt.Errorf("ticket already paid or confirmed with another vsp") + } + fp := c.feePayment(ticketHash, policy, true) + if fp == nil { + // Don't update VSPStatus to Errored if it was already paid or + // confirmed. + return fmt.Errorf("fee payment cannot be processed") + } + + return fp.confirmPayment() + case udb.VSPFeeProcessConfirmed: + // VSPTicket has already been confirmed, there is nothing to process. + return nil + } + return nil +} + +// SetVoteChoice takes the provided consensus, tspend and treasury key voting +// preferences, and checks if they match the status of the specified ticket from +// the connected VSP. The status provides the current voting preferences so we +// can just update from there if need be. +func (c *Client) SetVoteChoice(ctx context.Context, hash *chainhash.Hash, + choices []wallet.AgendaChoice, tspendPolicy map[string]string, treasuryPolicy map[string]string) error { + + // Retrieve current voting preferences from VSP. + status, err := c.status(ctx, hash) + if err != nil { + if errors.Is(err, errors.Locked) { + return err + } + log.Errorf("Could not check status of VSP ticket %s: %v", hash, err) + return nil + } + + // Check for any mismatch between the provided voting preferences and the + // VSP preferences to determine if VSP needs to be updated. + update := false + + // Check consensus vote choices. + for _, newChoice := range choices { + vspChoice, ok := status.VoteChoices[newChoice.AgendaID] + if !ok { + update = true + break + } + if vspChoice != newChoice.ChoiceID { + update = true + break + } + } + + // Apply the above changes to the two checks below. + + // Check tspend policies. + for newTSpend, newChoice := range tspendPolicy { + vspChoice, ok := status.TSpendPolicy[newTSpend] + if !ok { + update = true + break + } + if vspChoice != newChoice { + update = true + break + } + } + + // Check treasury policies. + for newKey, newChoice := range treasuryPolicy { + vspChoice, ok := status.TSpendPolicy[newKey] + if !ok { + update = true + break + } + if vspChoice != newChoice { + update = true + break + } + } + + if !update { + log.Debugf("VSP already has correct vote choices for ticket %s", hash) + return nil + } + + log.Debugf("Updating vote choices on VSP for ticket %s", hash) + err = c.setVoteChoices(ctx, hash, choices, tspendPolicy, treasuryPolicy) + if err != nil { + return err + } + return nil +} + +// TicketInfo stores per-ticket info tracked by a VSP Client instance. +type TicketInfo struct { + TicketHash chainhash.Hash + CommitmentAddr stdaddr.StakeAddress + VotingAddr stdaddr.StakeAddress + State uint32 + Fee dcrutil.Amount + FeeHash chainhash.Hash + + // TODO: include stuff returned by the status() call? +} + +// TrackedTickets returns information about all outstanding tickets tracked by +// a vsp.Client instance. +// +// Currently this returns only info about tickets which fee hasn't been paid or +// confirmed at enough depth to be considered committed to. +func (c *Client) TrackedTickets() []*TicketInfo { + // Collect all jobs first, to avoid working under two different locks. + c.mu.Lock() + jobs := make([]*feePayment, 0, len(c.jobs)) + for _, job := range c.jobs { + jobs = append(jobs, job) + } + c.mu.Unlock() + + tickets := make([]*TicketInfo, 0, len(c.jobs)) + for _, job := range jobs { + job.mu.Lock() + tickets = append(tickets, &TicketInfo{ + TicketHash: job.ticketHash, + CommitmentAddr: job.commitmentAddr, + VotingAddr: job.votingAddr, + State: uint32(job.state), + Fee: job.fee, + FeeHash: job.feeHash, + }) + job.mu.Unlock() + } + + return tickets +} diff --git a/client/asset/dcr/wallet.go b/client/asset/dcr/wallet.go index 0ea4e91143..1fdd4138c7 100644 --- a/client/asset/dcr/wallet.go +++ b/client/asset/dcr/wallet.go @@ -13,9 +13,11 @@ 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/v3" "github.com/decred/dcrd/txscript/v4/stdaddr" "github.com/decred/dcrd/wire" + vspdjson "github.com/decred/vspd/types" ) // WalletConstructor defines a function that can be invoked to create a custom @@ -143,7 +145,16 @@ 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. vsp arguments only needed for + // internal wallets. + PurchaseTickets(ctx context.Context, n int, vspHost, vspPubKey string) error + // Tickets returns current active ticket hashes up until they are voted + // or revoked. Includes unconfirmed tickets. + Tickets(ctx context.Context) ([]string, error) + VotingPreferences(ctx context.Context) ([]*walletjson.VoteChoice, []*walletjson.TSpendPolicyResult, []*walletjson.TreasuryPolicyResult, error) + SetVotingPreferences(ctx context.Context, choices, tspendPolicy, treasuryPolicy map[string]string, vspInfo func(url string) (*vspdjson.VspInfoResponse, error)) error Reconfigure(ctx context.Context, cfg *asset.WalletConfig, net dex.Network, currentAddress, depositAccount string) (restart bool, err error) } diff --git a/client/asset/interface.go b/client/asset/interface.go index 2a1d0a28d3..51c8d998a5 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/v2/rpc/jsonrpc/types" "github.com/decred/dcrd/dcrec/secp256k1/v4" ) @@ -28,6 +29,7 @@ const ( WalletTraitRedemptionConfirmer // The wallet has a process to confirm a redemption. WalletTraitTxFeeEstimator // The wallet can estimate transaction fees. WalletTraitPeerManager // The wallet can manage its peers. + WalletTraitTicketBuyer // The wallet can participate in decred staking. ) // IsRescanner tests if the WalletTrait has the WalletTraitRescanner bit set. @@ -96,6 +98,12 @@ func (wt WalletTrait) IsPeerManager() bool { return wt&WalletTraitPeerManager != 0 } +// IsTicketBuyer tests if the WalletTrait has the WalletTraitDCRStaker 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 { @@ -134,6 +142,9 @@ func DetermineWalletTraits(w Wallet) (t WalletTrait) { if _, is := w.(PeerManager); is { t |= WalletTraitPeerManager } + if _, is := w.(TicketBuyer); is { + t |= WalletTraitTicketBuyer + } return t } @@ -778,6 +789,32 @@ type PeerManager interface { RemovePeer(addr string) error } +// TicketBuyer is a wallet that can participate in decred staking. +type TicketBuyer interface { + // TicketPrice is the current price of one ticket. Also known as the + // stake difficulty. + TicketPrice() (uint64, error) + // VSP returns the currently set VSP address and fee. + VSP() (addr string, feePercent float64, err error) + // CanSetVSP returns whether the VSP can be changed. It cannot for + // rpcwallets but can for internal. + CanSetVSP() bool + // SetVSP sets the VSP provider. Ability to set should be checked with + // CanSetVSP first. + SetVSP(addr string) error + // PurchaseTickets purchases n amout of tickets. Ability to purchase + // should be checked with CanPurchaseTickets. + PurchaseTickets(n int) error + // Tickets returns current active ticket hashes up until they are voted + // or revoked. Includes unconfirmed tickets. + Tickets() ([]string, error) + // VotingPreferences returns current voting preferences. + VotingPreferences() ([]*dcrwalletjson.VoteChoice, []*dcrwalletjson.TSpendPolicyResult, []*dcrwalletjson.TreasuryPolicyResult, 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/dex/testing/dcr/create-vspd.sh b/dex/testing/dcr/create-vspd.sh new file mode 100755 index 0000000000..acea65663a --- /dev/null +++ b/dex/testing/dcr/create-vspd.sh @@ -0,0 +1,55 @@ +#!/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" +VSPD_WEBAPI_DIR="${VSPD_DIR}/webapi" +mkdir -p ${VSPD_WEBAPI_DIR} + +VSPD_TEMPLATES_FROM="${HARNESS_DIR}/vspdtemplates" +VSPD_TEMPLATES_TO="${VSPD_WEBAPI_DIR}/templates" +cp -a "${VSPD_TEMPLATES_FROM}" "${VSPD_TEMPLATES_TO}" + +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 2f7a3449b7..cf4bce53bd 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" @@ -143,7 +151,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" < +
+ +
+

Admin Panel

+
+ + {{ template "vsp-stats" . }} + +
+ + +
+
+ +
+
+ +
+ + + + +
    +
  • +
  • +
  • +
  • +
+ +
+ +
+ +
+

Local dcrd

+ + + + + + + + + + + {{ if .DcrdStatus.Connected }} + + {{ if .DcrdStatus.BestBlockError }} + + {{ else }} + + {{ end }} + + {{else}} + + {{end}} + + +
URLHeight
{{ stripWss .DcrdStatus.Host }} +
+
+ Error +
+
+
{{ .DcrdStatus.BestBlockHeight }} +
+
+ Cannot connect +
+
+
+
+ +
+

Voting Wallets

+ + + + + + + + + + + + {{ range $host, $status := .WalletStatus }} + + + + {{ if $status.Connected }} + + {{ if $status.BestBlockError }} + + {{ else }} + + {{ end }} + + {{ if $status.InfoError }} + + {{ else }} + + + + + + + + + + {{ end }} + + {{else}} + + {{end}} + + {{end}} + +
URLHeightConnected
to dcrd
UnlockedVotingVote
Version
{{ stripWss $host }} +
+
+ Error +
+
+
{{ $status.BestBlockHeight }} +
+
+ Error getting wallet info +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $status.VoteVersion }} +
+
+ Cannot connect to wallet +
+
+
+
+ +
+ +
+
+ + +
+ + {{ with .SearchResult }} + {{ template "ticket-search-result" . }} + {{ end }} +
+ +
+

Database size: {{ .WebApiCache.DatabaseSize }}

+ Download Backup +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +{{ template "footer" . }} diff --git a/dex/testing/dcr/vspdtemplates/footer.html b/dex/testing/dcr/vspdtemplates/footer.html new file mode 100644 index 0000000000..505a2cd8a1 --- /dev/null +++ b/dex/testing/dcr/vspdtemplates/footer.html @@ -0,0 +1,27 @@ +{{ define "footer" }} + + + +
+
+

+ Stats updated: {{ .WebApiCache.UpdateTime }} +
+ Support: {{ .WebApiCfg.SupportEmail }} +
+ VSP public key: {{ .WebApiCache.PubKey }} +

+
+ + +
+ + + +{{ end }} diff --git a/dex/testing/dcr/vspdtemplates/header.html b/dex/testing/dcr/vspdtemplates/header.html new file mode 100644 index 0000000000..40ac1d12d8 --- /dev/null +++ b/dex/testing/dcr/vspdtemplates/header.html @@ -0,0 +1,76 @@ +{{define "header"}} + + + + + + + Decred VSP - {{ .WebApiCfg.Designation }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + {{ if .WebApiCfg.Debug }} +
+
+ Web server is running in debug mode - don't do this in production! +
+
+ {{ end }} + +{{end}} diff --git a/dex/testing/dcr/vspdtemplates/homepage.html b/dex/testing/dcr/vspdtemplates/homepage.html new file mode 100644 index 0000000000..0a2d1e6d32 --- /dev/null +++ b/dex/testing/dcr/vspdtemplates/homepage.html @@ -0,0 +1,35 @@ +{{ template "header" . }} + +
+
+ + {{ if .WebApiCfg.VspClosed }} +
+

+ This Voting Service Provider is closed +

+

+ {{ .WebApiCfg.VspClosedMsg }} +

+

+ A closed VSP will still vote on tickets with already paid fees, but will not accept new any tickets. + Visit decred.org to find a new VSP. +

+
+ {{ end }} + +

VSP Overview

+ +

A Voting Service Provider (VSP) maintains a pool of always-online voting wallets, + and allows Decred ticket holders to use these wallets to vote their tickets in exchange for a small fee. + VSPs are completely non-custodial - they never hold, manage, or have access to any user funds. + Visit docs.decred.org + to find out more about VSPs, tickets, and voting. +

+ + {{ template "vsp-stats" . }} + +
+
+ +{{ template "footer" . }} diff --git a/dex/testing/dcr/vspdtemplates/login.html b/dex/testing/dcr/vspdtemplates/login.html new file mode 100644 index 0000000000..f008371bec --- /dev/null +++ b/dex/testing/dcr/vspdtemplates/login.html @@ -0,0 +1,16 @@ +{{ template "header" . }} + +
+

Login

+
+ + + +

{{ .FailedLoginMsg }}

+ + +
+ +
+ +{{ template "footer" . }} diff --git a/dex/testing/dcr/vspdtemplates/ticket-search-result.html b/dex/testing/dcr/vspdtemplates/ticket-search-result.html new file mode 100644 index 0000000000..c687c134a4 --- /dev/null +++ b/dex/testing/dcr/vspdtemplates/ticket-search-result.html @@ -0,0 +1,199 @@ +{{ define "ticket-search-result" }} + + {{ if .Found }} + +

Ticket

+ + + + + + + + + {{ if .Ticket.Confirmed }} + + {{ else }} + + {{ end }} + + + + + + + + + + + + + +
Hash + + {{ .Ticket.Hash }} + +
Status + Confirmed (purchase height: + {{ .Ticket.PurchaseHeight }}) + Not confirmed
Ticket Outcome{{ .Ticket.Outcome }}
Voting WIF{{ .Ticket.VotingWIF }}
Commitment Address + + {{ .Ticket.CommitmentAddress }} + +
+ +

Fee

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Fee Address + + {{ .Ticket.FeeAddress }} + +
Fee Address Index{{ .Ticket.FeeAddressIndex }}
Fee Amount{{ atomsToDCR .Ticket.FeeAmount }}
Fee Expiration{{ .Ticket.FeeExpiration }} ({{ dateTime .Ticket.FeeExpiration }})
Fee Tx Hash + + {{ .Ticket.FeeTxHash }} + +
Fee Tx{{ .Ticket.FeeTxHex }}
Fee Tx Status{{ .Ticket.FeeTxStatus }}
+ +

Vote Choices

+ + + + + + + + + + + + + + + + + + +
Consensus Vote Choices + {{ range $key, $value := .Ticket.VoteChoices }} + {{ $key }}: {{ $value }}
+ {{ end }} +
TSpend Policy + {{ range $key, $value := .Ticket.TSpendPolicy }} + {{ $key }}: {{ $value }}
+ {{ end }} +
Treasury Policy + {{ range $key, $value := .Ticket.TreasuryPolicy }} + {{ $key }}: {{ $value }}
+ {{ end }} +
+ Vote Choice Changes
+ ({{ .MaxVoteChanges }} most recent) +
+ {{ range $key, $value := .VoteChanges }} +
+ + {{ if eq $key 0}} + Initial choices + {{ else }} + Change {{ $key }} + {{ end }} + + + + + + + + + + + + + + + + + + +
Request{{ indentJSON $value.Request }}
Request
Signature
{{ $value.RequestSignature }}
Response{{ indentJSON $value.Response }}
Response
Signature
{{ $value.ResponseSignature }}
+
+ {{end}} +
+ +

Alternate Signing Address

+ + + + + + + + + + +
Alternate Signing Address + {{if .AltSignAddrData}} + + {{ .AltSignAddrData.AltSignAddr }} + + {{end}} +
+ AltSignAddress Change + + {{if .AltSignAddrData}} +
+ + + + + + + + + + + + + + + + + +
Request{{ indentJSON .AltSignAddrData.Req }}
Request
Signature
{{ .AltSignAddrData.ReqSig }}
Response{{ indentJSON .AltSignAddrData.Resp }}
Response
Signature
{{ .AltSignAddrData.RespSig }}
+
+ {{end}} +
+ + {{ else }} +

No ticket found with hash {{ .Hash }}

+ {{ end }} +{{ end }} diff --git a/dex/testing/dcr/vspdtemplates/vsp-stats.html b/dex/testing/dcr/vspdtemplates/vsp-stats.html new file mode 100644 index 0000000000..38dcb0186b --- /dev/null +++ b/dex/testing/dcr/vspdtemplates/vsp-stats.html @@ -0,0 +1,40 @@ +{{ define "vsp-stats" }} + +
+ +
+
Live tickets
+
{{ comma .WebApiCache.Voting }}
+
+ +
+
Voted tickets
+
{{ comma .WebApiCache.Voted }}
+
+ +
+
Revoked tickets
+
+ {{ comma .WebApiCache.Revoked }} + ({{ float32ToPercent .WebApiCache.RevokedProportion }}) +
+
+ +
+
VSP Fee
+
{{ .WebApiCfg.VSPFee }}%
+
+ +
+
Network
+
{{ .WebApiCfg.NetParams.Name }}
+
+ +
+
Network Proportion
+
{{ float32ToPercent .WebApiCache.NetworkProportion }}
+
+ +
+ +{{ end }} diff --git a/dex/testing/loadbot/go.mod b/dex/testing/loadbot/go.mod index 90c400cf02..9f10c68580 100644 --- a/dex/testing/loadbot/go.mod +++ b/dex/testing/loadbot/go.mod @@ -74,6 +74,7 @@ require ( github.com/decred/dcrd/wire v1.5.0 // indirect github.com/decred/go-socks v1.1.0 // indirect github.com/decred/slog v1.2.0 // indirect + github.com/decred/vspd/types v1.1.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/dex/testing/loadbot/go.sum b/dex/testing/loadbot/go.sum index d0ea502727..ea12cda963 100644 --- a/dex/testing/loadbot/go.sum +++ b/dex/testing/loadbot/go.sum @@ -319,6 +319,8 @@ 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/types v1.1.0 h1:hTeqQwgRUN2FGIbuCIdyzBejKV+jxKrmEIcLKxpsB1g= +github.com/decred/vspd/types v1.1.0/go.mod h1:THsO8aBSwWBq6ZsIG25cNqbkNb+EEASXzLhFvODVc0s= 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/go.mod b/go.mod index 1cce46fcb1..7935f95105 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module decred.org/dcrdex go 1.18 +replace decred.org/dcrdex/client/asset/dcr/vsp => ./client/asset/dcr/vsp + require ( decred.org/dcrwallet/v2 v2.0.11 fyne.io/systray v1.10.1-0.20220621085403-9a2652634e93 @@ -41,6 +43,7 @@ require ( github.com/decred/dcrd/wire v1.5.0 github.com/decred/go-socks v1.1.0 github.com/decred/slog v1.2.0 + github.com/decred/vspd/types v1.1.0 github.com/dgraph-io/badger v1.6.2 github.com/ethereum/go-ethereum v1.11.5 github.com/fatih/color v1.11.0 diff --git a/go.sum b/go.sum index d2e9adb23b..17634bbb2b 100644 --- a/go.sum +++ b/go.sum @@ -319,6 +319,8 @@ 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/types v1.1.0 h1:hTeqQwgRUN2FGIbuCIdyzBejKV+jxKrmEIcLKxpsB1g= +github.com/decred/vspd/types v1.1.0/go.mod h1:THsO8aBSwWBq6ZsIG25cNqbkNb+EEASXzLhFvODVc0s= 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=