Skip to content

policy: bound P2MR runtime stack bytes - #263

Open
djh58 wants to merge 5 commits into
1.x.xfrom
djh58/p2mr-stack-bytes-policy
Open

djh58 wants to merge 5 commits into
1.x.xfrom
djh58/p2mr-stack-bytes-policy

Conversation

@djh58

@djh58 djh58 commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Summary

This adds SCRIPT_VERIFY_P2MR_STACK_BYTES, a standard, non-mandatory script verification flag. It limits the combined size of the stack and the altstack to 1 MiB after each opcode of a P2MR v1 script.

This is a policy-only change with no consensus effect. Block validity is unchanged. The flag applies to mempool admission and to the other users of STANDARD_SCRIPT_VERIFY_FLAGS.

Why (#257):

  • The only runtime bound on a P2MR stack is 1,000 items. Initial witness items may be up to 16 KiB each.
  • So element-copying opcodes can grow the interpreter stack to about 1,000 times the largest initial item, about 16 MiB for one input.
  • That costs no validation weight, because no signature is involved.

What changed:

  • src/script/interpreter.h: adds SCRIPT_VERIFY_P2MR_STACK_BYTES = (1U << 23).
  • src/script/script.h: adds MAX_STANDARD_P2MR_V1_STACK_BYTES = 1 MiB.
    • A static_assert checks that the cap is at least 128 KiB of initial items plus 1,000 maximum-size (520-byte) pushes.
    • So only scripts that duplicate elements can reach it.
  • src/script/interpreter.cpp:
    • Adds StackBytesFrom() and keeps the total incrementally in EvalScript.
    • This runs only for P2MR with the flag set. Going over the cap fails with SCRIPT_ERR_STACK_SIZE.
    • Why it is incremental: a full re-sum after every opcode costs O(stack size), measured at about 45 times a DUP/DROP pair. Instead, each opcode re-measures only the elements it can change: the top six stack elements (the depth OP_2ROT reaches) and the top altstack element.
    • OP_ROLL: it only reorders the elements below its operand, so its window is the operand alone.
    • OP_CHECKMULTISIG: P2MR rejects it before it touches the stack.
  • src/policy/policy.h: the flag is added to STANDARD_SCRIPT_VERIFY_FLAGS, so it is in STANDARD_NOT_MANDATORY_VERIFY_FLAGS.
    • A failure is TX_NOT_STANDARD with mempool-script-verify-flag-failed (Stack size limit exceeded), and there is no peer penalty.
    • GetBlockScriptFlags is unchanged.
  • doc/consensus/p2mr-v1.md: adds one table row: "Main stack plus alt-stack bytes after each opcode | 1 MiB | policy".

Compatibility:

  • Older nodes: still relay these spends. Upgraded nodes reject them as non-standard, with no ban.
  • Wallet signing: verifies with STANDARD_SCRIPT_VERIFY_FLAGS, so a wallet-built spend of a duplication-heavy leaf is reported incomplete, which matches relay.
  • Existing scripts: miniscript and descriptor fragments duplicate only small values. The largest standard multi_a spend stays well under 1 MiB.

Not part of this PR: a consensus-level limit. Making the flag mandatory from a height, or charging validation weight for copied bytes, would be a soft fork. It needs its own proposal, activation plan and release coordination.

Refs #257

Testing

  • Built locally.
  • Ran focused unit or functional tests for the changed area.
  • Ran lint or formatting checks relevant to this change.
  • Not run. Reason:

Built with cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF … (targets test_qbit qbitd qbit-cli). Every result below is at this head, 185836514a.

New unit tests in src/test/script_p2mr_tests.cpp:

  • p2mr_dup_drop_leaf_runtime_stack_bytes: a duplication-heavy leaf is valid under consensus flags and rejected under the new flag and under STANDARD_SCRIPT_VERIFY_FLAGS.
  • p2mr_runtime_stack_bytes_boundary:
    • It reaches exactly 1 MiB through DUP, 2DUP, 3DUP, OVER, 2OVER, PICK, TUCK and IFDUP, with the altstack, ROLL, 2ROT, 2SWAP, ROT, SWAP and NIP in between.
    • Exactly 1 MiB is accepted. One more byte is rejected.

Results of build/bin/test_qbit --run_test='script_p2mr_tests/p2mr_dup_drop_leaf_runtime_stack_bytes:script_p2mr_tests/p2mr_runtime_stack_bytes_boundary':

  • Without the interpreter change: 2 of 2 cases fail, 8 of 18 assertions.
  • With it: 2 of 2 cases pass, 18 of 18 assertions.

Accounting cross-check (a temporary assert, not committed): tracked == StackBytesFrom(stack, 0) + StackBytesFrom(altstack, 0) after every opcode under the flag.

  • script_p2mr_tests, script_tests, transaction_tests, p2mr_datapqchash_tests, descriptor_p2mr_tests and psbt_p2mr_tests pass with it: 192 cases, 292,592 assertions.
  • 20,000 random P2MR scripts gave no mismatch. They had 1 to 8 initial items of up to 16 KiB and 6.0 M opcodes, covering every stack-moving opcode.

Regression: build/bin/test_qbit --run_test='script_p2mr_tests:script_tests:transaction_tests:txvalidationcache_tests:pqc_sigcache_tests:p2mr_datapqchash_tests:descriptor_p2mr_tests:psbt_p2mr_tests:script_standard_tests:sigopcount_tests:miniscript_tests:script_p2sh_tests:script_segwit_tests:txpackage_tests:mempool_tests:feebumper_tests:pqc_tests'. All 296 cases pass, with 515,954 assertions.

Functional: test/functional/test_runner.py feature_p2mr.py, with the new test_runtime_stack_bytes_policy_rejection.

  • testmempoolaccept rejects the leaf with exactly that reason, and a block containing it is accepted by submitblock.
  • It fails without the interpreter change and passes with it.
  • feature_p2mr_activation, feature_p2mr_weight_activation, feature_p2mr_reorg, mempool_accept, wallet_p2mr, wallet_p2mr_psbt and rpc_packages also pass.

Other changes:

  • src/test/transaction_tests.cpp: a flag-name map entry.
  • src/test/fuzz/p2mr_script.cpp: the flag is added to the fuzzed flag sets. This was syntax-checked with the unit-test flags. The fuzz binary was not built.

Not run: Windows and macOS, the fuzz binary, and a measurement of real-node memory while connecting a block.

Target Branch

  • This PR targets main or a maintainer-requested release branch such as 0.1.x.

This targets 1.x.x for v1.0.1 (#181 prepares that release), and then 1.1.0.

Risk / Review Notes

  • Consensus, script, crypto, wallet, P2P, release, CI, or security-sensitive behavior changed.
  • No consensus, script, crypto, wallet, P2P, release, CI, or security-sensitive behavior changed.

Notes:

  • Policy only. The change is in script interpretation, under standard flags. MANDATORY_SCRIPT_VERIFY_FLAGS and GetBlockScriptFlags are unchanged, so block validity is not affected.
  • Cache keys. Script-execution cache entries are keyed by flags.
  • The window invariant: the incremental total relies on every P2MR opcode touching at most the top six stack elements and the top altstack element, with OP_ROLL as the one exception (a permutation below a one-element window).
    • Every case in EvalScript was checked against it, and the randomized cross-check found no mismatch.
    • A future P2MR opcode that writes deeper than six elements would need to widen the window.
    • A debug-only Assume of the full re-sum would make that explicit. That is a candidate follow-up.

Docs / Process Impact

Choose exactly one:

  • I updated public docs because this PR changes user-visible behavior, integration guidance, release/process guidance, or expected validation.
  • No public docs update needed. Reason:

doc/consensus/p2mr-v1.md lists the new 1 MiB policy limit. The v1.0.1 release notes should mention it as a new standardness rule.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

Add SCRIPT_VERIFY_P2MR_STACK_BYTES, a standard (non-mandatory) script
verification flag that limits the total size of the stack and altstack
to 1 MiB after each opcode of a P2MR v1 script. The 1,000-item runtime
limit alone lets element-copying opcodes grow the interpreter stack to
about 1,000 times the largest initial witness item. Block validity is
unchanged; the flag applies to mempool admission and the other users of
STANDARD_SCRIPT_VERIFY_FLAGS.

The total is maintained by re-measuring only the stack elements an
opcode can change, so the check adds constant work per opcode.

Refs #257

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@djh58

djh58 commented Oct 29, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-30T19:47:17.079010Z 04c03ef New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: 185836514a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

kiwidream and others added 3 commits October 30, 2026 11:24
SCRIPT_VERIFY_P2MR_STACK_BYTES failed with SCRIPT_ERR_STACK_SIZE, the
error of the consensus 1,000-item stack limit. A mempool rejection then
read "Stack size limit exceeded" whether the spend broke the policy byte
limit or the consensus item limit.

Add SCRIPT_ERR_P2MR_STACK_BYTES, "Standard P2MR stack byte limit
exceeded", and return it when the byte limit is exceeded. The consensus
item limit is checked first after each opcode, so a spend that breaks
both on the same opcode still reports SCRIPT_ERR_STACK_SIZE.

Refs #257

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The conformance oracle derived each resource case's policy result from
its consensus result, so it could not express a policy-only limit, and
the corpus had no case for the 1 MiB runtime stack-byte limit.

Teach the oracle's evaluator OP_DUP, OP_DROP and an optional limit on
stack bytes after each opcode. Add a runtime-stack-bytes resource kind
that the oracle evaluates under consensus and policy separately, with
boundary cases at exactly 1 MiB (standard) and 1 MiB plus one byte
(consensus-valid, nonstandard with SCRIPT_ERR_P2MR_STACK_BYTES).
Regenerate the boundary corpus and the manifest.

The C++ consumer builds the same spend and checks both cases under
consensus and standard flags.

Refs #257

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Section 11 said a known leaf over any runtime resource limit is
consensus-invalid, which does not hold for the policy-only 1 MiB
stack-byte limit. Narrow that row to consensus limits, add a row for
otherwise valid spends over the byte limit, and describe the policy
check and its error in section 10.

Refs #257

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kiwidream

Copy link
Copy Markdown
Member

Review input from the duplicate branch for #257 (closed #269, kiwidream/rg-257-p2mr-runtime-stack-bytes, head d58d80f752). We're consolidating on this PR, so these are the points where #263 (head 185836514a) differs from #269. The enforcement here is correct: the windowed O(1) accounting is equivalent to #269's, and #263's two unit tests pass against #269's implementation as well.

Worth fixing before merge

  1. Distinct error. src/script/interpreter.cpp:1415 returns the existing SCRIPT_ERR_STACK_SIZE, so the reject reason is identical to the consensus 1,000-item failure (test/functional/feature_p2mr.py:526). Integrators then can't tell "nonstandard, mineable" from "consensus-invalid". The tests can't tell which limit fired either (script_p2mr_tests.cpp:4894, 4939, 4972, 5003). policy: cap P2MR runtime stack bytes at 1 MiB (v1.0.1) #269 adds SCRIPT_ERR_P2MR_RUNTIME_STACK_BYTES plus a script_tests.cpp error-name entry.
  2. Tests miss accounting regressions. Every boundary-test item is 16 KiB (script_p2mr_tests.cpp:4882, 4912, 4928), so rearranging opcodes never changes the total. We seeded regressions into the same window scheme (stack window 6→5, 6→4, and OP_ROLL routed through the generic window); all of them passed policy: bound P2MR runtime stack bytes #263's tests and failed policy: cap P2MR runtime stack bytes at 1 MiB (v1.0.1) #269's. Suggested fixes, both in src/test/script_p2mr_runtime_stack_tests.cpp on the branch above:
    • use distinct item sizes around ROLL/2ROT/2SWAP (non_copying_opcode_boundaries);
    • add a cross-check that rescans the full stack after every opcode across 500 random scripts (running_total_matches_rescan).
  3. Spec edit and release-gate cost. doc/consensus/p2mr-v1.md:437 edits the spec that ci/release/verify_p2mr_v1_conformance.py:530-554 (validate_specification_commit) binds at the release tag. That means refreshed consensus-review evidence for v1.0.1. If the row stays, the §11 row at p2mr-v1.md:489 also needs updating. policy: cap P2MR runtime stack bytes at 1 MiB (v1.0.1) #269 put the policy row in a new doc/policy/p2mr-script-limits.md instead.
  4. Release note and known limitation. No doc/release-notes-263.md. Nothing records that block validation still accepts about 16 MiB of runtime stack per input (about 256 MiB at 16 script-check threads) until a consensus cap.

Nice to have

  • Tests for flag-off error preservation, item-count precedence when both limits trip, and non-P2MR (TAPSCRIPT) gating.
  • A debug/fuzz-only Assume(total == rescan) self-check. The window rule rests on a comment (interpreter.cpp:576-579), and a future deeper-reaching opcode would drift silently.
  • An entry check before the first opcode (interpreter.cpp:533). VerifyScript can't reach this, but direct EvalScript callers can.

Worth keeping from #263: the testmempoolaccept/submitblock functional test (feature_p2mr.py:516-531) and the static_assert at script.h:57.

The tests, error code, policy doc and release note are on kiwidream/rg-257-p2mr-runtime-stack-bytes if you want to cherry-pick them.

kiwidream
kiwidream previously approved these changes Oct 30, 2026
@kiwidream

Copy link
Copy Markdown
Member

Merged #271 into this branch as a fast-forward (185836514a → ba62cfcd71). It adds three signed commits on top of your head and rewrites none of your commits:

  • SCRIPT_ERR_P2MR_STACK_BYTES: the byte cap now has its own error instead of reusing SCRIPT_ERR_STACK_SIZE.
  • doc/consensus/p2mr-v1.md: the policy limit is classified in §10, and the §11 row now names the consensus runtime limits.
  • P2MR v1 oracle cases for the byte cap, evaluated independently under consensus and policy.

This addresses points 1 and 3 of the earlier review comment. Required Merge Gate, Core Checks and Full Validation are now running on this head against 1.x.x. Because §10/§11 of the spec changed, the v1.0.1 P2MR consensus review must cite a commit that includes them.

The oracle cases added for the stack-byte limit changed src/test/data/p2mr_v1_manifest.json, so the draft support matrix still named the old corpus manifest digest and CheckedInP2MRIntegrationMatrixTest failed in Full Validation. Point the matrix and its generated markdown at the new digest. Every component is still not-tested or not-applicable, with no evidence, so no test result changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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