Skip to content

Keep data that nothing uses, and standard templates, byte for byte - #7

Merged
codenlighten merged 2 commits into
mainfrom
keep-data-and-templates
Sep 18, 2026
Merged

codenlighten merged 2 commits into
mainfrom
keep-data-and-templates

Conversation

@codenlighten

@codenlighten codenlighten commented Sep 17, 2026

Copy link
Copy Markdown
Owner

A script can be shorter without being the same output. A survey of 385 mainnet blocks (25,516 transactions, 77,908 outputs) found the optimizer making that mistake on real scripts:

script before after what was lost
<document> OP_DROP <pubkey> OP_CHECKSIG 121 B 35 B the document (289 outputs in the sample)
<pubkey> OP_CHECKSIG <tag> <fields…> OP_2DROP OP_2DROP OP_DROP 183 B 35 B the signed protocol fields (79 outputs)
<hash> 21e8 OP_SIZE … OP_DROP OP_CHECKSIG 46 B 10 B a Boost puzzle's content hash
OP_2 <key A> <key B> <key A> OP_3 OP_CHECKMULTISIG 201 B 136 B the bare multisig template

Each rewrite is correct: the script does exactly what it did. But the first three delete what the output exists to carry, and the fourth reuses the repeated key with OP_OVER, so the output is no longer a bare multisig, which wallets, indexers and relay policy recognise by its exact bytes.

Unused data is kept

A push is unused when no operation takes its value as an operand (folded operands and PICK/ROLL indices included) and it is not left on the stack for code after its region. That is what a data carrier looks like. Such pushes are now barriers: kept byte for byte with their encoding, never crossed, and checked by the proof like any other barrier.

  • Where a region holds an unused push of dataMinBytes (default 2) or more, every unused push in it is kept, one-byte fields and empty pushes included: those are as much a part of a payload as the rest.
  • keepData: false (--drop-unused-data) restores the old behaviour. report.kept.dataPushes counts what was kept, and a warning says so.
  • Used constants are unaffected: aabbccdd aabbccdd OP_EQUAL OP_VERIFY OP_1 still folds to OP_1.

Finding this needs to know which values an operation consumed, so SymState records that when asked (trackUses).

Standard templates are returned unchanged

P2PKH, P2PK, P2SH, bare multisig and OP_RETURN data outputs come back byte for byte, with report.kept.template naming the template. templates: false (--rewrite-templates) optimizes them anyway.

No cost to contract code

Run over all 72 script templates from the survey, at dataMinBytes 1, 2, 4 and 8 against the protections off:

  • dataMinBytes: 2 (the default): every OP_PUSH_TX contract saves exactly what it saved before. The only scripts that change are the data carriers and the multisigs above, which are now left alone.
  • 1 costs three contracts 10–14 bytes each; 8 lets short tags (dylan1, NOTE) be deleted.

Byte-identical output, compared by SHA-256, on: the Miller loop (333,031 → 199,147), the final exponentiation (473,562 → 284,713), field-300.hex, sha256.block, g1.inSubgroup, fp12.mul and ec.add. The 19-module harness from script-high-level-modules is unchanged too (74,362 → 57,462 total), with every honest case passing and every refusal refused.

Tests

  • Unit: the three data carriers above come out byte for byte (and shrink with keepData: false); the Boost puzzle keeps its hash while its code still shrinks; used constants still fold; the repeated-key multisig is untouched (and shrinks with templates: false); each template is named.
  • Fuzz: 300 scripts of data pushes interleaved with code and dropped in every shape. Every unused push of two or more bytes appears in the output, in order, and the result still matches on the interpreter.
  • Both fail if the protections are disabled. npm test: 30/30.

Second commit: the survey this came from

examples/mainnet-survey.js is the tool, and docs/mainnet-survey.md the results. It samples blocks from the Genesis upgrade to the tip through WhatsOnChain, classifies every output's locking script, groups the non-standard ones into templates and optimizes one script of each. It only reads public data, paces its requests (350 ms apart, retried on 429) and resumes where it stopped, so collect can be run in short sessions.

