From c1335ab58f76722b4bd0803f7732cdfbdcab4d7b Mon Sep 17 00:00:00 2001 From: Nitesh Balusu Date: Mon, 19 Oct 2026 22:28:02 -0400 Subject: [PATCH 1/2] rpc: gettxout is null for a confirmed unspendable output An OP_RETURN output, including the witness commitment, or a script over 10,000 bytes was reported as an unspent coin. Core never adds these to the coins view, and connect already refuses to spend them. The mempool view is unchanged, as in Core. Co-Authored-By: Claude Opus 5.5 --- changelog.d/gettxout-unspendable.md | 7 +++++ crates/rbitcoin-rpc/src/methods/chain.rs | 4 +++ crates/rbitcoin-rpc/src/methods_tests.rs | 37 ++++++++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 changelog.d/gettxout-unspendable.md diff --git a/changelog.d/gettxout-unspendable.md b/changelog.d/gettxout-unspendable.md new file mode 100644 index 0000000000..4bda7a7018 --- /dev/null +++ b/changelog.d/gettxout-unspendable.md @@ -0,0 +1,7 @@ +Fixed + +- **`gettxout` returns null for a confirmed unspendable output.** An + `OP_RETURN` output (including the witness commitment) or a script over + 10,000 bytes was reported as an unspent coin. Core never adds these to + the UTXO set, and rbitcoin already refuses to spend them. A mempool + transaction's outputs are still listed, as in Core. diff --git a/crates/rbitcoin-rpc/src/methods/chain.rs b/crates/rbitcoin-rpc/src/methods/chain.rs index e0b854af7a..a2319e00e3 100644 --- a/crates/rbitcoin-rpc/src/methods/chain.rs +++ b/crates/rbitcoin-rpc/src/methods/chain.rs @@ -1009,6 +1009,10 @@ pub(crate) fn gettxout(ctx: &RpcContext, params: &RpcParams) -> Result Date: Tue, 20 Oct 2026 08:59:50 -0400 Subject: [PATCH 2/2] rpc: pin a valued OP_RETURN, the 10,000-byte script and REST in the gettxout test The OP_RETURN now carries 1,000 sats, the case master reported as a coin. The 10,000-byte output must return its full script, and the REST bitmap must match gettxout. docs/rpc.md lists the unspendable null case. Co-Authored-By: Claude Opus 5.5 --- crates/rbitcoin-rpc/src/methods_tests.rs | 19 +++++++++++++++---- docs/rpc.md | 2 +- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/rbitcoin-rpc/src/methods_tests.rs b/crates/rbitcoin-rpc/src/methods_tests.rs index b5909fa8cc..57ac02b042 100644 --- a/crates/rbitcoin-rpc/src/methods_tests.rs +++ b/crates/rbitcoin-rpc/src/methods_tests.rs @@ -1658,14 +1658,14 @@ fn getrawtransaction_verbose_mid_disconnect_has_no_block_fields() { /// Core never adds an unspendable output to the UTXO set, so `gettxout` is /// null for a confirmed `OP_RETURN` or a script over 10,000 bytes. A script -/// of exactly 10,000 bytes is still a coin. +/// of exactly 10,000 bytes is still a coin. REST `getutxos` follows. #[test] fn gettxout_unspendable_confirmed_output_is_null() { use bitcoin::TxOut; let (ctx, dir, _hub) = ctx_from_mature_pad(300_000_000); - let (_, mut spend) = mature_coinbase_spend_hex(&ctx, 50_0000_0000 - 100_000); + let (_, mut spend) = mature_coinbase_spend_hex(&ctx, 50_0000_0000 - 101_000); spend.output.push(TxOut { - value: Amount::ZERO, + value: Amount::from_sat(1_000), script_pubkey: ScriptBuf::from_bytes(vec![0x6a, 0x01, 0x07]), }); let mut big = vec![0x51]; @@ -1685,11 +1685,22 @@ fn gettxout_unspendable_confirmed_output_is_null() { let coin = dispatch(&ctx, "gettxout", vec![json!(txid), json!(0)]).unwrap(); assert!(!coin.is_null(), "a spendable output is a coin"); let boundary = dispatch(&ctx, "gettxout", vec![json!(txid), json!(3)]).unwrap(); - assert!(!boundary.is_null(), "a 10,000-byte script is still a coin"); + assert_eq!( + boundary["scriptPubKey"]["hex"].as_str().map(str::len), + Some(20_000), + "a 10,000-byte script is still a coin: {boundary}" + ); for n in [1, 2] { let out = dispatch(&ctx, "gettxout", vec![json!(txid), json!(n)]).unwrap(); assert!(out.is_null(), "unspendable vout {n} is not a coin: {out}"); } + let (status, body) = rest_text( + &ctx, + &format!("/rest/getutxos/{txid}-0/{txid}-1/{txid}-2/{txid}-3.json"), + "", + ); + assert_eq!(status, axum::http::StatusCode::OK, "{body}"); + assert!(body.contains("\"bitmap\":\"1001\""), "{body}"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/docs/rpc.md b/docs/rpc.md index b9eba324fd..5b7e4bfb1f 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -156,7 +156,7 @@ still wait for durable SH when shindex is on. | `gettxspendingprevout` | All networks. Live mempool spender of each `{txid,vout}`. | | `submitblock` | All networks. Like Core, a coinbase with a witness commitment and no witness first gets the 32-byte zero reserved value once segwit is active. The parent's height comes from the connected chain, a header tip, or a walk of at most 10,000 stored headers. A parent past that walk is treated as before segwit, so the block is left as sent. Then the same `ChainHub::accept_received_block` as a P2P `block` message: tip-extend, or hold by hash + most-work `accept_branch`. A consensus reject is a BIP22 reason string. A local store fault, including one reading the parent or tip header or a spent output, is RPC error `-25` (Core `state.IsError()`). A cancelled connect is `inconclusive`. Neither caches the block as invalid. | | `getblockfilter` | All networks. Requires `--block-filter-index`. `filtertype` `basic` only (anything else is `-5`). Returns `filter` and `header` hex. A sealed best-chain height uses the index. One block whose parent filter header is already sealed (genesis uses the zero prev-header) is built from the stored body. A stale branch is rebuilt back to that sealed fork point. An unsealed best-chain gap is “Filter not found. Block filters are still in the process of being indexed.” Unknown hash is `-5` “Block not found”. Flag off is “Index is not enabled”. REST `/rest/blockfilter/` stays watermark-only (“still in the process of being indexed” past the watermark). | -| `gettxout` | All networks. Connected Class A + mempool. Default `include_mempool=true` returns `null` for a confirmed out spent by a live mempool tx. `include_mempool=false` still returns the confirmed coin. A leftover still live in the hub (IBD / `-blocksonly`) uses the connected path, not `confirmations: 0`. A disconnected archive row is `null` (not tip+1 confirmations). A vout at or past the output count is `null`. The genesis coinbase output is `null` (Core never adds it to the coins view); REST `getutxos` follows. | +| `gettxout` | All networks. Connected Class A + mempool. Default `include_mempool=true` returns `null` for a confirmed out spent by a live mempool tx. `include_mempool=false` still returns the confirmed coin. A leftover still live in the hub (IBD / `-blocksonly`) uses the connected path, not `confirmations: 0`. A disconnected archive row is `null` (not tip+1 confirmations). A vout at or past the output count is `null`. The genesis coinbase output is `null` (Core never adds it to the coins view). So is a confirmed unspendable output: a leading `OP_RETURN` (including the witness commitment) or a script over 10,000 bytes. A mempool tx's outputs are all listed, as in Core. REST `getutxos` follows. | | `getindexinfo` | All networks. Reports `txindex` synced at tip — we reconstruct by txid from Class A (no separate index flag). | | `getchaintips` | All networks. Active + archive `valid-fork` + held `valid-headers` + header-only (`submitheader` / P2P headers). Invalid body after a known header marks that branch `invalid`. | | `getdeploymentinfo` | All networks. Buried deployments from `ChainParams` including `--test-activation-height`. `active` follows Core `DeploymentActiveAfter` (true for the *next* block). No BIP9 / testdummy. |