Skip to content
58 changes: 8 additions & 50 deletions doc/offline-signing-tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ In this tutorial we have two hosts, both running Bitcoin v25.0
* `offline` host which is disconnected from all networks (internet, Tor, wifi, bluetooth etc.) and does not have, or need, a copy of the blockchain.
* `online` host which is a regular online node with a synced blockchain.

We are going to first create an `offline_wallet` on the offline host. We will then create a `watch_only_wallet` on the online host using public key descriptors exported from the `offline_wallet`. Next we will receive some coins into the wallet. In order to spend these coins we'll create an unsigned PSBT using the `watch_only_wallet`, sign the PSBT using the private keys in the `offline_wallet`, and finally broadcast the signed PSBT using the online host.
We are going to first create an `offline_wallet` on the offline host. We will then create a `watch_only_wallet` on the online host using a wallet file exported from the `offline_wallet`. Next we will receive some coins into the wallet. In order to spend these coins we'll create an unsigned PSBT using the `watch_only_wallet`, sign the PSBT using the private keys in the `offline_wallet`, and finally broadcast the signed PSBT using the online host.

### Requirements
- [jq](https://jqlang.github.io/jq/) installation - This tutorial uses jq to process certain fields from JSON RPC responses, but this convenience is optional.
Expand All @@ -39,72 +39,30 @@ We are going to first create an `offline_wallet` on the offline host. We will th
> [!NOTE]
> The use of a passphrase is crucial to encrypt the wallet.dat file. This encryption ensures that even if an unauthorized individual gains access to the offline host, they won't be able to access the wallet's contents. Further details about securing your wallet can be found in [Managing the Wallet](https://github.com/bitcoin/bitcoin/blob/master/doc/managing-wallets.md#12-encrypting-the-wallet)

2. Export the public key-only descriptors from the offline host to a JSON file named `descriptors.json`. We use `jq` here to extract the `.descriptors` field from the full RPC response.

2. Export the wallet in a watch-only format to a .dat file named `watch_only_wallet.dat`.
```sh
[offline]$ ./build/bin/bitcoin-cli -signet -rpcwallet="offline_wallet" listdescriptors \
| jq -r '.descriptors' \
>> /path/to/descriptors.json
[offline]$ ./build/bin/bitcoin-cli -signet -named exportwatchonlywallet \
destination=/path/to/watch_only_wallet.dat
```

> [!NOTE]
> The `descriptors.json` file will be transferred to the online machine (e.g. using a USB flash drive) where it can be imported to create a related watch-only wallet.
> The `watch_only_wallet.dat` file will be transferred to the online machine (e.g. using a USB flash drive) where it can be imported to create a related watch-only wallet.

### Create the online `watch_only_wallet`

1. On the online machine create a blank watch-only wallet which has private keys disabled and is named `watch_only_wallet`. This is achieved by using the `createwallet` options: `disable_private_keys=true, blank=true`.

On the online machine import the watch-only wallet. This wallet will have the private keys disabled and is named `watch_only_wallet`. This is achieved by using the `restorewallet` rpc call.
The `watch_only_wallet` wallet will be used to track and validate incoming transactions, create unsigned PSBTs when spending coins, and broadcast signed and finalized PSBTs.

> [!NOTE]
> `disable_private_keys` indicates that the wallet should refuse to import private keys, i.e. will be a dedicated watch-only wallet.

```sh
[online]$ ./build/bin/bitcoin-cli -signet -named createwallet \
[online]$ ./build/bin/bitcoin-cli -signet -named restorewallet \
wallet_name="watch_only_wallet" \
disable_private_keys=true \
blank=true
backup_file=/path/to/watch_only_wallet.dat

{
"name": "watch_only_wallet"
}
```

2. Import the `offline_wallet`s public key descriptors to the online `watch_only_wallet` using the `descriptors.json` file created on the offline wallet.

```sh
[online]$ ./build/bin/bitcoin-cli -signet -rpcwallet="watch_only_wallet" importdescriptors "$(cat /path/to/descriptors.json)"

[
{
"success": true
},
{
"success": true
},
{
"success": true
},
{
"success": true
},
{
"success": true
},
{
"success": true
},
{
"success": true
},
{
"success": true
}
]
```
> [!NOTE]
> Multiple success values indicate that multiple descriptors, for different address types, have been successfully imported. This allows generating different address types on the `watch_only_wallet`.

### Fund the `offline_wallet`

At this point, it's important to understand that both the `offline_wallet` and online `watch_only_wallet` share the same public keys. As a result, they generate the same addresses. Transactions can be created using either wallet, but valid signatures can only be added by the `offline_wallet` as only it has the private keys.
Expand Down
3 changes: 3 additions & 0 deletions src/interfaces/wallet.h
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,9 @@ class Wallet

//! Return pointer to internal wallet class, useful for testing.
virtual wallet::CWallet* wallet() { return nullptr; }

//! Export a watchonly wallet file. See CWallet::ExportWatchOnlyWallet
virtual util::Result<std::string> exportWatchOnlyWallet(const fs::path& destination) = 0;
};

//! Wallet chain client that in addition to having chain client methods for
Expand Down
11 changes: 11 additions & 0 deletions src/qt/bitcoingui.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,10 @@ void BitcoinGUI::createActions()
m_mask_values_action->setStatusTip(tr("Mask the values in the Overview tab"));
m_mask_values_action->setCheckable(true);

m_export_watchonly_action = new QAction(tr("Export watch-only wallet"), this);
m_export_watchonly_action->setEnabled(false);
m_export_watchonly_action->setStatusTip(tr("Export a watch-only version of the current wallet that can be restored onto another node."));

connect(quitAction, &QAction::triggered, this, &BitcoinGUI::quitRequested);
connect(aboutAction, &QAction::triggered, this, &BitcoinGUI::aboutClicked);
connect(aboutQtAction, &QAction::triggered, qApp, QApplication::aboutQt);
Expand Down Expand Up @@ -488,6 +492,11 @@ void BitcoinGUI::createActions()
});
connect(m_mask_values_action, &QAction::toggled, this, &BitcoinGUI::setPrivacy);
connect(m_mask_values_action, &QAction::toggled, this, &BitcoinGUI::enableHistoryAction);
connect(m_export_watchonly_action, &QAction::triggered, [this] {
QString destination = GUIUtil::getSaveFileName(this, tr("Save Watch-only Wallet Export"), QString(), QString(), nullptr);
if (destination.isEmpty()) return;
walletFrame->currentWalletModel()->wallet().exportWatchOnlyWallet(GUIUtil::QStringToPath(destination));
});
}
#endif // ENABLE_WALLET

