Skip to content

Commit 08f749c

Browse files
committed
Implement joinpsbts RPC and tests
Adds a joinpsbts RPC which combines multiple distinct PSBTs into one PSBT.
1 parent 7344a7b commit 08f749c

File tree

5 files changed

+115
-0
lines changed

5 files changed

+115
-0
lines changed

src/psbt.cpp

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,26 @@ bool PartiallySignedTransaction::IsSane() const
4242
return true;
4343
}
4444

45+
bool PartiallySignedTransaction::AddInput(const CTxIn& txin, PSBTInput& psbtin)
46+
{
47+
if (std::find(tx->vin.begin(), tx->vin.end(), txin) != tx->vin.end()) {
48+
return false;
49+
}
50+
tx->vin.push_back(txin);
51+
psbtin.partial_sigs.clear();
52+
psbtin.final_script_sig.clear();
53+
psbtin.final_script_witness.SetNull();
54+
inputs.push_back(psbtin);
55+
return true;
56+
}
57+
58+
bool PartiallySignedTransaction::AddOutput(const CTxOut& txout, const PSBTOutput& psbtout)
59+
{
60+
tx->vout.push_back(txout);
61+
outputs.push_back(psbtout);
62+
return true;
63+
}
64+
4565
bool PSBTInput::IsNull() const
4666
{
4767
return !non_witness_utxo && witness_utxo.IsNull() && partial_sigs.empty() && unknown.empty() && hd_keypaths.empty() && redeem_script.empty() && witness_script.empty();

src/psbt.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,8 @@ struct PartiallySignedTransaction
389389
* same actual Bitcoin transaction.) Returns true if the merge succeeded, false otherwise. */
390390
NODISCARD bool Merge(const PartiallySignedTransaction& psbt);
391391
bool IsSane() const;
392+
bool AddInput(const CTxIn& txin, PSBTInput& psbtin);
393+
bool AddOutput(const CTxOut& txout, const PSBTOutput& psbtout);
392394
PartiallySignedTransaction() {}
393395
PartiallySignedTransaction(const PartiallySignedTransaction& psbt_in) : tx(psbt_in.tx), inputs(psbt_in.inputs), outputs(psbt_in.outputs), unknown(psbt_in.unknown) {}
394396
explicit PartiallySignedTransaction(const CMutableTransaction& tx);

src/rpc/client.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
112112
{ "createpsbt", 2, "locktime" },
113113
{ "createpsbt", 3, "replaceable" },
114114
{ "combinepsbt", 0, "txs"},
115+
{ "joinpsbts", 0, "txs"},
115116
{ "finalizepsbt", 1, "extract"},
116117
{ "converttopsbt", 1, "permitsigdata"},
117118
{ "converttopsbt", 2, "iswitness"},

src/rpc/rawtransaction.cpp

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1755,6 +1755,80 @@ UniValue utxoupdatepsbt(const JSONRPCRequest& request)
17551755
return EncodeBase64((unsigned char*)ssTx.data(), ssTx.size());
17561756
}
17571757

