diff --git a/internal/rpc/jsonrpc/methods.go b/internal/rpc/jsonrpc/methods.go index e30fd4eff..2337c7b86 100644 --- a/internal/rpc/jsonrpc/methods.go +++ b/internal/rpc/jsonrpc/methods.go @@ -136,6 +136,7 @@ var handlers = map[string]handler{ "listsinceblock": {fn: (*Server).listSinceBlock}, "listtransactions": {fn: (*Server).listTransactions}, "listunspent": {fn: (*Server).listUnspent}, + "selectunspent": {fn: (*Server).selectUnspent}, "lockaccount": {fn: (*Server).lockAccount}, "lockunspent": {fn: (*Server).lockUnspent}, "mixaccount": {fn: (*Server).mixAccount}, @@ -3081,6 +3082,66 @@ func (s *Server) listUnspent(ctx context.Context, icmd interface{}) (interface{} return result, nil } +// selectUnspent handles the selectunspent command. +func (s *Server) selectUnspent(ctx context.Context, icmd interface{}) (interface{}, error) { + cmd := icmd.(*types.SelectUnspentCmd) + w, ok := s.walletLoader.LoadedWallet() + if !ok { + return nil, errUnloadedWallet + } + + targetAmount, err := dcrutil.NewAmount(cmd.TargetAmount) + if err != nil { + return nil, rpcError(dcrjson.ErrRPCInvalidParameter, err) + } + if targetAmount < 0 { + return nil, rpcErrorf(dcrjson.ErrRPCInvalidParameter, "negative target amount") + } + + var minAmount dcrutil.Amount + if cmd.MinAmount != nil { + minAmount, err = dcrutil.NewAmount(*cmd.MinAmount) + if err != nil { + return nil, rpcError(dcrjson.ErrRPCInvalidParameter, err) + } + } + + if minAmount < 0 { + return nil, rpcErrorf(dcrjson.ErrRPCInvalidParameter, "negative min amount") + } + + if minAmount > targetAmount { + return nil, rpcErrorf(dcrjson.ErrRPCInvalidParameter, "target amount is less than min amount") + } + + var account string + if cmd.Account != nil { + account = *cmd.Account + } + + var spendAll = false + if cmd.SpendAll != nil { + spendAll = *cmd.SpendAll + } + + var inputMethod = types.RandomInputSelection + if cmd != nil { + inputMethod = types.InputSelectionMethod(*cmd.InputMethod) + } + + skipTxAddress := make(map[string]struct{}) + if cmd.SkipTxAddress != nil { + skipTxAddress = *cmd.SkipTxAddress + } + + result, err := w.SelectUnspent(ctx, targetAmount, minAmount, int32(*cmd.MinConf), account, + spendAll, skipTxAddress, inputMethod) + if err != nil { + return nil, err + } + return result, nil +} + // lockUnspent handles the lockunspent command. func (s *Server) lockUnspent(ctx context.Context, icmd interface{}) (interface{}, error) { cmd := icmd.(*types.LockUnspentCmd) diff --git a/internal/rpc/jsonrpc/rpcserverhelp.go b/internal/rpc/jsonrpc/rpcserverhelp.go index dc2879032..1730b73f5 100644 --- a/internal/rpc/jsonrpc/rpcserverhelp.go +++ b/internal/rpc/jsonrpc/rpcserverhelp.go @@ -61,6 +61,7 @@ func helpDescsEnUS() map[string]string { "listsinceblock": "listsinceblock (\"blockhash\" targetconfirmations=1 includewatchonly=false)\n\nReturns a JSON array of objects listing details of all wallet transactions after some block.\n\nArguments:\n1. blockhash (string, optional) Hash of the parent block of the first block to consider transactions from, or unset to list all transactions\n2. targetconfirmations (numeric, optional, default=1) Minimum number of block confirmations of the last block in the result object. Must be 1 or greater. Note: The transactions array in the result object is not affected by this parameter\n3. includewatchonly (boolean, optional, default=false) Unused\n\nResult:\n{\n \"transactions\": [{ (array of object) JSON array of objects containing verbose details of the each transaction\n \"account\": \"value\", (string) DEPRECATED -- Unset\n \"address\": \"value\", (string) Payment address for a transaction output\n \"amount\": n.nnn, (numeric) The value of the transaction output valued in decred\n \"blockhash\": \"value\", (string) The hash of the block this transaction is mined in, or the empty string if unmined\n \"blockindex\": n, (numeric) Unset\n \"blocktime\": n, (numeric) The Unix time of the block header this transaction is mined in, or 0 if unmined\n \"category\": \"value\", (string) The kind of transaction: \"send\" for sent transactions, \"immature\" for immature coinbase outputs, \"generate\" for mature coinbase outputs, or \"recv\" for all other received outputs. Note: A single output may be included multiple times under different categories\n \"confirmations\": n, (numeric) The number of block confirmations of the transaction\n \"fee\": n.nnn, (numeric) The total input value minus the total output value for sent transactions\n \"generated\": true|false, (boolean) Whether the transaction output is a coinbase output\n \"involveswatchonly\": true|false, (boolean) Unset\n \"time\": n, (numeric) The earliest Unix time this transaction was known to exist\n \"timereceived\": n, (numeric) The earliest Unix time this transaction was known to exist\n \"txid\": \"value\", (string) The hash of the transaction\n \"txtype\": \"value\", (string) The type of tx (regular tx, stake tx)\n \"vout\": n, (numeric) The transaction output index\n \"walletconflicts\": [\"value\",...], (array of string) Unset\n \"comment\": \"value\", (string) Unset\n \"otheraccount\": \"value\", (string) Unset\n },...], \n \"lastblock\": \"value\", (string) Hash of the latest-synced block to be used in later calls to listsinceblock\n} \n", "listtransactions": "listtransactions (\"account\" count=10 from=0 includewatchonly=false)\n\nReturns a JSON array of objects containing verbose details for wallet transactions.\n\nArguments:\n1. account (string, optional) DEPRECATED -- Unused (must be unset or \"*\")\n2. count (numeric, optional, default=10) Maximum number of transactions to create results from\n3. from (numeric, optional, default=0) Number of transactions to skip before results are created\n4. includewatchonly (boolean, optional, default=false) Unused\n\nResult:\n[{\n \"account\": \"value\", (string) DEPRECATED -- Unset\n \"address\": \"value\", (string) Payment address for a transaction output\n \"amount\": n.nnn, (numeric) The value of the transaction output valued in decred\n \"blockhash\": \"value\", (string) The hash of the block this transaction is mined in, or the empty string if unmined\n \"blockindex\": n, (numeric) Unset\n \"blocktime\": n, (numeric) The Unix time of the block header this transaction is mined in, or 0 if unmined\n \"category\": \"value\", (string) The kind of transaction: \"send\" for sent transactions, \"immature\" for immature coinbase outputs, \"generate\" for mature coinbase outputs, or \"recv\" for all other received outputs. Note: A single output may be included multiple times under different categories\n \"confirmations\": n, (numeric) The number of block confirmations of the transaction\n \"fee\": n.nnn, (numeric) The total input value minus the total output value for sent transactions\n \"generated\": true|false, (boolean) Whether the transaction output is a coinbase output\n \"involveswatchonly\": true|false, (boolean) Unset\n \"time\": n, (numeric) The earliest Unix time this transaction was known to exist\n \"timereceived\": n, (numeric) The earliest Unix time this transaction was known to exist\n \"txid\": \"value\", (string) The hash of the transaction\n \"txtype\": \"value\", (string) The type of tx (regular tx, stake tx)\n \"vout\": n, (numeric) The transaction output index\n \"walletconflicts\": [\"value\",...], (array of string) Unset\n \"comment\": \"value\", (string) Unset\n \"otheraccount\": \"value\", (string) Unset\n},...]\n", "listunspent": "listunspent (minconf=1 maxconf=9999999 [\"address\",...] \"account\")\n\nReturns a JSON array of objects representing unlocked unspent outputs controlled by wallet keys.\n\nArguments:\n1. minconf (numeric, optional, default=1) Minimum number of block confirmations required before a transaction output is considered\n2. maxconf (numeric, optional, default=9999999) Maximum number of block confirmations required before a transaction output is excluded\n3. addresses (array of string, optional) If set, limits the returned details to unspent outputs received by any of these payment addresses\n4. account (string, optional) If set, only return unspent outputs from this account\n\nResult:\n{\n \"txid\": \"value\", (string) The transaction hash of the referenced output\n \"vout\": n, (numeric) The output index of the referenced output\n \"tree\": n, (numeric) The tree the transaction comes from\n \"txtype\": n, (numeric) The type of the transaction\n \"address\": \"value\", (string) The payment address that received the output\n \"account\": \"value\", (string) The account associated with the receiving payment address\n \"scriptPubKey\": \"value\", (string) The output script encoded as a hexadecimal string\n \"redeemScript\": \"value\", (string) The redeemScript if scriptPubKey is P2SH\n \"amount\": n.nnn, (numeric) The amount of the output valued in decred\n \"confirmations\": n, (numeric) The number of block confirmations of the transaction\n \"spendable\": true|false, (boolean) Whether the output is entirely controlled by wallet keys/scripts (false for partially controlled multisig outputs or outputs to watch-only addresses)\n} \n", + "selectunspent": "selectunspent targetamount (minamount=0 minconf=1 account=\"\" spendall=false inputmethod=\"random\" {\"address\":{},\"txhash\":{},...})\n\nReturns a JSON array of objects representing unlocked unspent outputs controlled by wallet keys that are enough to pay target amount.\n\nArguments:\n1. targetamount (numeric, required) The minimum total output value of all returned inputs\n2. minamount (numeric, optional, default=0) The minimum amount output value of transaction output should have before it is considered\n3. minconf (numeric, optional, default=1) Minimum block confirmations required for a utxo to be considered\n4. account (string, optional, default=\"\") If set, only return unspent outputs from this account\n5. spendall (boolean, optional, default=false) If set, all eligible inputs will be returned. (target amount will be ignored)\n6. inputmethod (string, optional, default=\"random\") The method for how transaction inputs should be selected.\n7. skiptxaddress (object, optional) Addresses or transaction hashes to be skipped when using UniqueTxInputSelection\n{\n \"Address or transaction hash\": Empty struct, (object) JSON object using addresses or transaction hashes as keys and empty structs as values to specify seen utxos\n ...\n}\n\nResult:\n{\n \"txid\": \"value\", (string) The transaction hash of the referenced output\n \"vout\": n, (numeric) The output index of the referenced output\n \"tree\": n, (numeric) The tree the transaction comes from\n \"txtype\": n, (numeric) The type of the transaction\n \"address\": \"value\", (string) The payment address that received the output\n \"account\": \"value\", (string) The account associated with the receiving payment address\n \"scriptPubKey\": \"value\", (string) The output script encoded as a hexadecimal string\n \"redeemScript\": \"value\", (string) The redeemScript if scriptPubKey is P2SH\n \"amount\": n.nnn, (numeric) The amount of the output valued in decred\n \"confirmations\": n, (numeric) The number of block confirmations of the transaction\n \"spendable\": true|false, (boolean) Whether the output is entirely controlled by wallet keys/scripts (false for partially controlled multisig outputs or outputs to watch-only addresses)\n} \n", "lockaccount": "lockaccount \"account\"\n\nLock an individually-encrypted account\n\nArguments:\n1. account (string, required) Account to lock\n\nResult:\nNothing\n", "lockunspent": "lockunspent unlock [{\"amount\":n.nnn,\"txid\":\"value\",\"vout\":n,\"tree\":n},...]\n\nLocks or unlocks an unspent output.\nLocked outputs are not chosen for transaction inputs of authored transactions and are not included in 'listunspent' results.\nLocked outputs are volatile and are not saved across wallet restarts.\nIf unlock is true and no transaction outputs are specified, all locked outputs are marked unlocked.\n\nArguments:\n1. unlock (boolean, required) True to unlock outputs, false to lock\n2. transactions (array of object, required) Transaction outputs to lock or unlock\n[{\n \"amount\": n.nnn, (numeric) The the previous output amount\n \"txid\": \"value\", (string) The transaction hash of the referenced output\n \"vout\": n, (numeric) The output index of the referenced output\n \"tree\": n, (numeric) The tree to generate transaction for\n},...]\n\nResult:\ntrue|false (boolean) The boolean 'true'\n", "mixaccount": "mixaccount\n\nMix all outputs of an account.\n\nArguments:\nNone\n\nResult:\nNothing\n", @@ -112,4 +113,4 @@ var localeHelpDescs = map[string]func() map[string]string{ "en_US": helpDescsEnUS, } -var requestUsages = "abandontransaction \"hash\"\naccountaddressindex \"account\" branch\naccountsyncaddressindex \"account\" branch index\naccountunlocked \"account\"\naddmultisigaddress nrequired [\"key\",...] (\"account\")\naddtransaction \"blockhash\" \"transaction\"\nauditreuse (since)\nconsolidate inputs (\"account\" \"address\")\ncreatemultisig nrequired [\"key\",...]\ncreatenewaccount \"account\"\ncreaterawtransaction [{\"amount\":n.nnn,\"txid\":\"value\",\"vout\":n,\"tree\":n},...] {\"address\":amount,...} (locktime expiry)\ncreatesignature \"address\" inputindex hashtype \"previouspkscript\" \"serializedtransaction\"\ndisapprovepercent\ndiscoverusage (\"startblock\" discoveraccounts gaplimit)\ndumpprivkey \"address\"\nfundrawtransaction \"hexstring\" \"fundaccount\" ({\"changeaddress\":changeaddress,\"feerate\":feerate,\"conftarget\":conftarget})\ngeneratevote \"blockhash\" height \"tickethash\" votebits \"votebitsext\"\ngetaccount \"address\"\ngetaccountaddress \"account\"\ngetaddressesbyaccount \"account\"\ngetbalance (\"account\" minconf=1)\ngetbestblock\ngetbestblockhash\ngetblockcount\ngetblockhash index\ngetblock \"hash\" (verbose=true verbosetx=false)\ngetcoinjoinsbyacct\ngetinfo\ngetmasterpubkey (\"account\")\ngetmultisigoutinfo \"hash\" index\ngetnewaddress (\"account\" \"gappolicy\")\ngetpeerinfo\ngetrawchangeaddress (\"account\")\ngetreceivedbyaccount \"account\" (minconf=1)\ngetreceivedbyaddress \"address\" (minconf=1)\ngetstakeinfo\ngettickets includeimmature\ngettransaction \"txid\" (includewatchonly=false)\ngettxout \"txid\" vout tree (includemempool=true)\ngetunconfirmedbalance (\"account\")\ngetvotechoices (\"tickethash\")\ngetwalletfee\ngetcfilterv2 \"blockhash\"\nhelp (\"command\")\nimportcfiltersv2 startheight [\"filter\",...]\nimportprivkey \"privkey\" (\"label\" rescan=true scanfrom)\nimportscript \"hex\" (rescan=true scanfrom)\nimportxpub \"name\" \"xpub\"\nlistaccounts (minconf=1)\nlistaddresstransactions [\"address\",...] (\"account\")\nlistalltransactions (\"account\")\nlistlockunspent (\"account\")\nlistreceivedbyaccount (minconf=1 includeempty=false includewatchonly=false)\nlistreceivedbyaddress (minconf=1 includeempty=false includewatchonly=false)\nlistsinceblock (\"blockhash\" targetconfirmations=1 includewatchonly=false)\nlisttransactions (\"account\" count=10 from=0 includewatchonly=false)\nlistunspent (minconf=1 maxconf=9999999 [\"address\",...] \"account\")\nlockaccount \"account\"\nlockunspent unlock [{\"amount\":n.nnn,\"txid\":\"value\",\"vout\":n,\"tree\":n},...]\nmixaccount\nmixoutput \"outpoint\"\npurchaseticket \"fromaccount\" spendlimit (minconf=1 \"ticketaddress\" numtickets=1 \"pooladdress\" poolfees expiry \"comment\" dontsigntx)\nredeemmultisigout \"hash\" index tree (\"address\")\nredeemmultisigouts \"fromscraddress\" (\"toaddress\" number)\nrenameaccount \"oldaccount\" \"newaccount\"\nrescanwallet (beginheight=0)\nrevoketickets\nsendfrom \"fromaccount\" \"toaddress\" amount (minconf=1 \"comment\" \"commentto\")\nsendfromtreasury \"key\" amounts\nsendmany \"fromaccount\" {\"address\":amount,...} (minconf=1 \"comment\")\nsendrawtransaction \"hextx\" (allowhighfees=false)\nsendtoaddress \"address\" amount (\"comment\" \"commentto\")\nsendtomultisig \"fromaccount\" amount [\"pubkey\",...] (nrequired=1 minconf=1 \"comment\")\nsendtotreasury amount\nsetaccountpassphrase \"account\" \"passphrase\"\nsetdisapprovepercent percent\nsettreasurypolicy \"key\" \"policy\"\nsettspendpolicy \"hash\" \"policy\"\nsettxfee amount\nsetvotechoice \"agendaid\" \"choiceid\" (\"tickethash\")\nsignmessage \"address\" \"message\"\nsignrawtransaction \"rawtx\" ([{\"txid\":\"value\",\"vout\":n,\"tree\":n,\"scriptpubkey\":\"value\",\"redeemscript\":\"value\"},...] [\"privkey\",...] flags=\"ALL\")\nsignrawtransactions [\"rawtx\",...] (send=true)\nstakepooluserinfo \"user\"\nsweepaccount \"sourceaccount\" \"destinationaddress\" (requiredconfirmations feeperkb)\nsyncstatus\nticketinfo (startheight=0)\nticketsforaddress \"address\"\ntreasurypolicy (\"key\")\ntspendpolicy (\"hash\")\nunlockaccount \"account\" \"passphrase\"\nvalidateaddress \"address\"\nvalidatepredcp0005cf\nverifymessage \"address\" \"signature\" \"message\"\nversion\nwalletinfo\nwalletislocked\nwalletlock\nwalletpassphrase \"passphrase\" timeout\nwalletpassphrasechange \"oldpassphrase\" \"newpassphrase\"\nwalletpubpassphrasechange \"oldpassphrase\" \"newpassphrase\"" +var requestUsages = "abandontransaction \"hash\"\naccountaddressindex \"account\" branch\naccountsyncaddressindex \"account\" branch index\naccountunlocked \"account\"\naddmultisigaddress nrequired [\"key\",...] (\"account\")\naddtransaction \"blockhash\" \"transaction\"\nauditreuse (since)\nconsolidate inputs (\"account\" \"address\")\ncreatemultisig nrequired [\"key\",...]\ncreatenewaccount \"account\"\ncreaterawtransaction [{\"amount\":n.nnn,\"txid\":\"value\",\"vout\":n,\"tree\":n},...] {\"address\":amount,...} (locktime expiry)\ncreatesignature \"address\" inputindex hashtype \"previouspkscript\" \"serializedtransaction\"\ndisapprovepercent\ndiscoverusage (\"startblock\" discoveraccounts gaplimit)\ndumpprivkey \"address\"\nfundrawtransaction \"hexstring\" \"fundaccount\" ({\"changeaddress\":changeaddress,\"feerate\":feerate,\"conftarget\":conftarget})\ngeneratevote \"blockhash\" height \"tickethash\" votebits \"votebitsext\"\ngetaccount \"address\"\ngetaccountaddress \"account\"\ngetaddressesbyaccount \"account\"\ngetbalance (\"account\" minconf=1)\ngetbestblock\ngetbestblockhash\ngetblockcount\ngetblockhash index\ngetblock \"hash\" (verbose=true verbosetx=false)\ngetcoinjoinsbyacct\ngetinfo\ngetmasterpubkey (\"account\")\ngetmultisigoutinfo \"hash\" index\ngetnewaddress (\"account\" \"gappolicy\")\ngetpeerinfo\ngetrawchangeaddress (\"account\")\ngetreceivedbyaccount \"account\" (minconf=1)\ngetreceivedbyaddress \"address\" (minconf=1)\ngetstakeinfo\ngettickets includeimmature\ngettransaction \"txid\" (includewatchonly=false)\ngettxout \"txid\" vout tree (includemempool=true)\ngetunconfirmedbalance (\"account\")\ngetvotechoices (\"tickethash\")\ngetwalletfee\ngetcfilterv2 \"blockhash\"\nhelp (\"command\")\nimportcfiltersv2 startheight [\"filter\",...]\nimportprivkey \"privkey\" (\"label\" rescan=true scanfrom)\nimportscript \"hex\" (rescan=true scanfrom)\nimportxpub \"name\" \"xpub\"\nlistaccounts (minconf=1)\nlistaddresstransactions [\"address\",...] (\"account\")\nlistalltransactions (\"account\")\nlistlockunspent (\"account\")\nlistreceivedbyaccount (minconf=1 includeempty=false includewatchonly=false)\nlistreceivedbyaddress (minconf=1 includeempty=false includewatchonly=false)\nlistsinceblock (\"blockhash\" targetconfirmations=1 includewatchonly=false)\nlisttransactions (\"account\" count=10 from=0 includewatchonly=false)\nlistunspent (minconf=1 maxconf=9999999 [\"address\",...] \"account\")\nselectunspent targetamount (minamount=0 minconf=1 account=\"\" spendall=false inputmethod=\"random\" {\"address\":{},\"txhash\":{},...})\nlockaccount \"account\"\nlockunspent unlock [{\"amount\":n.nnn,\"txid\":\"value\",\"vout\":n,\"tree\":n},...]\nmixaccount\nmixoutput \"outpoint\"\npurchaseticket \"fromaccount\" spendlimit (minconf=1 \"ticketaddress\" numtickets=1 \"pooladdress\" poolfees expiry \"comment\" dontsigntx)\nredeemmultisigout \"hash\" index tree (\"address\")\nredeemmultisigouts \"fromscraddress\" (\"toaddress\" number)\nrenameaccount \"oldaccount\" \"newaccount\"\nrescanwallet (beginheight=0)\nrevoketickets\nsendfrom \"fromaccount\" \"toaddress\" amount (minconf=1 \"comment\" \"commentto\")\nsendfromtreasury \"key\" amounts\nsendmany \"fromaccount\" {\"address\":amount,...} (minconf=1 \"comment\")\nsendrawtransaction \"hextx\" (allowhighfees=false)\nsendtoaddress \"address\" amount (\"comment\" \"commentto\")\nsendtomultisig \"fromaccount\" amount [\"pubkey\",...] (nrequired=1 minconf=1 \"comment\")\nsendtotreasury amount\nsetaccountpassphrase \"account\" \"passphrase\"\nsetdisapprovepercent percent\nsettreasurypolicy \"key\" \"policy\"\nsettspendpolicy \"hash\" \"policy\"\nsettxfee amount\nsetvotechoice \"agendaid\" \"choiceid\" (\"tickethash\")\nsignmessage \"address\" \"message\"\nsignrawtransaction \"rawtx\" ([{\"txid\":\"value\",\"vout\":n,\"tree\":n,\"scriptpubkey\":\"value\",\"redeemscript\":\"value\"},...] [\"privkey\",...] flags=\"ALL\")\nsignrawtransactions [\"rawtx\",...] (send=true)\nstakepooluserinfo \"user\"\nsweepaccount \"sourceaccount\" \"destinationaddress\" (requiredconfirmations feeperkb)\nsyncstatus\nticketinfo (startheight=0)\nticketsforaddress \"address\"\ntreasurypolicy (\"key\")\ntspendpolicy (\"hash\")\nunlockaccount \"account\" \"passphrase\"\nvalidateaddress \"address\"\nvalidatepredcp0005cf\nverifymessage \"address\" \"signature\" \"message\"\nversion\nwalletinfo\nwalletislocked\nwalletlock\nwalletpassphrase \"passphrase\" timeout\nwalletpassphrasechange \"oldpassphrase\" \"newpassphrase\"\nwalletpubpassphrasechange \"oldpassphrase\" \"newpassphrase\"" diff --git a/internal/rpchelp/helpdescs_en_US.go b/internal/rpchelp/helpdescs_en_US.go index 4e9585476..7173dc60a 100644 --- a/internal/rpchelp/helpdescs_en_US.go +++ b/internal/rpchelp/helpdescs_en_US.go @@ -610,6 +610,19 @@ var helpDescsEnUS = map[string]string{ "listunspentresult-txtype": "The type of the transaction", "listunspentresult-tree": "The tree the transaction comes from", + // SelectUnspentCmd help. + "selectunspent--synopsis": "Returns a JSON array of objects representing unlocked unspent outputs controlled by wallet keys that are enough to pay target amount.", + "selectunspent-targetamount": "The minimum total output value of all returned inputs", + "selectunspent-minamount": "The minimum amount output value of transaction output should have before it is considered", + "selectunspent-minconf": "Minimum block confirmations required for a utxo to be considered", + "selectunspent-account": "If set, only return unspent outputs from this account", + "selectunspent-spendall": "If set, all eligible inputs will be returned. (target amount will be ignored)", + "selectunspent-inputmethod": "The method for how transaction inputs should be selected.", + "selectunspent-skiptxaddress": "Addresses or transaction hashes to be skipped when using UniqueTxInputSelection", + "selectunspent-skiptxaddress--desc": "JSON object using addresses or transaction hashes as keys and empty structs as values to specify seen utxos", + "selectunspent-skiptxaddress--key": "Address or transaction hash", + "selectunspent-skiptxaddress--value": "Empty struct", + // LockAccountCmd help. "lockaccount--synopsis": "Lock an individually-encrypted account", "lockaccount-account": "Account to lock", diff --git a/internal/rpchelp/methods.go b/internal/rpchelp/methods.go index ce305a2cb..4829c4555 100644 --- a/internal/rpchelp/methods.go +++ b/internal/rpchelp/methods.go @@ -84,6 +84,7 @@ var Methods = []struct { {"listsinceblock", []interface{}{(*types.ListSinceBlockResult)(nil)}}, {"listtransactions", returnsLTRArray}, {"listunspent", []interface{}{(*types.ListUnspentResult)(nil)}}, + {"selectunspent", []interface{}{(*types.ListUnspentResult)(nil)}}, {"lockaccount", nil}, {"lockunspent", returnsBool}, {"mixaccount", nil}, diff --git a/rpc/client/dcrwallet/methods.go b/rpc/client/dcrwallet/methods.go index 063c504ef..af5c3415c 100644 --- a/rpc/client/dcrwallet/methods.go +++ b/rpc/client/dcrwallet/methods.go @@ -94,6 +94,13 @@ func (c *Client) ListUnspentMinMaxAddresses(ctx context.Context, minConf, maxCon return res, err } +func (c *Client) SelectUnspent(ctx context.Context, targetAmount, minAmount dcrutil.Amount, minConf int, + account string, spendAll bool, inputMethod string, skiptxaddress map[string]struct{}) ([]types.ListUnspentResult, error) { + var res []types.ListUnspentResult + err := c.Call(ctx, "selectunspent", &res, targetAmount.ToCoin(), minAmount.ToCoin(), minConf, account, spendAll, inputMethod, skiptxaddress) + return res, err +} + // ListSinceBlock returns all transactions added in blocks since the specified // block hash, or all transactions if it is nil, using the default number of // minimum confirmations as a filter. diff --git a/rpc/jsonrpc/types/methods.go b/rpc/jsonrpc/types/methods.go index 23c5325a2..b20b4db64 100644 --- a/rpc/jsonrpc/types/methods.go +++ b/rpc/jsonrpc/types/methods.go @@ -659,6 +659,29 @@ func NewListUnspentCmd(minConf, maxConf *int, addresses *[]string) *ListUnspentC } } +type SelectUnspentCmd struct { + TargetAmount float64 + MinAmount *float64 `jsonrpcdefault:"0"` + MinConf *int `jsonrpcdefault:"1"` + Account *string `jsonrpcdefault:"\"\""` + SpendAll *bool `jsonrpcdefault:"false"` + InputMethod *string `jsonrpcdefault:"\"random\""` + SkipTxAddress *map[string]struct{} `jsonrpcusage:"{\"address\":{},\"txhash\":{},...}"` +} + +func NewSelectUnspentCmd(targetAmount float64, minAmount *float64, minConf *int, account *string, + spendAll *bool, inputMethod *string, skipTxAddress *map[string]struct{}) *SelectUnspentCmd { + return &SelectUnspentCmd{ + TargetAmount: targetAmount, + MinConf: minConf, + MinAmount: minAmount, + Account: account, + SpendAll: spendAll, + InputMethod: inputMethod, + SkipTxAddress: skipTxAddress, + } +} + // LockUnspentCmd defines the lockunspent JSON-RPC command. type LockUnspentCmd struct { Unlock bool @@ -1234,6 +1257,7 @@ func init() { {"listsinceblock", (*ListSinceBlockCmd)(nil)}, {"listtransactions", (*ListTransactionsCmd)(nil)}, {"listunspent", (*ListUnspentCmd)(nil)}, + {"selectunspent", (*SelectUnspentCmd)(nil)}, {"lockaccount", (*LockAccountCmd)(nil)}, {"lockunspent", (*LockUnspentCmd)(nil)}, {"mixaccount", (*MixAccountCmd)(nil)}, diff --git a/rpc/jsonrpc/types/results.go b/rpc/jsonrpc/types/results.go index 8d6aa34c5..43c81ef09 100644 --- a/rpc/jsonrpc/types/results.go +++ b/rpc/jsonrpc/types/results.go @@ -265,6 +265,23 @@ type ListSinceBlockResult struct { LastBlock string `json:"lastblock"` } +// InputSelectionMethod defines the type used in the selectunspent JSON-RPC +// result for the InputSelectionMethod command field. +type InputSelectionMethod string + +const ( + // RandomInputSelection indicates any random utxo can be selected. + RandomInputSelection InputSelectionMethod = "random" + // RandomAddressInputSelection indicates that only utxos matching a randomly selected + // address should be selected. + RandomAddressInputSelection InputSelectionMethod = "randomaddress" + // OneUTXOInputSelection indicates that only one utxo should be selected. + OneUTXOInputSelection InputSelectionMethod = "oneutxo" + // UniqueTxInputSelection indicates that only utxos with unique address + // and hash should be selected. + UniqueTxInputSelection InputSelectionMethod = "uniquetx" +) + // ListUnspentResult models a successful response from the listunspent request. // Contains Decred additions. type ListUnspentResult struct { diff --git a/wallet/wallet.go b/wallet/wallet.go index 0e389eb2a..9ac0a5c50 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -3716,6 +3716,276 @@ func (w *Wallet) ListUnspent(ctx context.Context, minconf, maxconf int32, addres return results, nil } +// SelectUnspent returns a slice of objects representing the unspent wallet +// transactions for the given criteria that are enough to pay the target amount. +// The output amount and confirmations will be greater than the amount +// & minconf parameters. Only utxos matching the accountName will be returned if +// that parameter is used. targetAmount is ignored if spendAll is set to true. The +// inputMethod determines how inputs should be selected and theseenTxIDs is for +// use with the UniqueTxInputSelection parameter to determine what transaction hash +// or address should be skipped. +func (w *Wallet) SelectUnspent(ctx context.Context, targetAmount, minAmount dcrutil.Amount, minconf int32, accountName string, + spendAll bool, skipTxAddress map[string]struct{}, inputMethod types.InputSelectionMethod) ([]*types.ListUnspentResult, error) { + const op errors.Op = "wallet.SelectUnspent" + + var ( + currentTotal dcrutil.Amount + results []*types.ListUnspentResult + ) + + err := walletdb.View(ctx, w.db, func(dbtx walletdb.ReadTx) error { + addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey) + txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey) + + _, tipHeight := w.txStore.MainChainTip(dbtx) + + unspent, err := w.txStore.UnspentOutputs(dbtx) + if err != nil { + return err + } + + defaultAccountName, err := w.manager.AccountName( + addrmgrNs, udb.DefaultAccountNum) + if err != nil { + return err + } + + // Shuffe utxos + shuffle(len(unspent), func(i, j int) { unspent[i], unspent[j] = unspent[j], unspent[i] }) + + // used for RandomAddressInputSelection + var randomUnspent *types.ListUnspentResult + + for i := range unspent { + output := unspent[i] + + if output.Amount < minAmount { + log.Tracef("Skipping utxo %s, amount: %s, min: %s", output.Hash.String(), output.Amount, minAmount) + continue + } + + if inputMethod == types.OneUTXOInputSelection { + // We're selecting only one utxo so this loop will run until we find + // a single utxo that can pay the target amount. + + if targetAmount > output.Amount { + // continue if this utxo cannot pay the require amount + continue + } + } else if inputMethod == types.UniqueTxInputSelection { + // skip duplicate tx id + _, skipTx := skipTxAddress[output.Hash.String()] + if skipTx { + log.Tracef("Skipping duplicate txid: %s:%d", output.Hash.String(), output.Index) + continue + } + } + + details, err := w.txStore.TxDetails(txmgrNs, &output.Hash) + if err != nil { + return err + } + + // Outputs with fewer confirmations than the minimum are excluded. + confs := confirms(output.Height, tipHeight) + if confs < minconf { + continue + } + + // Only mature coinbase outputs are included. + if output.FromCoinBase { + if !coinbaseMatured(w.chainParams, output.Height, tipHeight) { + continue + } + } + + switch details.TxRecord.TxType { + case stake.TxTypeSStx: + // Ticket commitment, not spendable by regular transactions. + if output.Index == 0 { + continue + } + // Change outputs. + if (output.Index > 0) && (output.Index%2 == 0) { + if !ticketChangeMatured(w.chainParams, details.Height(), tipHeight) { + continue + } + } + case stake.TxTypeSSGen: + // All non-OP_RETURN outputs for SSGen tx are only spendable + // after coinbase maturity many blocks. + fallthrough + case stake.TxTypeSSRtx: + // All outputs for SSRtx tx are only spendable + // after coinbase maturity many blocks. + fallthrough + case stake.TxTypeTSpend: + // All non-OP_RETURN outputs for TGen tx are only spendable + // after coinbase maturity many blocks. + if !coinbaseMatured(w.chainParams, details.Height(), tipHeight) { + continue + } + } + + // Exclude locked outputs from the result set. + if w.LockedOutpoint(&output.OutPoint.Hash, output.OutPoint.Index) { + continue + } + + // Lookup the associated account for the output. Use the + // default account name in case there is no associated account + // for some reason, although this should never happen. + // + // This will be unnecessary once transactions and outputs are + // grouped under the associated account in the db. + acctName := defaultAccountName + sc, addrs, _, err := txscript.ExtractPkScriptAddrs( + 0, output.PkScript, w.chainParams, true) // Yes treasury + if err != nil { + continue + } + if len(addrs) > 0 { + acct, err := w.manager.AddrAccount(addrmgrNs, addrs[0]) + if err == nil { + var s string + s, err = w.manager.AccountName(addrmgrNs, acct) + if err == nil { + acctName = s + } + } + if err != nil && !errors.Is(err, errors.NotExist) { + return err + } + + if inputMethod == types.UniqueTxInputSelection { + // skip duplicate address + _, skipAddress := skipTxAddress[addrs[0].String()] + if skipAddress { + log.Trace("Skipping duplicate address:", addrs[0].String()) + continue + } + } + } + + if accountName != "" && accountName != acctName { + continue + } + + if randomUnspent != nil { // random address input selection + for _, addr := range addrs { + if randomUnspent.Address == addr.String() { + goto include + } + } + + continue + } + + include: + // At the moment watch-only addresses are not supported, so all + // recorded outputs that are not multisig are "spendable". + // Multisig outputs are only "spendable" if all keys are + // controlled by this wallet. + // + // TODO: Each case will need updates when watch-only addrs + // is added. For P2PK, P2PKH, and P2SH, the address must be + // looked up and not be watching-only. For multisig, all + // pubkeys must belong to the manager with the associated + // private key (currently it only checks whether the pubkey + // exists, since the private key is required at the moment). + var spendable bool + var redeemScript []byte + scSwitch: + switch sc { + case txscript.PubKeyHashTy: + spendable = true + case txscript.PubKeyTy: + spendable = true + case txscript.ScriptHashTy: + spendable = true + if len(addrs) != 1 { + return errors.Errorf("invalid address count for pay-to-script-hash output") + } + redeemScript, err = w.manager.RedeemScript(addrmgrNs, addrs[0]) + if err != nil { + return err + } + case txscript.StakeGenTy: + spendable = true + case txscript.StakeRevocationTy: + spendable = true + case txscript.StakeSubChangeTy: + spendable = true + case txscript.MultiSigTy: + for _, a := range addrs { + _, err := w.manager.Address(addrmgrNs, a) + if err == nil { + continue + } + if errors.Is(err, errors.NotExist) { + break scSwitch + } + return err + } + spendable = true + } + + if !spendable { + continue + } + + result := &types.ListUnspentResult{ + TxID: output.OutPoint.Hash.String(), + Vout: output.OutPoint.Index, + Tree: output.OutPoint.Tree, + Account: acctName, + ScriptPubKey: hex.EncodeToString(output.PkScript), + RedeemScript: hex.EncodeToString(redeemScript), + TxType: int(details.TxType), + Amount: output.Amount.ToCoin(), + Confirmations: int64(confs), + Spendable: spendable, + } + + if len(addrs) > 0 { + result.Address = addrs[0].String() + skipTxAddress[result.Address] = struct{}{} + } + results = append(results, result) + + currentTotal += output.Amount + skipTxAddress[result.TxID] = struct{}{} + + if inputMethod == types.RandomAddressInputSelection && randomUnspent == nil { + randomUnspent = result + } + + if inputMethod == types.OneUTXOInputSelection { + return nil + } else if currentTotal >= targetAmount { + if spendAll { + continue + } + + return nil + } + } + + if inputMethod == types.RandomAddressInputSelection { + return fmt.Errorf("insufficient balance, selected address does not have enough utxos to pay %s", targetAmount) + } else if inputMethod == types.OneUTXOInputSelection { + return fmt.Errorf("insufficient balance, no utxo is available to pay %s", targetAmount) + } + + return nil + }) + + if err != nil { + return nil, errors.E(op, err) + } + return results, nil +} + func (w *Wallet) LoadPrivateKey(ctx context.Context, addr stdaddr.Address) (key *secp256k1.PrivateKey, zero func(), err error) {