Expand All @@ -511,6 +520,7 @@ void BitcoinGUI::createMenuBar()
file->addSeparator();
file->addAction(backupWalletAction);
file->addAction(m_restore_wallet_action);
file->addAction(m_export_watchonly_action);
file->addSeparator();
file->addAction(openAction);
file->addAction(signMessageAction);
Expand Down Expand Up @@ -719,6 +729,7 @@ void BitcoinGUI::setWalletController(WalletController* wallet_controller, bool s
m_restore_wallet_action->setEnabled(true);
m_migrate_wallet_action->setEnabled(true);
m_migrate_wallet_action->setMenu(m_migrate_wallet_menu);
m_export_watchonly_action->setEnabled(true);

GUIUtil::ExceptionSafeConnect(wallet_controller, &WalletController::walletAdded, this, &BitcoinGUI::addWallet);
connect(wallet_controller, &WalletController::walletRemoved, this, &BitcoinGUI::removeWallet);
Expand Down
1 change: 1 addition & 0 deletions src/qt/bitcoingui.h
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ class BitcoinGUI : public QMainWindow
QAction* m_mask_values_action{nullptr};
QAction* m_migrate_wallet_action{nullptr};
QMenu* m_migrate_wallet_menu{nullptr};
QAction* m_export_watchonly_action{nullptr};

QLabel *m_wallet_selector_label = nullptr;
QComboBox* m_wallet_selector = nullptr;
Expand Down
25 changes: 25 additions & 0 deletions src/script/descriptor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,9 @@ struct PubkeyProvider

/** Whether this PubkeyProvider is a BIP 32 extended key that can be derived from */
virtual bool IsBIP32() const = 0;

/** Whether this PubkeyProvider can always provide a public key without cache or private key arguments */
virtual bool CanSelfExpand() const = 0;
};

class OriginPubkeyProvider final : public PubkeyProvider
Expand Down Expand Up @@ -295,6 +298,7 @@ class OriginPubkeyProvider final : public PubkeyProvider
{
return std::make_unique<OriginPubkeyProvider>(m_expr_index, m_origin, m_provider->Clone(), m_apostrophe);
}
bool CanSelfExpand() const override { return m_provider->CanSelfExpand(); }
};

