Skip to content

Commit d4b0107

Browse files
committed
rpc: send: support external signer
1 parent 245b445 commit d4b0107

10 files changed

+180
-5
lines changed

src/util/error.cpp

+4
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ bilingual_str TransactionErrorString(const TransactionError err)
3131
return Untranslated("Specified sighash value does not match value stored in PSBT");
3232
case TransactionError::MAX_FEE_EXCEEDED:
3333
return Untranslated("Fee exceeds maximum configured by user (e.g. -maxtxfee, maxfeerate)");
34+
case TransactionError::EXTERNAL_SIGNER_NOT_FOUND:
35+
return Untranslated("External signer not found");
36+
case TransactionError::EXTERNAL_SIGNER_FAILED:
37+
return Untranslated("External signer failed to sign");
3438
// no default case, so the compiler can warn about missing cases
3539
}
3640
assert(false);

src/util/error.h

+2
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ enum class TransactionError {
3030
PSBT_MISMATCH,
3131
SIGHASH_MISMATCH,
3232
MAX_FEE_EXCEEDED,
33+
EXTERNAL_SIGNER_NOT_FOUND,
34+
EXTERNAL_SIGNER_FAILED,
3335
};
3436

3537
bilingual_str TransactionErrorString(const TransactionError error);

src/wallet/external_signer.cpp

+51
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
44

55
#include <chainparams.h>
6+
#include <core_io.h>
7+
#include <psbt.h>
8+
#include <util/strencodings.h>
9+
#include <util/system.h>
610
#include <wallet/external_signer.h>
711

812
ExternalSigner::ExternalSigner(const std::string& command, const std::string& fingerprint, std::string chain, std::string name): m_command(command), m_fingerprint(fingerprint), m_chain(chain), m_name(name) {}
@@ -65,4 +69,51 @@ UniValue ExternalSigner::GetDescriptors(int account)
6569
return RunCommandParseJSON(m_command + " --fingerprint \"" + m_fingerprint + "\"" + NetworkArg() + " getdescriptors --account " + strprintf("%d", account));
6670
}
6771

72+
bool ExternalSigner::SignTransaction(PartiallySignedTransaction& psbtx, std::string& error)
73+
{
74+
// Serialize the PSBT
75+
CDataStream ssTx(SER_NETWORK, PROTOCOL_VERSION);
76+
ssTx << psbtx;
77+
78+
// Check if signer fingerprint matches any input master key fingerprint
79+
bool match = false;
80+
for (unsigned int i = 0; i < psbtx.inputs.size(); ++i) {
81+
const PSBTInput& input = psbtx.inputs[i];
82+
for (auto entry : input.hd_keypaths) {
83+
if (m_fingerprint == strprintf("%08x", ReadBE32(entry.second.fingerprint))) match = true;
84+
}
85+
}
86+
87+
if (!match) {
88+
error = "Signer fingerprint " + m_fingerprint + " does not match any of the inputs:\n" + EncodeBase64(ssTx.str());
89+
return false;
90+
}
91+
92+
std::string command = m_command + " --stdin --fingerprint \"" + m_fingerprint + "\"" + NetworkArg();
93+
std::string stdinStr = "signtx \"" + EncodeBase64(ssTx.str()) + "\"";
94+
95+
const UniValue signer_result = RunCommandParseJSON(command, stdinStr);
96+
97+
if (find_value(signer_result, "error").isStr()) {
98+
error = find_value(signer_result, "error").get_str();
99+
return false;
100+
}
101+
102+
if (!find_value(signer_result, "psbt").isStr()) {
103+
error = "Unexpected result from signer";
104+
return false;
105+
}
106+
107+
PartiallySignedTransaction signer_psbtx;
108+
std::string signer_psbt_error;
109+
if (!DecodeBase64PSBT(signer_psbtx, find_value(signer_result, "psbt").get_str(), signer_psbt_error)) {
110+
error = strprintf("TX decode failed %s", signer_psbt_error);
111+
return false;
112+
}
113+
114+
psbtx = signer_psbtx;
115+
116+
return true;
117+
}
118+
68119
#endif

src/wallet/external_signer.h

+7
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
#include <univalue.h>
1111
#include <util/system.h>
1212

13+
struct PartiallySignedTransaction;
14+
1315
class ExternalSignerException : public std::runtime_error {
1416
public:
1517
using std::runtime_error::runtime_error;
@@ -60,6 +62,11 @@ class ExternalSigner
6062
//! @param[out] UniValue see doc/external-signer.md
6163
UniValue GetDescriptors(int account);
6264

65+
//! Sign PartiallySignedTransaction on the device.
66+
//! Calls `<command> signtransaction` and passes the PSBT via stdin.
67+
//! @param[in,out] psbt PartiallySignedTransaction to be signed
68+
bool SignTransaction(PartiallySignedTransaction& psbt, std::string& error);
69+
6370
#endif
6471
};
6572

