Skip to content

rpc: gettxout is null for a confirmed unspendable output - #1095

Merged
reardencode merged 2 commits into
reardencode:masterfrom
smolcars:rpc/gettxout-unspendable
Oct 20, 2026
Merged

reardencode merged 2 commits into
reardencode:masterfrom
smolcars:rpc/gettxout-unspendable

Conversation

@niteshbalusu11

@niteshbalusu11 niteshbalusu11 commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

gettxout reported a confirmed unspendable output as an unspent coin. That covers an OP_RETURN output, including every coinbase witness commitment, and a script over 10,000 bytes. Core never adds these to the coins view, so it returns null. Consensus already agrees with Core: spending one is bad-txns-inputs-missingorspent (rule C43).

 gettxout (confirmed branch)
   resolve txid → fk, check vout range, spent, mempool-spent
   out = tx_output_at_fk(fk, n)
+  if policy::is_unspendable(out.script) → null      # the rule C43 already uses
   build the coin JSON

The mempool branch is unchanged. Core's CCoinsViewMemPool::GetCoin also returns any output of a mempool transaction. REST /rest/getutxos calls gettxout, so its bitmap follows.

Evidence

The same block was sent to bitcoind 31.1 and rbitcoin. It holds one tx with these outputs: OP_RETURN with value 0, OP_RETURN with value 1000, a 10,001-byte script, a 10,000-byte script, and OP_TRUE. The coinbase witness commitment of the next block was also queried.

gettxout Core 31.1 master this PR
OP_RETURN, 0 sat null 0.0 null
OP_RETURN, 1000 sat null 0.00001 null
10,001-byte script null 0.00001 null
10,000-byte script (a coin) 0.00001 0.00001 0.00001
OP_TRUE value value value
coinbase witness commitment null 0.0 null

Test: gettxout_unspendable_confirmed_output_is_null. A block from generateblock holds a spend with an OP_TRUE output, a 1,000-sat OP_RETURN, a zero-value 10,001-byte script, and a 10,000-byte script that must stay a coin with its full 20,000-hex-char script. REST /rest/getutxos over vouts 0–3 must give bitmap 1001.

  • Before: fails with unspendable vout 1 is not a coin: {…"hex":"6a0107"},"value":0.00001}. A length-only predicate (script.len() > 10_000) fails the same way.
    After: passes. cargo test -p rbitcoin-rpc --lib reports 125 passed.

Local: fmt, clippy -D warnings, ast-grep, cargo test --workspace --exclude rbitcoin-bench, ci-os-smoke.sh.

Found while comparing crash-recovery state against Core: a full gettxout sweep differed only on these outputs.

Merge Danger

Door: two-way

RPC and REST output only. docs/rpc.md lists the new null case. The store, consensus and the mempool view are unchanged.

Blast Radius: gettxout and REST /rest/getutxos

A caller that read OP_RETURN outputs back through gettxout now gets null, as it would from Core.

🤖 Generated with Claude Code

@niteshbalusu11
niteshbalusu11 force-pushed the rpc/gettxout-unspendable branch from ac54854 to ac87bd4 Compare October 20, 2026 02:48

@rearden-grok rearden-grok Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment, not a change request. The confirmed-path filter is the right fix, and the new test should stay its own test on the shared mature pad.

Behavior

gettxout loads the Class A output and then checks spentness. An unspendable output is not a coin, so it is never spent, and master returns it. Connect already refuses a spend of one (bad-txns-inputs-missingorspent), and the BIP30 walk already skips policy::is_unspendable. This RPC was the surface that still treated the stored script as a coin.

The new check is in the right place: after the spent and mempool-spent returns, and only on the confirmed branch. It uses policy::is_unspendable (leading OP_RETURN, or length greater than 10,000), the same predicate as connect and blockstats. A script of exactly 10,000 bytes stays a coin. The mempool branch is untouched, which matches Core: AddCoin drops IsUnspendable, and CCoinsViewMemPool::GetCoin still returns whatever output the mempool tx has. include_mempool=false hits this same confirmed branch. REST /rest/getutxos calls gettxout, so a null coin becomes bitmap 0.

Leaving the store and the mempool view alone is correct. This is an RPC view of a rule the node already enforces.

Test time

gettxout_unspendable_confirmed_output_is_null already shares the pad. ctx_from_mature_pad copies the process-wide OnceLock from mature_regtest_pad (one generate 101 per test binary). This test does not remine that chain.

The added cost is one store copy, one hub and mempool open, and one generateblock. The 10,000-byte and 10,001-byte scripts are the boundary under test. About 20KB on that one block is not a second pad, and it is small next to the open.

Keep it a separate #[test]. gettxout_leftover_is_connected_not_unconfirmed is the nearest neighbor and pays for the same copy, but its story is a mempool spend and a leftover confirmation count. Distinct cases that only share a costly chain stay separate tests on that fixture.