/** An object representing a parsed constant public key in a descriptor. */
Expand Down Expand Up @@ -356,6 +360,7 @@ class ConstPubkeyProvider final : public PubkeyProvider
{
return std::make_unique<ConstPubkeyProvider>(m_expr_index, m_pubkey, m_xonly);
}
bool CanSelfExpand() const final { return true; }
};

enum class DeriveType {
Expand Down Expand Up @@ -579,6 +584,7 @@ class BIP32PubkeyProvider final : public PubkeyProvider
{
return std::make_unique<BIP32PubkeyProvider>(m_expr_index, m_root_extkey, m_path, m_derive, m_apostrophe);
}
bool CanSelfExpand() const override { return !IsHardened(); }
};

/** PubkeyProvider for a musig() expression */
Expand Down Expand Up @@ -787,6 +793,13 @@ class MuSigPubkeyProvider final : public PubkeyProvider
// musig() can only be a BIP 32 key if all participants are bip32 too
return std::all_of(m_participants.begin(), m_participants.end(), [](const auto& pubkey) { return pubkey->IsBIP32(); });
}
bool CanSelfExpand() const override
{
for (const auto& key : m_participants) {
if (!key->CanSelfExpand()) return false;
}
return true;
}
};

/** Base class for all Descriptor implementations. */
Expand Down Expand Up @@ -996,6 +1009,18 @@ class DescriptorImpl : public Descriptor
}

virtual std::unique_ptr<DescriptorImpl> Clone() const = 0;

// NOLINTNEXTLINE(misc-no-recursion)
bool CanSelfExpand() const override
{
for (const auto& key : m_pubkey_args) {
if (!key->CanSelfExpand()) return false;
}
for (const auto& sub : m_subdescriptor_args) {
if (!sub->CanSelfExpand()) return false;
}
return true;
}
};