src/wallet/external_signer_scriptpubkeyman.h

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ class ExternalSignerScriptPubKeyMan : public DescriptorScriptPubKeyMan
2626
static ExternalSigner GetExternalSigner();
2727

2828
bool DisplayAddress(const CScript scriptPubKey, const ExternalSigner &signer) const;
29+
30+
TransactionError FillPSBT(PartiallySignedTransaction& psbt, int sighash_type = 1 /* SIGHASH_ALL */, bool sign = true, bool bip32derivs = false, int* n_signed = nullptr) const override;
2931
};
3032
#endif
3133

src/wallet/rpcwallet.cpp

+4-2
Original file line numberDiff line numberDiff line change
@@ -4195,8 +4195,10 @@ static RPCHelpMan send()
41954195
// Make a blank psbt
41964196
PartiallySignedTransaction psbtx(rawTx);
41974197

4198-
// Fill transaction with our data and sign
4199-
bool complete = true;
4198+
// First fill transaction with our data without signing,
4199+
// so external signers are not asked sign more than once.
4200+
bool complete;
4201+
pwallet->FillPSBT(psbtx, complete, SIGHASH_ALL, false, true);
42004202
const TransactionError err = pwallet->FillPSBT(psbtx, complete, SIGHASH_ALL, true, false);
42014203
if (err != TransactionError::OK) {
42024204
throw JSONRPCTransactionError(err);

src/wallet/scriptpubkeyman.cpp

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
#include <util/system.h>
1414
#include <util/time.h>
1515
#include <util/translation.h>
16+
#include <wallet/external_signer.h>
1617
#include <wallet/scriptpubkeyman.h>
1718

1819
//! Value for the first BIP 32 hardened derivation. Can be used as a bit mask and as a value. See BIP 32 for more details.

src/wallet/wallet.cpp

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
#include <policy/policy.h>
2020
#include <primitives/block.h>
2121
#include <primitives/transaction.h>
22+
#include <psbt.h>
2223
#include <script/descriptor.h>
2324
#include <script/script.h>
2425
#include <script/signingprovider.h>

test/functional/mocks/signer.py

+26
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,25 @@ def displayaddress(args):
5151

5252
return sys.stdout.write(json.dumps({"address": "bcrt1qm90ugl4d48jv8n6e5t9ln6t9zlpm5th68x4f8g"}))
5353

54+
def signtx(args):
55+
if args.fingerprint != "00000001":
56+
return sys.stdout.write(json.dumps({"error": "Unexpected fingerprint", "fingerprint": args.fingerprint}))
57+
58+
with open(os.path.join(os.getcwd(), "mock_psbt"), "r", encoding="utf8") as f:
59+
mock_psbt = f.read()
60+
61+
if args.fingerprint == "00000001" :
62+
sys.stdout.write(json.dumps({
63+
"psbt": mock_psbt,
64+
"complete": True
65+
}))
66+
else:
67+
sys.stdout.write(json.dumps({"psbt": args.psbt}))
68+
5469
parser = argparse.ArgumentParser(prog='./signer.py', description='External signer mock')
5570
parser.add_argument('--fingerprint')
5671
parser.add_argument('--chain', default='main')
72+
parser.add_argument('--stdin', action='store_true')
5773

5874
subparsers = parser.add_subparsers(description='Commands', dest='command')
5975
subparsers.required = True
@@ -69,6 +85,16 @@ def displayaddress(args):
6985
parser_displayaddress.add_argument('--desc', metavar='desc')
7086
parser_displayaddress.set_defaults(func=displayaddress)
7187

88+
parser_signtx = subparsers.add_parser('signtx')
89+
parser_signtx.add_argument('psbt', metavar='psbt')
90+
91+
parser_signtx.set_defaults(func=signtx)
92+
93+
if not sys.stdin.isatty():
94+
buffer = sys.stdin.read()
95+
if buffer and buffer.rstrip() != "":
96+
sys.argv.extend(buffer.rstrip().split(" "))
97+
7298
args = parser.parse_args()
7399

74100
perform_pre_checks()

test/functional/wallet_signer.py

+82-3
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def mock_signer_path(self):
2525
return path
2626

2727
def set_test_params(self):
28-
self.num_nodes = 3
28+
self.num_nodes = 4
2929

3030
self.extra_args = [
3131
[],
@@ -54,7 +54,7 @@ def run_test(self):
5454

5555
# Handle script missing:
5656
assert_raises_rpc_error(-1, 'execve failed: No such file or directory',
57-
self.nodes[2].enumeratesigners
57+
self.nodes[3].enumeratesigners
5858
)
5959

6060
# Handle error thrown by script
@@ -100,7 +100,7 @@ def run_test(self):
100100
# )
101101
# self.clear_mock_result(self.nodes[1])
102102

103-
assert_equal(hww.getwalletinfo()["keypoolsize"], 3)
103+
assert_equal(hww.getwalletinfo()["keypoolsize"], 30)
104104

105105
address1 = hww.getnewaddress(address_type="bech32")
106106
assert_equal(address1, "bcrt1qm90ugl4d48jv8n6e5t9ln6t9zlpm5th68x4f8g")
@@ -134,5 +134,84 @@ def run_test(self):
134134
)
135135
self.clear_mock_result(self.nodes[1])
136136

137+
self.log.info('Prepare mock PSBT')
138+
self.nodes[0].sendtoaddress(address1, 1)
139+
self.nodes[0].generate(1)
140+
self.sync_all()
141+
142+
# Load private key into wallet to generate a signed PSBT for the mock
143+
self.nodes[1].createwallet(wallet_name="mock", disable_private_keys=False, blank=True, descriptors=True)
144+
mock_wallet = self.nodes[1].get_wallet_rpc("mock")
145+
assert mock_wallet.getwalletinfo()['private_keys_enabled']
146+
147+
result = mock_wallet.importdescriptors([{
148+
"desc": "wpkh([00000001/84'/1'/0']tprv8ZgxMBicQKsPd7Uf69XL1XwhmjHopUGep8GuEiJDZmbQz6o58LninorQAfcKZWARbtRtfnLcJ5MQ2AtHcQJCCRUcMRvmDUjyEmNUWwx8UbK/0/*)#rweraev0",
149+
"timestamp": 0,
150+
"range": [0,1],
151+
"internal": False,
152+
"active": True
153+
},
154+
{
155+
"desc": "wpkh([00000001/84'/1'/0']tprv8ZgxMBicQKsPd7Uf69XL1XwhmjHopUGep8GuEiJDZmbQz6o58LninorQAfcKZWARbtRtfnLcJ5MQ2AtHcQJCCRUcMRvmDUjyEmNUWwx8UbK/1/*)#j6uzqvuh",
156+
"timestamp": 0,
157+
"range": [0, 0],
158+
"internal": True,
159+
"active": True
160+
}])
161+
assert_equal(result[0], {'success': True})
162+
assert_equal(result[1], {'success': True})
163+
assert_equal(mock_wallet.getwalletinfo()["txcount"], 1)
164+
dest = self.nodes[0].getnewaddress(address_type='bech32')
165+
mock_psbt = mock_wallet.walletcreatefundedpsbt([], {dest:0.5}, 0, {}, True)['psbt']
166+
mock_psbt_signed = mock_wallet.walletprocesspsbt(psbt=mock_psbt, sign=True, sighashtype="ALL", bip32derivs=True)
167+
mock_psbt_final = mock_wallet.finalizepsbt(mock_psbt_signed["psbt"])
168+
mock_tx = mock_psbt_final["hex"]
169+
assert(mock_wallet.testmempoolaccept([mock_tx])[0]["allowed"])
170+
171+
# # Create a new wallet and populate with specific public keys, in order
172+
# # to work with the mock signed PSBT.
173+
# self.nodes[1].createwallet(wallet_name="hww4", disable_private_keys=True, descriptors=True, external_signer=True)
174+
# hww4 = self.nodes[1].get_wallet_rpc("hww4")
175+
#
176+
# descriptors = [{
177+
# "desc": "wpkh([00000001/84'/1'/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/*)#x30uthjs",
178+
# "timestamp": "now",
179+
# "range": [0, 1],
180+
# "internal": False,
181+
# "watchonly": True,
182+
# "active": True
183+
# },
184+
# {
185+
# "desc": "wpkh([00000001/84'/1'/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/1/*)#h92akzzg",
186+
# "timestamp": "now",
187+
# "range": [0, 0],
188+
# "internal": True,
189+
# "watchonly": True,
190+
# "active": True
191+
# }]
192+
193+
# result = hww4.importdescriptors(descriptors)
194+
# assert_equal(result[0], {'success': True})
195+
# assert_equal(result[1], {'success': True})
196+
assert_equal(hww.getwalletinfo()["txcount"], 1)
197+
198+
assert(hww.testmempoolaccept([mock_tx])[0]["allowed"])
199+
200+
with open(os.path.join(self.nodes[1].cwd, "mock_psbt"), "w", encoding="utf8") as f:
201+
f.write(mock_psbt_signed["psbt"])
202+
203+
self.log.info('Test send using hww1')
204+
205+
res = hww.send(outputs={dest:0.5},options={"add_to_wallet": False})
206+
assert(res["complete"])
207+
assert_equal(res["hex"], mock_tx)
208+
209+
# # Handle error thrown by script
210+
# self.set_mock_result(self.nodes[4], "2")
211+
# assert_raises_rpc_error(-1, 'Unable to parse JSON',
212+
# hww4.signerprocesspsbt, psbt_orig, "00000001"
213+
# )
214+
# self.clear_mock_result(self.nodes[4])
215+
137216
if __name__ == '__main__':
138217
SignerTest().main()

0 commit comments

Comments
 (0)