From fab92257fe6f7414af52f235c83d55092a4a81d8 Mon Sep 17 00:00:00 2001 From: woltx <94266259+w0xlt@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:18:23 -0700 Subject: [PATCH 1/5] doc, rpc: document enumerate model field and fingerprint deduplication The external signer "enumerate" response uses the optional "model" field, not "name". Document that Bitcoin Core maps it to the "name" field of the enumeratesigners RPC result, and that signers with duplicate master key fingerprints are skipped. Also document that wallet operations require exactly one connected signer. Co-authored-by: optout <13562139+optout21@users.noreply.github.com> Co-authored-by: naiyoma --- doc/external-signer.md | 8 ++++++-- src/rpc/external_signer.cpp | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/doc/external-signer.md b/doc/external-signer.md index ee1b68bb89f..d777871d7da 100644 --- a/doc/external-signer.md +++ b/doc/external-signer.md @@ -125,13 +125,15 @@ Usage: [ { "fingerprint": "00000000", - "name": "trezor_t" + "model": "trezor_t" } ] ``` The command MUST return an array, possibly empty, of entries that contain at least a `fingerprint` field. +If present, the optional `model` field is used as the device name in the `enumeratesigners` RPC result. + A future extension could add an optional return field with device capabilities. Perhaps a descriptor with wildcards. For example: `["pkh("44'/0'/$'/{0,1}/*"), sh(wpkh("49'/0'/$'/{0,1}/*")), wpkh("84'/0'/$'/{0,1}/*")]`. This would indicate the device supports legacy, wrapped SegWit and native SegWit. In addition it restricts the derivation paths that can used for those, to maintain compatibility with other wallet software. It also indicates the device, or the driver, doesn't support multisig. A future extension could add an optional return field `reachable`, in case `` knows a signer exists but can't currently reach it. @@ -204,7 +206,9 @@ The command MAY complain if `--chain` is set to a test-network, but the BIP32 co ## How Bitcoin Core uses the Signer API -The `enumeratesigners` RPC simply calls ` enumerate`. +The `enumeratesigners` RPC calls ` enumerate`, skips duplicate entries with the same `fingerprint`, and maps the optional `model` field to the RPC `name` field. + +Wallet operations that need a signer (`createwallet`, `walletdisplayaddress` and spending) also call ` enumerate` and fail unless exactly one signer is found, so only one device should be connected at a time. The `createwallet` RPC calls: diff --git a/src/rpc/external_signer.cpp b/src/rpc/external_signer.cpp index 08226ffb431..bc10a9c2c22 100644 --- a/src/rpc/external_signer.cpp +++ b/src/rpc/external_signer.cpp @@ -20,7 +20,7 @@ static RPCMethod enumeratesigners() { return RPCMethod{"enumeratesigners", - "Returns a list of external signers from -signer.", + "Returns a list of external signers from -signer. Signers with duplicate master key fingerprints are skipped.", {}, RPCResult{ RPCResult::Type::OBJ, "", "", @@ -30,7 +30,7 @@ static RPCMethod enumeratesigners() {RPCResult::Type::OBJ, "", "", { {RPCResult::Type::STR_HEX, "fingerprint", "Master key fingerprint"}, - {RPCResult::Type::STR, "name", "Device name"}, + {RPCResult::Type::STR, "name", "Device name, the model returned by the signer"}, }}, }, } From 4fdd4d8d29f15cc0b2e76443733a74a41070e681 Mon Sep 17 00:00:00 2001 From: woltx <94266259+w0xlt@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:18:55 -0700 Subject: [PATCH 2/5] doc: replace stale signtransaction wording with current signtx flow The protocol documentation still described a "signtransaction" command driven by sendtoaddress and sendmany. Those RPCs never gained external signer support: it was effectively precluded by #21201, which was merged a few days before external signer support landed in #16546, so the interaction was missed in review (#33112 has a commit making the rejection explicit). Spending from an external signer wallet uses send/sendall (and bumpfee for fee-bumping), which invoke the signer with ` --stdin` and pass the `signtx` subcommand and PSBT over stdin. Update the spending example and the protocol description accordingly, using `bitcoin rpc` for the example since it enables -named by default. --- doc/external-signer.md | 6 ++---- src/external_signer.h | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/doc/external-signer.md b/doc/external-signer.md index d777871d7da..de5e5b5aba8 100644 --- a/doc/external-signer.md +++ b/doc/external-signer.md @@ -67,7 +67,7 @@ Replace `
` with the result of `getnewaddress`. Under the hood this uses a [PSBT (Partially Signed Bitcoin Transaction)](psbt.md). ```sh -bitcoin-cli -rpcwallet= sendtoaddress
+bitcoin rpc -rpcwallet= send outputs='{"
": }' ``` This constructs a PSBT and prompts your external signer to sign (will fail if it's not connected). If successful, Bitcoin Core finalizes and broadcasts the transaction. @@ -218,6 +218,4 @@ It then imports descriptors for all supported address types, in a BIP44/49/84/86 The `walletdisplayaddress` RPC reuses some code from `getaddressinfo` on the provided address and obtains the inferred descriptor. It then calls ` --fingerprint=00000000 displayaddress --desc=`. -For external-signer wallets, spending uses `send` or `sendall`. Bitcoin Core builds a PSBT, calls the signer via stdin with `signtx`, and if signatures are sufficient, finalizes and broadcasts the transaction. If the signer is not connected or cancels, the call fails with an error. For fee-bumping on such wallets, use `psbtbumpfee` to involve an external signer. - -`sendtoaddress` and `sendmany` check `inputs->bip32_derivs` to see if any inputs have the same `master_fingerprint` as the signer. If so, it calls ` --fingerprint=00000000 signtransaction `. It waits for the device to return a (partially) signed psbt, tries to finalize it and broadcasts the transaction. +For external-signer wallets, spending uses `send` or `sendall`, and fee-bumping uses `bumpfee`. Bitcoin Core builds a PSBT, adds key origin information, checks whether any input key origin fingerprint matches the signer, calls ` --stdin --fingerprint 00000000 --chain `, and sends `signtx ` over stdin. If signatures are sufficient, it finalizes the transaction and, for broadcasting RPCs, broadcasts it. If signing cannot complete, the call fails with an error. For manual fee-bumping, use `psbtbumpfee` to obtain a PSBT for signing. diff --git a/src/external_signer.h b/src/external_signer.h index 87fbbf0b8c2..a6f16e8e8ac 100644 --- a/src/external_signer.h +++ b/src/external_signer.h @@ -58,7 +58,8 @@ public: UniValue GetDescriptors(int account); //! Sign PartiallySignedTransaction on the device. - //! Calls ` signtransaction` and passes the PSBT via stdin. + //! Calls ` --stdin --fingerprint --chain ` and passes the + //! `signtx` command and PSBT via stdin. //! @param[in,out] psbt PartiallySignedTransaction to be signed bool SignTransaction(PartiallySignedTransaction& psbt, std::string& error); }; From 7131c82937835bdbe83bd64f784d8a265d12caab Mon Sep 17 00:00:00 2001 From: woltx <94266259+w0xlt@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:19:32 -0700 Subject: [PATCH 3/5] doc: clarify which commands receive --chain, --fingerprint and --stdin Bitcoin Core passes --chain and --fingerprint to every signer command except enumerate, so mark them "(required except for enumerate)" and name the current commands only as examples. Keep --stdin documented as required for all commands so the interface stays flexible, noting that Bitcoin Core currently only uses it for signtx. Add the missing flags to the getdescriptors and displayaddress usage examples and the corresponding doxygen comments, matching the order and form of the actual invocations, add testnet4 to the chain name lists, and drop the getaddressinfo implementation detail from the walletdisplayaddress description. --- doc/external-signer.md | 25 ++++++++++++++----------- src/external_signer.h | 10 ++++++---- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/doc/external-signer.md b/doc/external-signer.md index de5e5b5aba8..174ccb2f6e5 100644 --- a/doc/external-signer.md +++ b/doc/external-signer.md @@ -84,13 +84,17 @@ Prerequisite knowledge: * [Output Descriptors](descriptors.md) * Partially Signed Bitcoin Transaction ([PSBT](psbt.md)) -### Flag `--chain ` (required) +### Flag `--chain ` (required except for `enumerate`) With `` one of `main`, `test`, `signet`, `regtest`, `testnet4`. +Bitcoin Core passes this flag to commands that operate on a chain-specific signer, for example `getdescriptors`, `displayaddress` and `signtx`. + ### Flag `--stdin` (required) -Indicate that (sub)command should be received over stdin and results returned in response to that. `--stdin` is a global flag, it is not used for all subcommands. +Indicate that (sub)command should be received over stdin and results returned in response to that. `--stdin` is a global flag, it is not specific to any subcommand. + +Bitcoin Core currently uses this flag for `signtx`. All subcommands SHOULD support both - being called as commandline arguments; or @@ -107,11 +111,11 @@ Usage: Note: remember that shell-expansion is not available on _stdin_. Consequently, commands such as `signtx`, may write their arguments in either quoted or unquoted form. -### Flag `--fingerprint ` (required) +### Flag `--fingerprint ` (required except for `enumerate`) With `` being the hexadecimal 8-symbol identifier for a wallet. -Commands will specify a fingerprint as an identifier for external-signer wallet. +Bitcoin Core passes this flag to commands that operate on a specific external-signer wallet, for example `getdescriptors`, `displayaddress` and `signtx`. ### `enumerate` (required) @@ -155,14 +159,13 @@ The command MAY complain if `--chain` is set to a test-network, but any of the B Usage: ```sh - --fingerprint= getdescriptors - + --fingerprint --chain getdescriptors --account ``` Returns descriptors supported by the device. Example: ```sh - --fingerprint=00000000 getdescriptors + --fingerprint 00000000 --chain main getdescriptors --account 0 ``` ``` @@ -184,13 +187,13 @@ Returns descriptors supported by the device. Example: Usage: ```sh - --fingerprint= displayaddress --desc + --fingerprint --chain displayaddress --desc ``` Example, display the first native SegWit receive address on Testnet: ```sh - --chain test --fingerprint=00000000 displayaddress --desc "wpkh([00000000/84h/1h/0h]tpubDDUZ..../0/0)" + --fingerprint 00000000 --chain test displayaddress --desc "wpkh([00000000/84h/1h/0h]tpubDDUZ..../0/0)" ``` The command MUST be able to figure out the address type from the descriptor. @@ -212,10 +215,10 @@ Wallet operations that need a signer (`createwallet`, `walletdisplayaddress` and The `createwallet` RPC calls: -* ` --chain --fingerprint=00000000 getdescriptors --account 0` +* ` --fingerprint 00000000 --chain getdescriptors --account 0` It then imports descriptors for all supported address types, in a BIP44/49/84/86 compatible manner. -The `walletdisplayaddress` RPC reuses some code from `getaddressinfo` on the provided address and obtains the inferred descriptor. It then calls ` --fingerprint=00000000 displayaddress --desc=`. +The `walletdisplayaddress` RPC obtains the inferred descriptor for the provided address. It then calls ` --fingerprint 00000000 --chain displayaddress --desc `. For external-signer wallets, spending uses `send` or `sendall`, and fee-bumping uses `bumpfee`. Bitcoin Core builds a PSBT, adds key origin information, checks whether any input key origin fingerprint matches the signer, calls ` --stdin --fingerprint 00000000 --chain `, and sends `signtx ` over stdin. If signatures are sufficient, it finalizes the transaction and, for broadcasting RPCs, broadcasts it. If signing cannot complete, the call fails with an error. For manual fee-bumping, use `psbtbumpfee` to obtain a PSBT for signing. diff --git a/src/external_signer.h b/src/external_signer.h index a6f16e8e8ac..5131a2078f3 100644 --- a/src/external_signer.h +++ b/src/external_signer.h @@ -29,7 +29,7 @@ private: public: //! @param[in] command the command which handles interaction with the external signer //! @param[in] fingerprint master key fingerprint of the signer - //! @param[in] chain "main", "test", "regtest" or "signet" + //! @param[in] chain "main", "test", "signet", "regtest" or "testnet4" //! @param[in] name device name ExternalSigner(std::vector command, std::string chain, std::string fingerprint, std::string name); @@ -42,17 +42,19 @@ public: //! Obtain a list of signers. Calls ` enumerate`. //! @param[in] command the command which handles interaction with the external signer //! @param[in,out] signers vector to which new signers (with a unique master key fingerprint) are added - //! @param chain "main", "test", "regtest" or "signet" + //! @param chain "main", "test", "signet", "regtest" or "testnet4" //! @returns success static bool Enumerate(const std::string& command, std::vector& signers, const std::string& chain); - //! Display address on the device. Calls ` displayaddress --desc `. + //! Display address on the device. Calls ` --fingerprint --chain + //! displayaddress --desc `. //! @param[in] descriptor Descriptor specifying which address to display. //! Must include a public key or xpub, as well as key origin. UniValue DisplayAddress(const std::string& descriptor) const; //! Get receive and change Descriptor(s) from device for a given account. - //! Calls ` getdescriptors --account ` + //! Calls ` --fingerprint --chain getdescriptors + //! --account `. //! @param[in] account which BIP32 account to use (e.g. `m/44'/0'/account'`) //! @returns see doc/external-signer.md UniValue GetDescriptors(int account); From bd5a32f7db29c0f223c674cdd24cf04f94fda096 Mon Sep 17 00:00:00 2001 From: woltx <94266259+w0xlt@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:19:46 -0700 Subject: [PATCH 4/5] doc: add taproot descriptor to getdescriptors example Wallets import descriptors for all supported address types in a BIP44/49/84/86 compatible manner, so show the BIP86 tr() descriptor in the example response as well. --- doc/external-signer.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/external-signer.md b/doc/external-signer.md index 174ccb2f6e5..8b02edd6b8a 100644 --- a/doc/external-signer.md +++ b/doc/external-signer.md @@ -173,12 +173,14 @@ Returns descriptors supported by the device. Example: "receive": [ "pkh([00000000/44h/0h/0h]xpub6C.../0/*)#fn95jwmg", "sh(wpkh([00000000/49h/0h/0h]xpub6B..../0/*))#j4r9hntt", - "wpkh([00000000/84h/0h/0h]xpub6C.../0/*)#qw72dxa9" + "wpkh([00000000/84h/0h/0h]xpub6C.../0/*)#qw72dxa9", + "tr([00000000/86h/0h/0h]xpub6C.../0/*)#4d8tq2ns" ], "internal": [ "pkh([00000000/44h/0h/0h]xpub6C.../1/*)#c8q40mts", "sh(wpkh([00000000/49h/0h/0h]xpub6B..../1/*))#85dn0v75", - "wpkh([00000000/84h/0h/0h]xpub6C..../1/*)#36mtsnda" + "wpkh([00000000/84h/0h/0h]xpub6C..../1/*)#36mtsnda", + "tr([00000000/86h/0h/0h]xpub6C.../1/*)#d63h6jpt" ] } ``` From 2fe34808faaac41cca228b8795539b567908ad51 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Wed, 6 May 2026 11:25:23 +0200 Subject: [PATCH 5/5] wallet: reject sendtoaddress and sendmany for external signers The sendtoaddress and sendmany RPCs always go through SendMoney(), which expects to sign internally. External signer wallets should use the PSBT flow instead, via the send RPC. Return a more specific error for external signer wallets and add functional test coverage for both RPCs. --- src/wallet/rpc/spend.cpp | 6 +++++- test/functional/wallet_signer.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/wallet/rpc/spend.cpp b/src/wallet/rpc/spend.cpp index b6cdc8600f3..d1f838c9373 100644 --- a/src/wallet/rpc/spend.cpp +++ b/src/wallet/rpc/spend.cpp @@ -173,7 +173,11 @@ UniValue SendMoney(CWallet& wallet, const CCoinControl &coin_control, std::vecto EnsureWalletIsUnlocked(wallet); // This function is only used by sendtoaddress and sendmany. - // This should always try to sign, if we don't have private keys, don't try to do anything here. + // This should always try to sign, if we don't have (all) private keys, don't + // try to do anything here. + if (wallet.IsWalletFlagSet(WALLET_FLAG_EXTERNAL_SIGNER)) { + throw JSONRPCError(RPC_WALLET_ERROR, "Error: sendtoaddress and sendmany are not supported for wallets with external signers; use send instead"); + } if (wallet.IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) { throw JSONRPCError(RPC_WALLET_ERROR, "Error: Private keys are disabled for this wallet"); } diff --git a/test/functional/wallet_signer.py b/test/functional/wallet_signer.py index 4b8e63887a3..896169d4276 100755 --- a/test/functional/wallet_signer.py +++ b/test/functional/wallet_signer.py @@ -120,6 +120,21 @@ class WalletSignerTest(BitcoinTestFramework): result = hww.walletdisplayaddress(address) assert_equal(result, {"address": address}) + assert_raises_rpc_error( + -4, + "Error: sendtoaddress and sendmany are not supported for wallets with external signers; use send instead", + hww.sendtoaddress, + self.nodes[0].getnewaddress(), + 0.01, + ) + assert_raises_rpc_error( + -4, + "Error: sendtoaddress and sendmany are not supported for wallets with external signers; use send instead", + hww.sendmany, + "", + {self.nodes[0].getnewaddress(): 0.01}, + ) + # Handle error thrown by script self.set_mock_result(self.nodes[1], "2") assert_raises_rpc_error(-1, 'RunCommandParseJSON error',