/** A parsed addr(A) descriptor. */
Expand Down
3 changes: 3 additions & 0 deletions src/script/descriptor.h
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ struct Descriptor {
/** Convert the descriptor to a normalized string. Normalized descriptors have the xpub at the last hardened step. This fails if the provided provider does not have the private keys to derive that xpub. */
virtual bool ToNormalizedString(const SigningProvider& provider, std::string& out, const DescriptorCache* cache = nullptr) const = 0;

/** Whether the descriptor can be used to get more addresses without needing a cache or private keys. */
virtual bool CanSelfExpand() const = 0;

/** Expand a descriptor at a specified position.
*
* @param[in] pos The position at which to expand the descriptor. If IsRange() is false, this is ignored.
Expand Down
5 changes: 5 additions & 0 deletions src/wallet/interfaces.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,11 @@ class WalletImpl : public Wallet
}
CWallet* wallet() override { return m_wallet.get(); }

util::Result<std::string> exportWatchOnlyWallet(const fs::path& destination) override {
LOCK(m_wallet->cs_wallet);
return m_wallet->ExportWatchOnlyWallet(destination, m_context);
}

WalletContext& m_context;
std::shared_ptr<CWallet> m_wallet;
};
Expand Down
37 changes: 4 additions & 33 deletions src/wallet/rpc/backup.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -495,40 +495,11 @@ RPCHelpMan listdescriptors()
}

LOCK(wallet->cs_wallet);

const auto active_spk_mans = wallet->GetActiveScriptPubKeyMans();

struct WalletDescInfo {
std::string descriptor;
uint64_t creation_time;
bool active;
std::optional<bool> internal;
std::optional<std::pair<int64_t,int64_t>> range;
int64_t next_index;
};

std::vector<WalletDescInfo> wallet_descriptors;
for (const auto& spk_man : wallet->GetAllScriptPubKeyMans()) {
const auto desc_spk_man = dynamic_cast<DescriptorScriptPubKeyMan*>(spk_man);
if (!desc_spk_man) {
throw JSONRPCError(RPC_WALLET_ERROR, "Unexpected ScriptPubKey manager type.");
}
LOCK(desc_spk_man->cs_desc_man);
const auto& wallet_descriptor = desc_spk_man->GetWalletDescriptor();
std::string descriptor;
if (!desc_spk_man->GetDescriptorString(descriptor, priv)) {
throw JSONRPCError(RPC_WALLET_ERROR, "Can't get descriptor string.");
}
const bool is_range = wallet_descriptor.descriptor->IsRange();
wallet_descriptors.push_back({
descriptor,
wallet_descriptor.creation_time,
active_spk_mans.count(desc_spk_man) != 0,
wallet->IsInternalScriptPubKeyMan(desc_spk_man),
is_range ? std::optional(std::make_pair(wallet_descriptor.range_start, wallet_descriptor.range_end)) : std::nullopt,
wallet_descriptor.next_index
});
util::Result<std::vector<WalletDescInfo>> exported = wallet->ExportDescriptors(priv);
if (!exported) {
throw JSONRPCError(RPC_WALLET_ERROR, util::ErrorString(exported).original);
}
std::vector<WalletDescInfo> wallet_descriptors = *exported;

std::sort(wallet_descriptors.begin(), wallet_descriptors.end(), [](const auto& a, const auto& b) {
return a.descriptor < b.descriptor;
Expand Down
44 changes: 44 additions & 0 deletions src/wallet/rpc/wallet.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,49 @@ static RPCHelpMan createwalletdescriptor()
};
}

static RPCHelpMan exportwatchonlywallet()
{
return RPCHelpMan{"exportwatchonlywallet",
"Creates a wallet file at the specified destination containing a watchonly version "
"of the current wallet. This watchonly wallet contains the wallet's public descriptors, "
"its transactions, and address book data. Descriptors that use hardened derivation will "
"only have a limited number of derived keys included in the export due to hardened "
"derivation requiring private keys. Descriptors with unhardened derivation do not have "
"this limitation. The watchonly wallet can be imported into another node using 'restorewallet'.",
{
{"destination", RPCArg::Type::STR, RPCArg::Optional::NO, "The path to the filename the exported watchonly wallet will be saved to"},
},
RPCResult{
RPCResult::Type::OBJ, "", "",
{
{RPCResult::Type::STR, "exported_file", "The full path that the file has been exported to"},
},
},
RPCExamples{
HelpExampleCli("exportwatchonlywallet", "\"home\\user\\export.dat\"")
+ HelpExampleRpc("exportwatchonlywallet", "\"home\\user\\export.dat\"")
},
[&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue
{
std::shared_ptr<CWallet> const pwallet = GetWalletForJSONRPCRequest(request);
if (!pwallet) return UniValue::VNULL;
WalletContext& context = EnsureWalletContext(request.context);

std::string dest = request.params[0].get_str();

LOCK(pwallet->cs_wallet);
pwallet->TopUpKeyPool();
util::Result<std::string> exported = pwallet->ExportWatchOnlyWallet(fs::PathFromString(dest), context);
if (!exported) {
throw JSONRPCError(RPC_WALLET_ERROR, util::ErrorString(exported).original);
}
UniValue out{UniValue::VOBJ};
out.pushKV("exported_file", *exported);
return out;
}
};
}

// addresses
RPCHelpMan getaddressinfo();
RPCHelpMan getnewaddress();
Expand Down Expand Up @@ -921,6 +964,7 @@ std::span<const CRPCCommand> GetWalletRPCCommands()
{"wallet", &createwalletdescriptor},
{"wallet", &restorewallet},
{"wallet", &encryptwallet},
{"wallet", &exportwatchonlywallet},
{"wallet", &getaddressesbylabel},
{"wallet", &getaddressinfo},
{"wallet", &getbalance},
Expand Down
2 changes: 1 addition & 1 deletion src/wallet/scriptpubkeyman.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1176,7 +1176,7 @@ bool DescriptorScriptPubKeyMan::CanGetAddresses(bool internal) const
LOCK(cs_desc_man);
return m_wallet_descriptor.descriptor->IsSingleType() &&
m_wallet_descriptor.descriptor->IsRange() &&
(HavePrivateKeys() || m_wallet_descriptor.next_index < m_wallet_descriptor.range_end);
(HavePrivateKeys() || m_wallet_descriptor.next_index < m_wallet_descriptor.range_end || m_wallet_descriptor.descriptor->CanSelfExpand());
}

bool DescriptorScriptPubKeyMan::HavePrivateKeys() const
Expand Down
1 change: 1 addition & 0 deletions src/wallet/test/walletload_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class DummyDescriptor final : public Descriptor {
std::optional<int64_t> MaxSatisfactionWeight(bool) const override { return {}; }
std::optional<int64_t> MaxSatisfactionElems() const override { return {}; }
void GetPubKeys(std::set<CPubKey>& pubkeys, std::set<CExtPubKey>& ext_pubs) const override {}
bool CanSelfExpand() const final { return false; }
};

BOOST_FIXTURE_TEST_CASE(wallet_load_descriptors, TestingSetup)
Expand Down
Loading
Loading