script: store interpreter stack elements as reference-counted objects #36721

pull conduition wants to merge 5 commits into bitcoin:master from conduition:scriptvalptr-stage1 changing 10 files +528 −131
  1. conduition commented at 3:36 AM on November 2, 2026: none

    Modifies the script interpreter to represent stack elements internally using smart pointers, making duplication memory-cheap, and giving us caching keys which are cheap and guaranteed unique by the compiler. First part of a 2-part project to improve script interpreter performance in preparation for PQ signatures.

    No consensus changes - a pure performance improvement refactor.

    Motivation

    We presently have MAX_SCRIPT_ELEMENT_SIZE = 520, but in the near future, motivated by the need for PQ signature schemes, we may want to introduce soft fork changes which increase the max stack element size for new output types well into the multi-kilobyte range. This may be perhaps as high as 10kb or more given the current landscape of PQ signature schemes' sizes.

    Ongoing related work:

    This poses a new problem to today's implementations if deployed naively.

    Today's Bitcoin script interpreter moves around raw mutable byte vectors to represent stack elements. When those elements are duplicated, the underlying byte vectors are deep-copied. When hash opcodes execute, they always invoke the corresponding hash function and fully ingest the top stack element. Duplication and hashing cost only a single weight unit in tapscript, with no upper limit on how many times they can be executed, aside from implicit limits due to the 10,000 byte script size maximum.

    If we were to simply up the stack element size limit from 520b to 10kb, then we exacerbate an existing DoS vector on bitcoin nodes: Push a big (today 520b, maybe someday 10kb) stack element, then duplicate and hash it over and over. Worst case would be a script like this:

    <big_blob> DUP DUP [DUP3 RMD160 DROP RMD160 DROP RMD160 DROP]...
    

    ...with the [...] part repeating up to the script size limit. RMD160 is used because it lacks hardware acceleration support and so performs significantly worse than even HASH256 or HASH160, making a more effective CPU attack.

    This costs the attacker only ~20000 weight units (10kb for the script, 10kb for the stack element), but forces all validators to evaluate OP_RIPEMD160 about 4284 times, each on the same 10kb input. That's about 672588 RMD160 compressions in software.

    I ran a benchmark (not in this PR) using this style of script with 520b and 10kb script elements.

    • 520b: validation takes about 3 milliseconds
    • 10,000b: validation takes about 52 milliseconds

    This PR

    (idea credit: EthanHeilman)

    To address this risk, we would want to implement a hash cache for all the hashing opcodes. However, to implement such a cache safely in consensus, we need extremely robust cache keys. A collision in the cache key would be a consensus bug because the cache would return the wrong hash for a given input. But we can't cryptographically hash the inputs to compute a cache key... that is the same work we are trying to avoid with the cache.

    Instead we can kill two birds with two PRs.

    1. Replace stack elements with ref-counting pointers, so OP_DUP et al duplicate pointers and not the underlying raw bytes. :arrow_backward: THIS PR
    2. Implement a hash cache keyed by the stack element pointers. Each cache entry holds a handle to the key pointer so that the key doesn't get dropped and the pointer reallocated for a new stack element (which would cause a consensus bug). Cache key uniqueness is guaranteed by the compiler. :arrow_backward: Future PR

    Stage 1 reduces the asymmetric memory inflation attack allowed by OP_DUP et al. Stage 2 fixes the asymmetric CPU DoS attack. To be clear, neither stage fully prevents an attacker from uploading a series of large stack elements, and duplicating/hashing them. But we do force attackers to pay for those operations with fees and block space.

    I have both stage 1 and 2 implemented locally and can show benchmarks to prove the effect of the cache, if desired. I could have submitted it all as one PR but both stages are atomic and could do well with separate review.

  2. DrahtBot added the label Consensus on Nov 2, 2026
  3. DrahtBot commented at 3:36 AM on November 2, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    <!--006a51241073e994b41acfe9ec718e94-->

    External sites

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline and AI policy for information on the review process. A summary of reviews will appear here.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #36521 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36521.svg"></sub> (Keep witness stacks in serialized form in CScriptWitness by sipa)
    • #36395 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36395.svg"></sub> (script: clarify implicit signature-cache input requirements by l0rinc)
    • #35662 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/35662.svg"></sub> (script: prevent partial reinitialization of sighash data by l0rinc)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  4. in src/script/interpreter.cpp:431 in bc8ec65ab9
     429 |      // static const CScriptNum bnTrue(1);
     430 | -    static const valtype vchFalse(0);
     431 | +    static const ScriptStackElem elemFalse = MakeStackElem(valtype(0));
     432 |      // static const valtype vchZero(0);
     433 | -    static const valtype vchTrue(1, 1);
     434 | +    static const ScriptStackElem elemTrue = MakeStackElem(valtype(1, 1));
    


    Yudis-bit commented at 10:51 AM on November 2, 2026:

    Wait, doesn't elemTrue introduce cross-core atomic contention in CCheckQueue?

    With static const valtype vchTrue(1, 1), reads were const and the cache line remained in Shared (S) state across all core caches without bus traffic.

    Copying a std::shared_ptr executes lock xaddl on _M_use_count. When parallel script verification evaluates a valid block across 16+ worker threads, almost every standard transaction ends in a successful CHECKSIG or EQUAL. All worker threads contend on this single control block, bouncing the cache line across sockets and serializing execution. In local benchmarks, multi-threaded throughput degrades by over 2x (Welch test t = -4.86, p = 1.56e-5).

    Beyond contention, wrapping elements in std::shared_ptr doubles heap allocations on the hot path. Moving a valtype into the stack is a 3-pointer transfer, whereas MakeStackElem allocates a separate 48-byte control block (std::make_shared). Pushing elements becomes ~4x slower (312 ns vs 1310 ns per push).

    Since the interpreter stack machine is thread-local, atomic refcounting on OP_DUP/OP_DROP adds synchronization where no cross-thread sharing exists.


    conduition commented at 8:17 PM on November 2, 2026:

    Wait, doesn't elemTrue introduce cross-core atomic contention in CCheckQueue?

    Good observation. I mitigated by making elemTrue and elemFalse per-execution locals, so distinct script eval threads no longer contend with each other. https://github.com/bitcoin/bitcoin/pull/36721/commits/2405fe20bab4b4f7def9ecf8c3a2f8dc8e150fdd

    Since the interpreter stack machine is thread-local, atomic refcounting on OP_DUP/OP_DROP adds synchronization where no cross-thread sharing exists.

    Yes, the smart pointer we use doesn't need to be atomically synchronized. I chose shared_ptr because it was readily available in std and already in use elsewhere in the code. Coming from a Rust background, i would want to use Rc (a reference counting pointer type with no cross-thread synchronization) for representing ScriptStackElem. However i found no equivalent type in the C++ standard library. shared_ptr is synchronized which we don't need here, it adds overhead. boost::intrusive_ptr is an option i suppose but isn't used elsewhere so i reasoned it'd be best to stick to repo convention. Rolling my own seemed like an even worse option. Do you have any recommendations?

    Beyond contention, wrapping elements in std::shared_ptr doubles heap allocations on the hot path. Moving a valtype into the stack is a 3-pointer transfer, whereas MakeStackElem allocates a separate 48-byte control block (std::make_shared).

    According to the bench_bitcoin results on my machine at least, this doesn't seem to make a big difference in terms of concrete script interpreter performance. The only benchmark that gets meaningfully worse is VerifyNestedIfScript, which slows from 11400 ns to 15600 ns (+38%), probably because its main benchmark workload is stack pushing which this PR does worsen. I expect we could improve that if we use a non-synchronized reference counter type.

    The deltas for all other benchmarks (including for common standard scripts like P2PKH) are within noise.

    If it would help, i could write some benchmarks that more precisely stress-test the allocations that this change introduces.

    In local benchmarks, multi-threaded throughput degrades by over 2x (Welch test t = -4.86, p = 1.56e-5).

    Pushing elements becomes ~4x slower (312 ns vs 1310 ns per push).

    Could you show how you got those numbers? i don't see a dedicated stack-pushing or multi-threaded benchmark so maybe i'm running the wrong harness.


    Yudis-bit commented at 11:47 PM on November 2, 2026:

    Could you show how you got those numbers?

    The contention was measured in CCheckQueue (16 workers) against the static instance prior to 62520cf. Moving them to locals eliminates the bus lock bouncing, but replaces it with two unconditional heap allocations on every EvalScript call before inspecting a single opcode. That allocator churn explains the +38.8% regression in your benchmark.

    The larger issue is architectural. The 10KB duplication attack is consensus-invalid today under MAX_SCRIPT_ELEMENT_SIZE = 520. Any future soft fork that raises element limits must budget its own hashing costs anyway (analogous to BIP 342 sigops), rather than penalizing existing mainnet script evaluation. Also, pointer identity does not imply data equality. Pushing identical elements separately creates distinct allocations, bypassing a pointer-keyed cache entirely while pinning dropped elements in memory.

    Do you have any recommendations?

    I don't recommend alternative smart pointers here. The interpreter hot path shouldn't pay heap or refcounting overhead for a non-existent attack. Concept NACK.


    conduition commented at 8:27 PM on November 3, 2026:

    The contention was measured in CCheckQueue (16 workers) against the static instance prior to 62520cf.

    I ran the CCheckQueueSpeedPrevectorJob benchmark on this branch and it doesn't seem to give any worse results on than master. Could you please provide links or instructions for how to replicate your benchmark?

    That allocator churn explains the +38.8% regression in your benchmark.

    The VerifyNestedIfScript benchmark only allocates those locals once per benchmark loop. One allocation does not a benchmark kill. The other benchmarks that do this (e.g. VerifyScriptP2WPKH) have not regressed. The +38% slowdown in VerifyNestedIfScript i believe is coming from the additional allocations involved in pushing each of the 1000 stack elements (any elements, including elemTrue and elemFalse).

    The larger issue is architectural. The 10KB duplication attack is consensus-invalid today under MAX_SCRIPT_ELEMENT_SIZE = 520. Any future soft fork that raises element limits must budget its own hashing costs anyway (analogous to BIP 342 sigops), rather than penalizing existing mainnet script evaluation.

    We did consider enforcing new consensus rules as a solution. See this section. The most obvious option is to merely prohibit duplication or hashing opcodes from executing on inputs larger than today's 520b limit. A more subtle sigops-like budget system is also an option.

    However, we reasoned that additional implementation complexity (pointers and caching) was worth it for a simpler and more permissive consensus rule set, with less arbitrary discontinuities.

    Also, pointer identity does not imply data equality. Pushing identical elements separately creates distinct allocations, bypassing a pointer-keyed cache entirely while pinning dropped elements in memory.

    I don't think we need be concerned with attacks that push identical elements, because such pushes must be paid for by the attacker in bandwidth and fees. We are concerned with duplicate-and-hash attacks which are cheap and asymmetrical to their validation cost.

    The cache does not need to guarantee "if we hashed the bytes 0xABCD... then we never need to hash them again". We only need to guarantee "if a spender pays to push some large stack elements, we only hash each such element at most once".

    Put another way... It's OK if two distinct pointers refer to the same bytes cause two cache misses. What would NOT be OK would be if a pointer is reallocated to store a different byte vector, after a prior cache insertion for that pointer, leading to a cache hit that yields the wrong hash. We prevent this by pinning any such elements' pointers for the duration of script evaluation. For details, see my working branch for stage 2: https://github.com/conduition/bitcoin/compare/scriptvalptr-stage1..scriptvalptr-stage2

    This design would break if we had opcodes that allowed users to modify or push large stack elements, such as OP_CAT for example. But under the current script system, it seems robust and simple.

  5. DrahtBot added the label CI failed on Nov 2, 2026
  6. DrahtBot commented at 10:55 AM on November 2, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task iwyu: https://github.com/bitcoin/bitcoin/actions/runs/41383997767/job/125213261218</sub> <sub>LLM reason (✨ experimental): CI failed because IWYU added a missing <memory> include to src/script/sign.cpp, leaving a diff that the formatting/lint check rejects.</sub>

    <details><summary>Hints</summary>

    Try to run the tests locally, according to the documentation. However, a CI failure may still happen due to a number of reasons, for example:

    • Possibly due to a silent merge conflict (the changes in this pull request being incompatible with the current code in the target branch). If so, make sure to rebase on the latest commit of the target branch.

    • A sanitizer issue, which can only be found by compiling with the sanitizer and running the affected test.

    • An intermittent issue.

    Leave a comment here, if you need help tracking down a confusing failure.

    </details>

  7. ismaelsadeeq commented at 11:28 AM on November 2, 2026: member

    I looked at this issue in the past and concluded it wasn't worth the effort, but from the angle of increasing the max stack element size, it does become an attack vector.

    Did you benchmark the cost of making the inner vector a shared pointer for current regular scripts? I'd expect it to be slightly worse.

    I think we can do better by turning the stack element into a standalone class that is either a prevector or a std::shared_ptr<std::vector> depending on the element's size. That should be more efficient than the status quo for small elements and no worse for regular scripts, while keeping cheap duplication for large ones.

    Also, my agent and Ralph (https://git.fish.foo/bitcoin/bitcoin/pulls/36721#issuecomment-619311) flagged potential contention on the shared shared_ptr reference count might be worth looking at under parallel validation context.

  8. l0rinc commented at 7:41 PM on November 2, 2026: contributor

    Please fold the fixups back or turn it into a draft to signal that it's not ready for review yet

  9. conduition force-pushed on Nov 2, 2026
  10. conduition commented at 8:21 PM on November 2, 2026: none

    @l0rinc Folded

  11. DrahtBot removed the label CI failed on Nov 2, 2026
  12. conduition commented at 9:21 PM on November 2, 2026: none

    Did you benchmark the cost of making the inner vector a shared pointer for current regular scripts? I'd expect it to be slightly worse. @ismaelsadeeq I used the existing benchmarks and the delta is negligible or better for regular scripts. The only one meaningfully worsened by this PR is VerifyNestedIfScript due to the number of pushes, leading to a +38% slowdown on that benchmark, but we're talking about a difference of a few nanoseconds per push (on my machine). But that script doesn't seem like a good representative for an adversarial attack or for a common happy path. If an attacker wanted to waste CPU time, they'd use hashing opcodes, which are far slower than push opcodes like OP_1, or of course checksig opcodes. If he wanted to consume memory, he'd use OP_DUP.

    Benchmark Before After Delta
    VerifyNestedIfScript 11,140 ns 15,459 ns +38.8%
    VerifyScriptP2WPKH 20,102 ns 21,573 ns +7.3%
    VerifyScriptP2TR_KeyPath 21,424 ns 20,435 ns -4.6%
    VerifyScriptP2TR_ScriptPath 38,347 ns 36,244 ns -5.5%

    run-to-run noise on my machine seems to be +/- 7%, so everything but VerifyNestedIfScript was well within that.

    I did not go so far as to write any new benchmarks, but I will try to hammer some together that better demonstrate the costs this PR introduces.

    I think we can do better by turning the stack element into a standalone class that is either a prevector or a std::shared_ptrstd::vector depending on the element's size. That should be more efficient than the status quo for small elements and no worse for regular scripts, while keeping cheap duplication for large ones.

    Neat idea. In my current stage 2 impl, the hash cache has a minimum input size needed to trigger it (currently set to 128 bytes). If we arrange so that the prevector's small-buffer size limit matches the hash cache minimum threshold, then we'd never hit the prevector's small-buffer size limit. Add in the fact that stack elements are never grown, so their size is fixed at constructor time. So we could have a ScriptStackElem as a struct which embeds a union of char[127] and struct { char* indirect; size_type capacity }. Any value smaller than 128 bytes would fit in the statically allocated array. Any value larger would go to the heap.

    Is that worth the complexity in consensus-critical code though? the benchmarks don't seem to indicate any major issues with the present design. @Yudis-bit did find benchmarks that slowed down, but i need to see how he got those numbers first before i can figure out why.

  13. ismaelsadeeq commented at 11:01 AM on November 3, 2026: member

    The only one meaningfully worsened by this PR is VerifyNestedIfScript … a few nanoseconds per push

    Yeah, I was just after the numbers. Signature verification dominates the interpreter today, so per-push cost is noise. But that's an artifact of where the cost sits right now, not a permanent property: if sigop verification moves off the hot path — batched or offloaded (e.g. to a GPU), with optimistic script execution that defers the actual signature checks and only fails later if one doesn't verify — then stack-op work becomes the dominant interpreter cost, and a few ns per push starts to matter. So the +38% on pushes isn't irrelevant forever; it's just hidden behind ECDSA/Schnorr for now.

    I will try to hammer some [benchmarks] together…

    Nice, curious to see them.

    ScriptStackElem as a struct … union of char[127] and { char* indirect; size_type capacity }

    Really like this.

    Is that worth the complexity in consensus-critical code though?

    Yep, thin motivation while the underlying proposal is speculative. But it's a nice problem to brainstorm, and if we're doing the refactor anyway, doing the more optimal change is better for me.

    Another potential direction is to have the (proposed sigop) opcode read from multiple stack elements instead of one. Then the signature stays as ≤520-byte chunks and gets streamed into the verifier, and we never raise the element-size limit at all, so the large-element attack surface is never opened in the first place.

  14. conduition force-pushed on Nov 3, 2026
  15. conduition commented at 9:01 PM on November 3, 2026: none

    @ismaelsadeeq Benchmarks pushed. I added them as earlier commits so they can be tested with or without the ScriptStackElem changes.

    Benchmark Name Before (ns) After (ns) Delta
    VerifyScriptP2SH_P2WPKH 21,328 21,387 +0.3%
    VerifyPushDropSmall 52,119 81,063 +55.5%
    VerifyDupDropLarge 108,646 48,862 -55.0%

    So basically it seems like this change makes simple data pushing slower, but makes duplication much cheaper. I will look into the union type idea and see whether that improves the situation at all.

    Another potential direction is to have the (proposed sigop) opcode read from multiple stack elements instead of one. Then the signature stays as ≤520-byte chunks and gets streamed into the verifier, and we never raise the element-size limit at all, so the large-element attack surface is never opened in the first place.

    We did consider this type of chunking as an option. Ultimately this would be a trade-off that passes complexity down to wallet developers and users. This gets especially hairy when we consider that some signature algorithms like SHRINCS have variable-size signatures. So we decided against this route, full explanation here

  16. conduition commented at 6:16 AM on November 4, 2026: none

    after some experimenting with the union-based approach, i could not make it perform meaningfully better than a simple intrusive ref-counter which seemed much less error-prone. Experimental intrusive ref-counting code pushed to this branch. It achieves performance at or better than master on these benchmarks.

    Benchmark 104c7a02 scriptvalptr-stage1 (8b9ef52a) scriptvalptr-stage1-refctr (ad6cef8b2)
    VerifyScriptP2WPKH 20133 20373 20432
    VerifyScriptP2SH_P2WPKH 21357 21263 20741
    VerifyScriptP2TR_KeyPath 20454 20283 20433
    VerifyScriptP2TR_ScriptPath 36100 36069 36621
    VerifyNestedIfScript 11592 15890 9938
    VerifyPushDropSmall 55154 85622 48993
    VerifyDupDropLarge 110920 49964 38022
    VerifyPushDropSized8 1823 2575 1853
    VerifyPushDropSized24 2124 2825 2114
    VerifyPushDropSized40 2444 3236 2414
    VerifyPushDropSized56 2735 3477 2715
    VerifyPushDropSized520 2765 2906 2706

    The cost of course is the greater impl complexity, using a homebrewed pointer type to handle stack elements is risky. If you think the performance is worth it, i'll update this PR branch.

    EDIT: updated with additional benchmarks

  17. conduition force-pushed on Nov 4, 2026
  18. ismaelsadeeq commented at 4:01 PM on November 4, 2026: member

    We did consider this type of chunking as an option. Ultimately this would be a trade-off that passes complexity down to wallet developers and users. This gets especially hairy when we consider that some signature algorithms like SHRINCS have variable-size signatures. So we decided against this route, full explanation here

    I started reading the proposal, and the branching inside OP_CHECKSIG on whether the signature is stateful or stateless feels a bit icky; those are two different schemes, and bundling them into one leaf checksig is where some of the complexity comes from?

    I think it'd be better to split them into separate BIPs for stateful SHRINCS and stateless SHRINCS, and commit both as leaves in a taproot tree. Then which scheme runs is decided by the leaf you reveal, not by checking the signature mid-opcode eval. (I'd recommend this even if we end up increasing the script element size.)

    On the variable-size point from full explanation here, splitting actually makes it okayish. The stateless leaf is fixed-size, a fixed n-element read, no decisions. The stateful leaf is variable, and we can do like a self-delimiting read, read chunks into the verifier, which already has the length from an indicator byte in the first chunk, and stop once it has that many bytes capped at a max. This way, no spender supplied count, the stop comes from the signature's own (verified) length, so a lie just fails, and the cap means a garbage indicator byte can at worst make you read n elements. Statically then "this opcode pops ≤n, known from the leaf".

    (You could instead pad to a fixed n for the stateful leaf with empties to make the pop count constant, but that's a bit of a hack) I think this is a far smaller thing to reason about than multi-KB elements across the whole interpreter (it has a wider scope).

    I think this is ideally the same with the current version of the shrincs proposal?, both leaves under one output are still one address that can always spend, stateful or stateless.

    re: #36721 (comment)

    Nice benchmarks, thanks for the results.

  19. conduition commented at 5:14 PM on November 4, 2026: none

    I started reading the proposal, and the branching inside OP_CHECKSIG on whether the signature is stateful or stateless feels a bit icky; those are two different schemes, and bundling them into one leaf checksig is where some of the complexity comes from?

    i should disambiguate here: the multiplexed OP_CHECKSIG defined in that BIP is designed to deploy new signature algorithms like SHRINCS, or maybe someday SQIsign/Falcon/whatever. That BIP doesn't know anything about the stateful vs stateless signing components in SHRINCS. It treats every signature scheme as a black box verification subroutine that takes a triple of byte strings in, and returns success/fail. SHRINCS is defined as one single unified signature scheme within that framework. I should also mention that BIP is presently being rewritten, to remove SHRINCS-specific language

    SHRINCS' verification subroutine does indeed branch based on the signature's leading 'indicator' byte - that is a hard requirement to achieve its design properties. But to the script interpreter, that branching is completely black-box. It is not part of script.

    If we chunked signatures into 520b elements though, and relied on the signature itself to tell us how many chunks to pop, we could not use SHRINCS (or any variable-size signature scheme, for that matter) as a black box anymore. Increasing the stack element size limit allows us to keep that black-box compatibility with any signature scheme up to the new size limit.

    Here is the current shrincs_verify reference code in case you're interested.

    I think it'd be better to split them into separate BIPs for stateful SHRINCS and stateless SHRINCS, and commit both as leaves in a taproot tree. Then which scheme runs is decided by the leaf you reveal, not by checking the signature mid-opcode eval. (I'd recommend this even if we end up increasing the script element size.)

    We did consider a design where the stateful and stateless components are entirely separate schemes, whose keys can be stashed in disjoint tapscript leaves. However this would severely complicate scripting. Think about how you'd design a 2-of-3 multisig output, where each signer has two possible keys they may use, and we don't know which one they'll use until signing time. Every pubkey would need IF/ELSE branching, and every signature would need a true/false decision push (e.g. you sign stateless, i sign stateful, and vice versa, would use completely different pubkeys under this paradigm).

  20. script: remove the overload wrapper around EvalScript
    Fold the two EvalScript declarations into a single one by defaulting the
    ScriptExecutionData argument, so a caller with no execution data to pass simply
    omits it instead of going through an overload wrapper.
    
    Callers that pass a ScriptError pointer must give a nullptr in this position,
    and the two fuzz targets that passed a bare nullptr drop the argument entirely.
    This is pure refactoring with no behaviour change.
    d767b51bde
  21. tests: add benchmark for P2SH-wrapped P2WPKH script validation 9f5e67bf8e
  22. tests: add benchmarks for stack pushing a93ec340f8
  23. script: store interpreter stack elements as reference-counted objects
    The interpreter holds each stack item by value, so every opcode that
    duplicates or reorders items copies their contents. A future output type
    may soon raise the maximum stack element size well past today's 520 bytes,
    at which point a few weight units of script would be enough to drive
    megabytes of interpreter memory.
    
    Store the items as reference-counted objects instead: each element is
    materialized once per execution and shared from then on, and each gains a
    stable identity that later work can use as keys for caching. Every caller
    of the interpreter uses the new stack type. Opcode semantics unchanged.
    d5cf2f361e
  24. script: test element sharing on the interpreter stack
    Add unit tests for the interpreter's reference-counted stack: that duplicating and
    reordering stack items share a single element rather than copying it, and that an
    element is released once no stack slot holds it any more. No behaviour changes.
    40a419af63
  25. conduition force-pushed on Nov 5, 2026
  26. conduition commented at 4:32 AM on November 5, 2026: none

    Fixed IWYU CI (and figured out how to run the CI on my own fork)

  27. ismaelsadeeq commented at 3:27 PM on November 5, 2026: member

    It treats every signature scheme as a black box … SHRINCS is one single unified scheme within that framework.

    Clearer, thanks. I think https://github.com/SHRINCS/shrincs-bip/pull/76 approach is better, the leaf-version spec becomes a generic sig check, with each scheme defined in its own BIP. That makes the black-box boundary explicit, which is what I was missing. So scheme choice stays committed per key/leaf (the flag lives in the pubkey). With that rewrite reserving 0x01 and moving SHRINCS out to its own BIP/flag, a scheme-flagged pubkey locks that spending path to that scheme, and only new outputs adopt future schemes. Is that the intended upgrade hook?

    SHRINCS branches on the indicator byte … but to the interpreter that branching is completely black-box.

    Cool, the pubkey flag picks the scheme (e.g. SHRINCS vs BIP340), and within SHRINCS the indicator byte picks stateful vs stateless. From the interpreter's side, it's one indivisible unit; it never looks inside the sig. Makes sense.

    Increasing the stack element size limit keeps black-box compatibility up to the new size limit.

    Alternative that keeps black-box without raising the limit: the pubkey flag already tells you the scheme, so you can bound the max pop count up front (for SHRINCS). For the exact count, OP_CHECKSIG pops the pubkey and the first chunk, then lets the verifier read the indicator byte and say how many more chunks it needs, pops them, and verifies the concatenation. The interpreter never parses the sig itself, the verifier returns an opaque, bounded count, so it stays black-box; the signature just arrives as ≤520-byte pieces. (Inspired by https://github.com/SHRINCS/shrincs-bip/issues/3#issuecomment-4918125246.) And when you add a new flag the max can be adapted as well?

    If we do raise the script element size: keeping the 1000-items stack limit means the total stack-data ceiling goes up by a lot. Do you plan to pair that with a lower max stack element count or a max stack bytes budget too?

    … separate stateful/stateless schemes would severely complicate scripting.

    Indeed, doesn't work for multisig on one pubkey. The two independent leaves in a tree will not play well.

    Increasing the stack element size limit keeps black-box compatibility up to the new size limit.

    Will await others to chime in and also follow the development. Not sure about https://github.com/SHRINCS/shrincs-bip/blob/4fe68e3a68fd58be6b0e19436ff6fb7a3357a291/leaf-version-0xC2.mediawiki#user-content-Relaxing_the_Max_Stack_Element_Size, still leaning towards the chunking idea.


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-11-09 21:51 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me