rpc_regtest_mature_chain_ops is a worse home. That journey mines its own 130-block chain, not this OnceLock, and a fat script would sit on the chain the rest of the journey reads.

A one-block hand-built coinbase would skip the copy. It would also be a second block builder beside the generateblock this test already uses.

Follow-ups, same test, no extra open

These can land in this function. None of them need another pad or another #[test].

  • docs/rpc.md owns the gettxout contract. That row already lists the other null cases (mempool-spent, disconnected, past the output count, genesis coinbase) and does not mention an unspendable confirmed output. One sentence there.
  • The evidence table's interesting case is a valued OP_RETURN (master returned 0.00001). Every extra output in the test is Amount::ZERO. Move 1,000 sats from vout 0 onto the OP_RETURN. Same tx, same generateblock.
  • assert!(!boundary.is_null()) accepts any object. Assert scriptPubKey.hex is 20,000 hex chars so a truncated 10,000-byte script cannot pass as "still a coin".
  • The witness commitment on the block generateblock just mined is the same OP_RETURN predicate, and REST is the same function. One gettxout on that coinbase output and one /rest/getutxos bitmap fit here without another mine. The mempool "still listed" claim does not: a standard mempool tx cannot carry the 10,001-byte non-OP_RETURN script, or a zero-value spendable 10,000-byte script. Leave that path to the early return above this check.

Comment thread crates/rbitcoin-rpc/src/methods_tests.rs
niteshbalusu11 and others added 2 commits October 20, 2026 08:59
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 <noreply@anthropic.com>
…ettxout 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 <noreply@anthropic.com>
@niteshbalusu11
niteshbalusu11 force-pushed the rpc/gettxout-unspendable branch from ac87bd4 to da72a87 Compare October 20, 2026 13:02
@niteshbalusu11

Copy link
Copy Markdown
Contributor Author

Thanks. Rebased onto master and pushed da72a875. All in the same test, with no extra pad, open, or mine:

  • Valued OP_RETURN: moved 1,000 sats from vout 0 onto it. With the check reduced to script.len() > 10_000, the test fails on {"hex":"6a0107"},"value":0.00001}, the master case from the evidence table.
  • Boundary: the 10,000-byte output must now return its full script (scriptPubKey.hex of 20,000 chars), not just a non-null object.
  • REST: /rest/getutxos/{txid}-0/{txid}-1/{txid}-2/{txid}-3.json must give bitmap 1001.
  • docs/rpc.md: the gettxout row now lists a confirmed unspendable output as null and says mempool outputs are still listed.

Witness commitment: I did not add this. The block generateblock mines here has no commitment output. apply_witness_commitment (rbitcoin-consensus/src/block/mod.rs:352) does nothing without witness data. This test's only spend has an OP_TRUE input with an empty witness, so the coinbase gets no commitment. Getting one means a second tx with a witness input. The commitment uses the same leading-OP_RETURN predicate as the valued OP_RETURN the test now pins. The Core 31.1 row for it in the evidence table came from the differential run.

Kept as its own #[test] on the shared pad, as you suggested.

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve. The confirmed-path filter is unchanged and still right, and the follow-ups land in the same test.

What I re-checked

The four-line change is still only on the confirmed branch, after spent and mempool-spent, using policy::is_unspendable (leading OP_RETURN, or length greater than 10,000). The mempool early return still lists every output. That is Core: AddCoin drops IsUnspendable, CCoinsViewMemPool::GetCoin does not.

The updated test pins both arms of that predicate, on one generateblock off the shared mature pad:

  • Vout 1 is a 1,000-sat OP_RETURN (6a0107). A length-only check still returns 0.00001. Fee stays 100,000 sats: vout 0 is 50 BTC − 101_000, plus this 1,000.
  • Vout 2 is a zero-value 10,001-byte script. A value-only check would still return it.
  • Vout 3 must come back with scriptPubKey.hex of 20,000 characters. >= 10_000, or a truncated script, fails that.
  • Default gettxout uses include_mempool=true. REST /rest/getutxos without checkmempool calls gettxout with include_mempool=false. Bitmap 1001 is vout 0 and vout 3. .json is stripped by split_format before the outpoints are parsed, so the last n is 3.

docs/rpc.md now lists the confirmed unspendable null and the mempool exception. The row matches the code.

Witness commitment

Agreed, this block has none. mine_regtest_paying calls apply_witness_commitment only when block_has_witness is true, and that walks inputs for a non-empty witness. This spend's witness is empty, so the coinbase is just the payment output. The commitment script (6a24aa21a9ed…) is the same leading-OP_RETURN arm vout 1 already fails closed. A second witness transaction would be another mine for a predicate this test already hits.

Still one private copy of the OnceLock pad. No second test.

@reardencode
reardencode merged commit b439605 into reardencode:master Oct 20, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants