Skip to content

Commit ad140d2

Browse files
committed
Implement spendoutputs JSON-RPC method
This method provides greater coin control when creating and sending transactions. Rather than the wallet performing UTXO selection on its own, only the specified inputs and outputs (with the exception of wallet change) will be used. This is intended to be a more ergonomic and easier to use alternative to the raw transaction RPCs (createrawtransaction, signrawtransaction, and sendrawtransaction).
1 parent 7e5c5f2 commit ad140d2

File tree

7 files changed

+261
-9
lines changed

7 files changed

+261
-9
lines changed

internal/rpc/jsonrpc/methods.go

Lines changed: 165 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ import (
5555

5656
// API version constants
5757
const (
58-
jsonrpcSemverString = "9.1.0"
58+
jsonrpcSemverString = "9.2.0"
5959
jsonrpcSemverMajor = 9
60-
jsonrpcSemverMinor = 1
60+
jsonrpcSemverMinor = 2
6161
jsonrpcSemverPatch = 0
6262
)
6363

@@ -170,6 +170,7 @@ var handlers = map[string]handler{
170170
"signmessage": {fn: (*Server).signMessage},
171171
"signrawtransaction": {fn: (*Server).signRawTransaction},
172172
"signrawtransactions": {fn: (*Server).signRawTransactions},
173+
"spendoutputs": {fn: (*Server).spendOutputs},
173174
"stakepooluserinfo": {fn: (*Server).stakePoolUserInfo},
174175
"sweepaccount": {fn: (*Server).sweepAccount},
175176
"syncstatus": {fn: (*Server).syncStatus},
@@ -4180,6 +4181,168 @@ func (s *Server) rescanWallet(ctx context.Context, icmd any) (any, error) {
41804181
return nil, err
41814182
}
41824183

4184+
// spendOutputsInputSource creates an input source from a wallet and a list of
4185+
// outputs to be spent. Only the provided outputs will be returned by the
4186+
// source, without any other input selection.
4187+
func spendOutputsInputSource(ctx context.Context, w *wallet.Wallet,
4188+
account string, inputs []*wire.TxIn) (txauthor.InputSource, error) {
4189+
4190+
params := w.ChainParams()
4191+
4192+
detail := new(txauthor.InputDetail)
4193+
detail.Inputs = inputs
4194+
detail.Scripts = make([][]byte, len(inputs))
4195+
detail.RedeemScriptSizes = make([]int, len(inputs))
4196+
for i, in := range inputs {
4197+
prevOut, err := w.FetchOutput(ctx, &in.PreviousOutPoint)
4198+
if err != nil {
4199+
return nil, err
4200+
}
4201+
detail.Amount += dcrutil.Amount(prevOut.Value)
4202+
detail.Scripts[i] = prevOut.PkScript
4203+
st, addrs := stdscript.ExtractAddrs(prevOut.Version,
4204+
prevOut.PkScript, params)
4205+
var addr stdaddr.Address
4206+
var redeemScriptSize int
4207+
switch st {
4208+
case stdscript.STPubKeyHashEcdsaSecp256k1:
4209+
addr = addrs[0]
4210+
redeemScriptSize = txsizes.RedeemP2PKHInputSize
4211+
default:
4212+
// XXX: don't assume P2PKH, support other script types
4213+
return nil, errors.E("unsupport address type")
4214+
}
4215+
ka, err := w.KnownAddress(ctx, addr)
4216+
if err != nil {
4217+
return nil, err
4218+
}
4219+
if ka.AccountName() != account {
4220+
err := errors.Errorf("output address of %v does not "+
4221+
"belong to account %q", &in.PreviousOutPoint,
4222+
account)
4223+
return nil, errors.E(errors.Invalid, err)
4224+
}
4225+
detail.RedeemScriptSizes[i] = redeemScriptSize
4226+
}
4227+
4228+
fn := func(target dcrutil.Amount) (*txauthor.InputDetail, error) {
4229+
return detail, nil
4230+
}
4231+
return fn, nil
4232+
}
4233+
4234+
type accountChangeSource struct {
4235+
ctx context.Context
4236+
wallet *wallet.Wallet
4237+
account uint32
4238+
addr stdaddr.Address
4239+
}
4240+
4241+
func (a *accountChangeSource) Script() (script []byte, version uint16, err error) {
4242+
if a.addr == nil {
4243+
addr, err := a.wallet.NewChangeAddress(a.ctx, a.account)
4244+
if err != nil {
4245+
return nil, 0, err
4246+
}
4247+
a.addr = addr
4248+
}
4249+
4250+
version, script = a.addr.PaymentScript()
4251+
return
4252+
}
4253+
4254+
func (a *accountChangeSource) ScriptSize() int {
4255+
// XXX: shouldn't assume P2PKH
4256+
return txsizes.P2PKHOutputSize
4257+
}
4258+
4259+
// spendOutputs creates, signs and publishes a transaction that spends the
4260+
// specified outputs belonging to an account, pays a list of address/amount
4261+
// pairs, with any change returned to the specified account.
4262+
func (s *Server) spendOutputs(ctx context.Context, icmd any) (any, error) {
4263+
cmd := icmd.(*types.SpendOutputsCmd)
4264+
w, ok := s.walletLoader.LoadedWallet()
4265+
if !ok {
4266+
return nil, errUnloadedWallet
4267+
}
4268+
n, err := w.NetworkBackend()
4269+
if err != nil {
4270+
return nil, err
4271+
}
4272+
4273+
params := w.ChainParams()
4274+
4275+
account, err := w.AccountNumber(ctx, cmd.Account)
4276+
if err != nil {
4277+
return nil, err
4278+
}
4279+
4280+
inputs := make([]*wire.TxIn, 0, len(cmd.PreviousOutpoints))
4281+
outputs := make([]*wire.TxOut, 0, len(cmd.Outputs))
4282+
for _, outpointStr := range cmd.PreviousOutpoints {
4283+
op, err := parseOutpoint(outpointStr)
4284+
if err != nil {
4285+
return nil, rpcError(dcrjson.ErrRPCInvalidParameter, err)
4286+
}
4287+
inputs = append(inputs, wire.NewTxIn(op, wire.NullValueIn, nil))
4288+
}
4289+
for _, output := range cmd.Outputs {
4290+
addr, err := stdaddr.DecodeAddress(output.Address, params)
4291+
if err != nil {
4292+
return nil, err
4293+
}
4294+
amount, err := dcrutil.NewAmount(output.Amount)
4295+
if err != nil {
4296+
return nil, rpcError(dcrjson.ErrRPCInvalidParameter, err)
4297+
}
4298+
scriptVersion, script := addr.PaymentScript()
4299+
txOut := wire.NewTxOut(int64(amount), script)
4300+
txOut.Version = scriptVersion
4301+
outputs = append(outputs, txOut)
4302+
}
4303+
wallet.Shuffle(len(inputs), func(i, j int) {
4304+
inputs[i], inputs[j] = inputs[j], inputs[i]
4305+
})
4306+
wallet.Shuffle(len(outputs), func(i, j int) {
4307+
outputs[i], outputs[j] = outputs[j], outputs[i]
4308+
})
4309+
4310+
inputSource, err := spendOutputsInputSource(ctx, w, cmd.Account,
4311+
inputs)
4312+
if err != nil {
4313+
return nil, err
4314+
}
4315+
4316+
changeSource := &accountChangeSource{
4317+
ctx: ctx,
4318+
wallet: w,
4319+
account: account,
4320+
}
4321+
4322+
secretsSource, err := w.SecretsSource()
4323+
if err != nil {
4324+
return nil, err
4325+
}
4326+
defer secretsSource.Close()
4327+
4328+
atx, err := txauthor.NewUnsignedTransaction(outputs, w.RelayFee(),
4329+
inputSource, changeSource, params.MaxTxSize)
4330+
if err != nil {
4331+
return nil, err
4332+
}
4333+
atx.RandomizeChangePosition()
4334+
err = atx.AddAllInputScripts(secretsSource)
4335+
if err != nil {
4336+
return nil, err
4337+
}
4338+
4339+
hash, err := w.PublishTransaction(ctx, atx.Tx, n)
4340+
if err != nil {
4341+
return nil, err
4342+
}
4343+
return hash.String(), nil
4344+
}
4345+
41834346
// stakePoolUserInfo returns the ticket information for a given user from the
41844347
// stake pool.
41854348
func (s *Server) stakePoolUserInfo(ctx context.Context, icmd any) (any, error) {

internal/rpc/jsonrpc/rpcserverhelp.go

Lines changed: 3 additions & 2 deletions
Large diffs are not rendered by default.

internal/rpchelp/helpdescs_en_US.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,16 @@ var helpDescsEnUS = map[string]string{
869869
"signedtransaction-sent": "Tells if the transaction was sent.",
870870
"signedtransaction-signingresult": "Success or failure of signing.",
871871

872+
// SpendOutputsCmd help.
873+
"spendoutputs--synopsis": "Create, sign, and publish a transaction spending the specified wallet outputs, and paying an array of address/amount pairs.\n" +
874+
"Outputs must belong to the specified account, and change (if needed) is returned to an internal address of the same account.",
875+
"spendoutputs-account": "Account of specified previous outpoints, and account used to return change",
876+
"spendoutputs-previousoutpoints": `Array of outpoints in string encoding ("hash:index")`,
877+
"spendoutputs-outputs": "Array of JSON objects, each specifying an address string and amount",
878+
"spendoutputs--result0": "The published transaction hash",
879+
"addressamountpair-address": "Address to pay",
880+
"addressamountpair-amount": "Amount to pay the address",
881+
872882
// StakePoolUserInfoCmd help.
873883
"stakepooluserinfo--synopsis": "Get user info for stakepool",
874884
"stakepooluserinfo-user": "The id of the user to be looked up",

internal/rpchelp/methods.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ var Methods = []struct {
9090
{"lockunspent", returnsBool},
9191
{"mixaccount", nil},
9292
{"mixoutput", nil},
93-
{"purchaseticket", returnsString},
9493
{"processunmanagedticket", nil},
94+
{"purchaseticket", returnsString},
9595
{"redeemmultisigout", []any{(*types.RedeemMultiSigOutResult)(nil)}},
9696
{"redeemmultisigouts", []any{(*types.RedeemMultiSigOutResult)(nil)}},
9797
{"renameaccount", nil},
@@ -112,6 +112,7 @@ var Methods = []struct {
112112
{"signmessage", returnsString},
113113
{"signrawtransaction", []any{(*types.SignRawTransactionResult)(nil)}},
114114
{"signrawtransactions", []any{(*types.SignRawTransactionsResult)(nil)}},
115+
{"spendoutputs", returnsString},
115116
{"stakepooluserinfo", []any{(*types.StakePoolUserInfoResult)(nil)}},
116117
{"sweepaccount", []any{(*types.SweepAccountResult)(nil)}},
117118
{"syncstatus", []any{(*types.SyncStatusResult)(nil)}},

rpc/jsonrpc/types/methods.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,14 +1206,29 @@ type ProcessUnmanagedTicketCmd struct {
12061206
TicketHash *string
12071207
}
12081208

1209-
type registeredMethod struct {
1210-
method string
1211-
cmd any
1209+
// GetCoinjoinsByAcctCmd defines the getcoinjoinsbyaccount JSON-RPC command arguments.
1210+
type GetCoinjoinsByAcctCmd struct{}
1211+
1212+
// SpendOutputsCmd defines the spendoutputs JSON-RPC command arguments.
1213+
type SpendOutputsCmd struct {
1214+
Account string
1215+
PreviousOutpoints []string
1216+
Outputs []AddressAmountPair
12121217
}
12131218

1214-
type GetCoinjoinsByAcctCmd struct{}
1219+
// AddressAmountPair represents a JSON object defining an address and an
1220+
// amount.
1221+
type AddressAmountPair struct {
1222+
Address string `json:"address"`
1223+
Amount float64 `json:"amount"`
1224+
}
12151225

12161226
func init() {
1227+
type registeredMethod struct {
1228+
method string
1229+
cmd any
1230+
}
1231+
12171232
// Wallet-specific methods
12181233
register := []registeredMethod{
12191234
{"abandontransaction", (*AbandonTransactionCmd)(nil)},
@@ -1289,6 +1304,7 @@ func init() {
12891304
{"signmessage", (*SignMessageCmd)(nil)},
12901305
{"signrawtransaction", (*SignRawTransactionCmd)(nil)},
12911306
{"signrawtransactions", (*SignRawTransactionsCmd)(nil)},
1307+
{"spendoutputs", (*SpendOutputsCmd)(nil)},
12921308
{"stakepooluserinfo", (*StakePoolUserInfoCmd)(nil)},
12931309
{"sweepaccount", (*SweepAccountCmd)(nil)},
12941310
{"syncstatus", (*SyncStatusCmd)(nil)},

wallet/createtx.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,62 @@ func (s *secretSource) GetScript(addr stdaddr.Address) ([]byte, error) {
199199
return s.Manager.RedeemScript(s.addrmgrNs, addr)
200200
}
201201

202+
// SecretsSource is an implementation of txauthor.SecretsSource querying the
203+
// wallet's address manager.
204+
//
205+
// The Close method must be called after the SecretsSource usage is over.
206+
type SecretsSource struct {
207+
wallet *Wallet
208+
dbtx walletdb.ReadTx
209+
doneFuncs []func()
210+
}
211+
212+
// SecretsSource returns a txauthor.SecretsSource implementor using the wallet
213+
// as the backing store for keys and scripts.
214+
func (w *Wallet) SecretsSource() (*SecretsSource, error) {
215+
dbtx, err := w.db.BeginReadTx()
216+
if err != nil {
217+
return nil, err
218+
}
219+
return &SecretsSource{wallet: w, dbtx: dbtx}, nil
220+
}
221+
222+
// ChainParams returns the chain parameters.
223+
func (s *SecretsSource) ChainParams() *chaincfg.Params {
224+
return s.wallet.chainParams
225+
}
226+
227+
// GetKey provides the private key associated with an address.
228+
func (s *SecretsSource) GetKey(addr stdaddr.Address) (key []byte, sigType dcrec.SignatureType, compressed bool, err error) {
229+
addrmgrNs := s.dbtx.ReadBucket(waddrmgrNamespaceKey)
230+
privKey, done, err := s.wallet.manager.PrivateKey(addrmgrNs, addr)
231+
if err != nil {
232+
return
233+
}
234+
s.doneFuncs = append(s.doneFuncs, done)
235+
return privKey.Serialize(), dcrec.STEcdsaSecp256k1, true, nil
236+
}
237+
238+
// GetScript provides the redeem script for a P2SH address.
239+
func (s *SecretsSource) GetScript(addr stdaddr.Address) ([]byte, error) {
240+
addrmgrNs := s.dbtx.ReadBucket(waddrmgrNamespaceKey)
241+
return s.wallet.manager.RedeemScript(addrmgrNs, addr)
242+
}
243+
244+
// Close finishes the SecretsSource usage by releasing all secret key material
245+
// and closing the underlying database transaction.
246+
func (s *SecretsSource) Close() error {
247+
for _, f := range s.doneFuncs {
248+
f()
249+
}
250+
s.doneFuncs = nil
251+
err := s.dbtx.Rollback()
252+
if err == nil {
253+
s.dbtx = nil
254+
}
255+
return err
256+
}
257+
202258
// CreatedTx holds the state of a newly-created transaction and the change
203259
// output (if one was added).
204260
type CreatedTx struct {

wallet/rand.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,8 @@ func shuffle(n int, swap func(i, j int)) {
4545
swap(int(i), int(j))
4646
}
4747
}
48+
49+
// Shuffle cryptographically shuffles a total of n items.
50+
func Shuffle(n int, swap func(i, j int)) {
51+
shuffle(n, swap)
52+
}

0 commit comments

Comments
 (0)