1758+
UniValue joinpsbts(const JSONRPCRequest& request)
1759+
{
1760+
if (request.fHelp || request.params.size() != 1) {
1761+
throw std::runtime_error(
1762+
RPCHelpMan{"joinpsbts",
1763+
"\nJoins multiple distinct PSBTs with different inputs and outputs into one PSBT with inputs and outputs from all of the PSBTs\n"
1764+
"No input in any of the PSBTs can be in more than one of the PSBTs.\n",
1765+
{
1766+
{"txs", RPCArg::Type::ARR, RPCArg::Optional::NO, "A json array of base64 strings of partially signed transactions",
1767+
{
1768+
{"psbt", RPCArg::Type::STR, RPCArg::Optional::NO, "A base64 string of a PSBT"}
1769+
}}
1770+
},
1771+
RPCResult {
1772+
" \"psbt\" (string) The base64-encoded partially signed transaction\n"
1773+
},
1774+
RPCExamples {
1775+
HelpExampleCli("joinpsbts", "\"psbt\"")
1776+
}}.ToString());
1777+
}
1778+
1779+
RPCTypeCheck(request.params, {UniValue::VARR}, true);
1780+
1781+
// Unserialize the transactions
1782+
std::vector<PartiallySignedTransaction> psbtxs;
1783+
UniValue txs = request.params[0].get_array();
1784+
1785+
if (txs.size() <= 1) {
1786+
throw JSONRPCError(RPC_INVALID_PARAMETER, "At least two PSBTs are required to join PSBTs.");
1787+
}
1788+
1789+
int32_t best_version = 1;
1790+
uint32_t best_locktime = 0xffffffff;
1791+
for (unsigned int i = 0; i < txs.size(); ++i) {
1792+
PartiallySignedTransaction psbtx;
1793+
std::string error;
1794+
if (!DecodeBase64PSBT(psbtx, txs[i].get_str(), error)) {
1795+
throw JSONRPCError(RPC_DESERIALIZATION_ERROR, strprintf("TX decode failed %s", error));
1796+
}
1797+
psbtxs.push_back(psbtx);
1798+
// Choose the highest version number
1799+
if (psbtx.tx->nVersion > best_version) {
1800+
best_version = psbtx.tx->nVersion;
1801+
}
1802+
// Choose the lowest lock time
1803+
if (psbtx.tx->nLockTime < best_locktime) {
1804+
best_locktime = psbtx.tx->nLockTime;
1805+
}
1806+
}
1807+
1808+
// Create a blank psbt where everything will be added
1809+
PartiallySignedTransaction merged_psbt;
1810+
merged_psbt.tx = CMutableTransaction();
1811+
merged_psbt.tx->nVersion = best_version;
1812+
merged_psbt.tx->nLockTime = best_locktime;
1813+
1814+
// Merge
1815+
for (auto& psbt : psbtxs) {
1816+
for (unsigned int i = 0; i < psbt.tx->vin.size(); ++i) {
1817+
if (!merged_psbt.AddInput(psbt.tx->vin[i], psbt.inputs[i])) {
1818+
throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Input %s:%d exists in multiple PSBTs", psbt.tx->vin[i].prevout.hash.ToString().c_str(), psbt.tx->vin[i].prevout.n));
1819+
}
1820+
}
1821+
for (unsigned int i = 0; i < psbt.tx->vout.size(); ++i) {
1822+
merged_psbt.AddOutput(psbt.tx->vout[i], psbt.outputs[i]);
1823+
}
1824+
merged_psbt.unknown.insert(psbt.unknown.begin(), psbt.unknown.end());
1825+
}
1826+
1827+
CDataStream ssTx(SER_NETWORK, PROTOCOL_VERSION);
1828+
ssTx << merged_psbt;
1829+
return EncodeBase64((unsigned char*)ssTx.data(), ssTx.size());
1830+
}
1831+
17581832
// clang-format off
17591833
static const CRPCCommand commands[] =
17601834
{ // category name actor (function) argNames
@@ -1774,6 +1848,7 @@ static const CRPCCommand commands[] =
17741848
{ "rawtransactions", "createpsbt", &createpsbt, {"inputs","outputs","locktime","replaceable"} },
17751849
{ "rawtransactions", "converttopsbt", &converttopsbt, {"hexstring","permitsigdata","iswitness"} },
17761850
{ "rawtransactions", "utxoupdatepsbt", &utxoupdatepsbt, {"psbt"} },
1851+
{ "rawtransactions", "joinpsbts", &joinpsbts, {"txs"} },
17771852

17781853
{ "blockchain", "gettxoutproof", &gettxoutproof, {"txids", "blockhash"} },
17791854
{ "blockchain", "verifytxoutproof", &verifytxoutproof, {"proof"} },

test/functional/rpc_psbt.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,23 @@ def run_test(self):
321321
assert "witness_utxo" not in decoded['inputs'][1] and "non_witness_utxo" not in decoded['inputs'][1]
322322
assert "witness_utxo" not in decoded['inputs'][2] and "non_witness_utxo" not in decoded['inputs'][2]
323323

324+
# Two PSBTs with a common input should not be joinable
325+
psbt1 = self.nodes[1].createpsbt([{"txid":txid1, "vout":vout1}], {self.nodes[0].getnewaddress():Decimal('10.999')})
326+
assert_raises_rpc_error(-8, "exists in multiple PSBTs", self.nodes[1].joinpsbts, [psbt1, updated])
327+
328+
# Join two distinct PSBTs
329+
addr4 = self.nodes[1].getnewaddress("", "p2sh-segwit")
330+
txid4 = self.nodes[0].sendtoaddress(addr4, 5)
331+
vout4 = find_output(self.nodes[0], txid4, 5)
332+
self.nodes[0].generate(6)
333+
self.sync_all()
334+
psbt2 = self.nodes[1].createpsbt([{"txid":txid4, "vout":vout4}], {self.nodes[0].getnewaddress():Decimal('4.999')})
335+
psbt2 = self.nodes[1].walletprocesspsbt(psbt2)['psbt']
336+
psbt2_decoded = self.nodes[0].decodepsbt(psbt2)
337+
assert "final_scriptwitness" in psbt2_decoded['inputs'][0] and "final_scriptSig" in psbt2_decoded['inputs'][0]
338+
joined = self.nodes[0].joinpsbts([psbt, psbt2])
339+
joined_decoded = self.nodes[0].decodepsbt(joined)
340+
assert len(joined_decoded['inputs']) == 4 and len(joined_decoded['outputs']) == 2 and "final_scriptwitness" not in joined_decoded['inputs'][3] and "final_scriptSig" not in joined_decoded['inputs'][3]
324341

325342

326343
if __name__ == '__main__':

0 commit comments

Comments
 (0)