Bitcoin Core `exportwatchonlywallet` RPC 详解:一键导出 Watch-Only 钱包文件
本篇基于 Bitcoin Core 发布说明 release-notes-32489 展开,讲解新增的 exportwatchonlywallet RPC 的完整用法、底层导出机制与安全边界。读完后,你将掌握如何从现有描述符钱包导出一份不含私钥的 watch-only 钱包文件,通过 restorewallet 在另一台节点上导入,并能在"离线签名 + 在线观察"场景中正确评估硬派生描述符的地址限制。
功能概述:一条 RPC 替代手工描述符导入
发布说明 release-notes-32489 对该功能的官方定义是:
A new
exportwatchonlywalletRPC creates a watchonly wallet file from an existing descriptor wallet. The exported file contains the wallet's public descriptors (with derived key caches where needed), transactions, and address book data, but no private keys. It can be imported on another node using the existingrestorewalletRPC.
即:该 RPC 从当前已加载的描述符钱包直接生成一个 watch-only 钱包文件,文件内包含:
- 钱包的公钥描述符(需要时附带派生密钥缓存);
- 钱包已知的交易记录;
- 地址簿数据(标签、用途、收款请求等);
- 以及钱包的持久化标记(如避免复用的锁定 UTXO)。
- 不包含任何私钥。
导出的文件可随后续有(或没有)网络的介质带到另一台节点,用既有 restorewallet RPC 导入。此前的做法是用 listdescriptors/importdescriptors 手工搬运描述符并重建地址簿,这一流程在 离线签名教程 中已被更新为直接使用 exportwatchonlywallet 完成在线 watch-only 钱包的搭建。
RPC 接口:参数、返回值与错误
RPC 的定义位于 src/wallet/rpc/wallet.cpp,注册在 wallet RPC 组中:
| 项目 | 说明 |
|---|---|
| 方法名 | exportwatchonlywallet |
| 参数 | destination(字符串,必填):导出的 watch-only 钱包文件的保存路径 |
| 返回值 | 对象,字段 exported_file:文件实际导出到的完整路径 |
| 执行前置 | 锁定当前钱包(cs_wallet)并先调用 TopUpKeyPool() 补齐密钥池,再执行导出 |
官方帮助中的示例:
bitcoin-cli -rpcwallet=<钱包名> -named exportwatchonlywallet \
destination="/path/to/export.dat"
帮助文本还特别说明了硬派生的限制(源码原文):
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.
也就是说:含硬派生分叉(xprv.../0'/* 这类)的描述符,由于无法从公钥做硬派生,导出文件里只能携带有限数量的已派生密钥(即密钥缓存);非硬派生描述符则不存在此限制,可以在目标节点上自行扩展。
从 功能测试 中可以确认两类典型报错,均返回 RPC 错误码 -4(RPC_WALLET_ERROR):
- 目标路径为空:
Error: Export destination cannot be empty; - 目标已存在:
Error: Export destination '<path>' already exists(导出不会覆盖已有文件); - 从没有任何描述符的空钱包(例如
createwallet时blank=true或仅disable_private_keys=true且无导入)导出:Error: Wallet has no descriptors to export。
导出文件里到底有什么:逐项对照源码
核心实现是 src/wallet/export.cpp 中的 ExportWatchOnlyWallet 函数(L46-L195)。按代码顺序,导出过程依次做了以下事情:
- 校验目标路径:非空、不存在,且可以成功创建文件句柄,任一失败立即返回错误。
- 登记失败清理:通过
interfaces::MakeCleanupHandler注册回调——只要后续任一步骤失败,自动删除已创建的不完整导出文件,避免在磁盘上留下半成品钱包。 - 导出描述符:调用
ExportDescriptors(wallet, /*export_private=*/false)(L18-L44),只取公钥描述符字符串。注意这里传入export_private=false,随后在解析回描述符对象时,源码还会断言解析出的临时 key 集合为空(dummy_keys.keys.size() == 0,L96),即双重保证导出描述符中不含私钥。 - 创建临时钱包:以"原钱包标记 +
WALLET_FLAG_DISABLE_PRIVATE_KEYS"作为标志,在内存数据库(MakeInMemoryWalletDatabase())中创建一个名为<原钱包名>_watchonly_temp的临时钱包(L74-L84)。选择在内存中操作,是为了失败时不会把临时文件遗留在磁盘上。 - 逐条导入描述符:解析每个描述符并加回新钱包;对于不能自我扩展(缺少私钥或缓存)的描述符,从原钱包拷贝其派生密钥缓存(L108-L113)——这正是发布说明中"with derived key caches where needed"的出处。同时保留描述符的激活状态(active)与 internal 标记。
- 在一个原子数据库事务中批量写入(L138-L185)。事务内拷贝的数据包括:
orderPosNext(交易排序计数,保持listtransactions顺序一致);- 最佳区块定位器(best block locator):避免导入节点重新扫描区块;
- 全部交易(
mapWallet逐笔序列化写入); - 地址簿:每个条目的用途(purpose)、标签(label)、收款请求(receive requests)以及"该地址曾被花费过"的标记(
previously_spent)。
- 拷贝持久化的锁定 UTXO:只有
persistent=true的锁定项会被复制到 watch-only 钱包(临时锁定不跨文件保留)。 - 落盘:最后调用
BackupWallet把内存中的 watch-only 钱包写入destination,成功才将清理标记置为"无需删除",并返回导出路径。
由此可以整理出导出内容的完整清单:
| 数据项 | 是否导出 | 依据 |
|---|---|---|
| 描述符(仅公钥形式) | 是 | export.cpp L29-L41 |
| 不可自扩展描述符的派生密钥缓存 | 是(有限) | export.cpp L108-L113 |
| 钱包标志(含 avoid-reuse 等) | 是(叠加禁用私钥) | export.cpp L74 |
| 持久化锁定的 UTXO | 是 | export.cpp L132-L136 |
| 交易历史 | 是 | export.cpp L160-L169 |
| 地址簿(用途/标签/收款请求/复用标记) | 是 | export.cpp L171-L180 |
| 私钥 | 否 | export_private=false + 空 key 集合断言 |
另外值得注意的是:加密且处于锁定状态的钱包也可以导出。因为导出全程只需要公开数据,功能测试 test_encrypted_wallet 专门验证了这一点——从未解锁的加密钱包导出的 watch-only 钱包既不含 unlocked_until 字段(没有私钥也就无需解锁),其 listdescriptors 结果却与源钱包完全一致(wallet_exported_watchonly.py L246-L261)。
硬派生限制:地址池能生成多少地址?
由于硬派生无法从公钥继续派生,导出文件只能携带已派生的密钥缓存。缓存里有多少地址,取决于导出时源钱包的密钥池规模。RPC 实现里先调用 TopUpKeyPool()(wallet.cpp L946)再导出,意味着默认会把密钥池补到默认大小(-keypool,默认 1000)再冻结进导出文件。
功能测试用一个 -keypool=10 的节点量化了这一行为(L200-L203):对一条硬派生的 sh(wpkh(xprv.../0'/*)) 描述符,导入方连续生成 KEYPOOL_SIZE - 1 个新地址后,第 10 次 getnewaddress(address_type="p2sh-segwit") 抛出 No addresses available(错误码 -12)。而非硬派生的描述符没有这个上限:测试中源钱包与导入钱包连续生成 2 倍密钥池大小的地址后,两边生成的地址仍然逐一对应(L84-L89)。
实践建议:如果目标节点需要大量新地址且描述符含硬派生分叉,应在导出前用 -keypool 调大密钥池,或优先使用非硬派生描述符。
实战工作流:离线签名场景
离线签名教程 已改用 exportwatchonlywallet 替代了原先的手工描述符导入流程。在 signet 上的典型操作如下(引自教程原文):
- 离线机创建带密码的私钥钱包:
[offline]$ ./build/bin/bitcoin-cli -signet -named createwallet \
wallet_name="offline_wallet" \
passphrase="** enter passphrase **"
- 离线机导出 watch-only 钱包文件(该文件通过 U 盘等介质转移到在线机):
[offline]$ ./build/bin/bitcoin-cli -signet -rpcwallet="offline_wallet" -named exportwatchonlywallet \
destination=/path/to/watch_only_wallet.dat
- 在线机用
restorewallet导入为 watch-only 钱包:
[online]$ ./build/bin/bitcoin-cli -signet -named restorewallet \
wallet_name="watch_only_wallet" \
backup_file=/path/to/watch_only_wallet.dat
由于两个钱包共享同一组公钥描述符,它们生成的地址完全相同:在线 watch-only 钱包负责追踪入账、创建未签名 PSBT、广播已签名交易;真正的签名只能由离线钱包完成。功能测试对这条链路的端到端验证是:在线钱包 send 得到 complete=false 且返回 psbt 字段,离线钱包 walletprocesspsbt 签名后,在线节点 finalizepsbt + sendrawtransaction 完成上链,且找零地址在两个钱包中都被识别为 ismine(L63-L82)。
此外,restorewallet 导入后不会触发重新扫描:因为导出文件写入了源钱包的最佳区块定位器,测试中通过日志断言 Rescanning last 未出现(L138-L140)。前提是导出前两个节点区块高度一致;若在线节点落后,导入后仍可能自行触发扫描以追上链高。
功能测试覆盖的行为矩阵
wallet_exported_watchonly.py 是这条功能的验收测试,覆盖 7 组场景,可作为自查清单:
| 测试函数 | 验证内容 |
|---|---|
test_basic_export |
基本导出/导入;错误路径报错;从 watch-only 钱包再导出 watch-only 也成立;两边 listdescriptors 一致;未签名 PSBT 协作转账;密钥池之外的地址两侧一致 |
test_export_with_address_book |
receive/send 两类标签、getaddressesbylabel 在两个钱包中一致 |
test_export_with_txs_and_locked_coins |
交易逐笔一致、listtransactions 顺序一致;持久锁定在两侧都在,临时锁定只在源钱包;导入不触发重扫 |
test_export_imported_descriptors |
单密钥、硬派生、multisig、taproot、miniscript 等各类导入描述符均被正确导出,且各地址类型的收款地址逐一对应 |
test_avoid_reuse |
avoid_reuse 钱包标志及其"地址曾被复用"标记随导出保留 |
test_encrypted_wallet |
锁定的加密钱包可导出,导出的 watch-only 钱包无解锁字段 |
test_export_blank_wallet |
无描述符的空钱包导出报 Wallet has no descriptors to export |
使用限制小结
- 源必须是描述符钱包:实现只接受
DescriptorScriptPubKeyMan类型的密钥管理器,遇到其他类型直接报错(export.cpp L22-L26); - 目标路径不能已存在,重复导出需换路径(测试对已存在路径断言报错);
- 不导出私钥、不导出加密状态:导出结果永远是 watch-only 钱包,
restorewallet导入后不能发起需签名的完整支付; - 硬派生描述符的地址生成上限等于导出时刻的派生缓存规模;
- 临时锁定的 UTXO 不会跟随导出,只有
persistent=true的锁定会被拷贝; - 功能位于钱包 RPC 组,需以钱包已加载的节点上下文调用(
-rpcwallet指定目标钱包),GUI 侧同样有对应的导出入口(参见 src/qt/bitcoingui.cpp 对ExportWatchOnlyWallet的调用)。
综合来看,exportwatchonlywallet 把"搬运描述符 + 手工恢复地址簿/交易状态"这一系列容易出错的步骤收敛为一条命令,并通过内存构建、原子事务、失败清理和无私钥断码这几道工程手段保证了导出文件的安全性与一致性,是搭建离线签名工作流时值得优先使用的入口 RPC。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00