On 240 blocks, 15,564 transactions and 59,323 outputs holding 27.5 MB of locking script:

script outputs bytes
non-standard 1.9% 52.3%
OP_RETURN data output 45.0% 41.2%
P2PKH + inscription 0.4% 3.7%
P2PKH 52.6% 2.8%
P2PK, P2PKH + data 0.1% 0.0%

The saving is 0.40% of all locking-script bytes, almost all of it in contracts that check their own transaction: compiled contracts give up a fifth to a third of their bytes (a 200 KB one from 2026: −32,081 B per output), while the most common hand-tuned covenant gives up 5 bytes of 1,341. A wider 385-block sample gave 0.96% overall and 21% of OP_PUSH_TX contract bytes; how much a sample catches depends on which contract outputs it includes.

The document states the caveats: one sample, one script per template, locking scripts only, and savings that apply to a contract deployed from the optimized script, since these contracts commit to their own code.

The two commits are separate; this one changes no optimizer behaviour (it adds the tool, the document, a README paragraph and .survey/ to .gitignore). I would have put it in its own pull request stacked on this one, but that needed a force push, which is disabled here.

codenlighten added 2 commits September 17, 2026 13:53
A script can be shorter without being the same output. A survey of 385 mainnet
blocks found two cases where the optimizer was making that mistake:

  <document> OP_DROP <pubkey> OP_CHECKSIG      121 B -> 35 B
  <hash> 21e8 OP_SIZE ... OP_DROP OP_CHECKSIG   46 B -> 10 B (a Boost puzzle)
  OP_2 <key A> <key B> <key A> OP_3 OP_CHECKMULTISIG   201 B -> 136 B

The first two carry data: a push no operation takes as an operand and that is
not left on the stack. Removing it leaves the script doing the same thing, but
the output no longer carries what it was made to carry. Such pushes are now
barriers, kept with their encoding; when a region carries data, its one-byte
fields and empty pushes are kept too, since they are part of the payload.
keepData: false (--drop-unused-data) restores the old behaviour, and
dataMinBytes (default 2) sets the length that marks a region as carrying data.

The third is a bare multisig with a repeated key: reusing it with OP_OVER saves
65 bytes and stops the output being a bare multisig, which wallets, indexers
and relay policy recognise by its exact bytes. P2PKH, P2PK, P2SH, bare multisig
and OP_RETURN data outputs are now returned unchanged; templates: false
(--rewrite-templates) optimizes them anyway.

Finding unused values needs to know which ones an operation consumed, folded
operands and PICK/ROLL indices included, so SymState records that on request.

No effect on generated contract code: across the 72 mainnet script templates
the savings are the same as with the protections off, and the Miller loop,
final exponentiation and 19 other module scripts come out byte for byte as
before.
examples/mainnet-survey.js samples blocks from the Genesis upgrade to the tip
through WhatsOnChain, classifies every output's locking script, groups the
non-standard ones into templates and optimizes one script of each. It only
reads public data, paces its requests, and resumes where it stopped.

On 240 blocks (15,564 transactions, 59,323 outputs, 27.5 MB of locking script):
half the outputs are P2PKH holding 2.8% of the bytes, 41.2% of the bytes are
OP_RETURN data, and non-standard scripts are 1.9% of outputs but 52.3% of the
bytes. The saving is 0.40% of all locking-script bytes, nearly all of it in
contracts that check their own transaction: compiled contracts give up a fifth
to a third of their bytes, a hand-tuned covenant 5 bytes of 1,341.

docs/mainnet-survey.md has the numbers, what they mean for who would use this,
and the caveats: one sample, one script per template, locking scripts only, and
savings that apply to a contract deployed from the optimized script.
@codenlighten
codenlighten merged commit d9d166b into main Sep 18, 2026
5 checks passed
@codenlighten
codenlighten deleted the keep-data-and-templates branch September 18, 2026 00:10
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.

1 participant