Sign lattice + EC reduction sinking: p256-wallet 304,463 → 179,890 B - #158
Closed
icellan wants to merge 1 commit into
Closed
Sign lattice + EC reduction sinking: p256-wallet 304,463 → 179,890 B#158icellan wants to merge 1 commit into
icellan wants to merge 1 commit into
Conversation
…ion-sinking
`fieldMod` costs 10 bytes and runs ~20,000 times in a P-256 verify. Six of them
are a sign fix-up that exists only because OP_MOD takes the sign of the
dividend. Where the dividend is provably non-negative they are dead.
p256-wallet 958,792 -> 304,463 (pool) -> 179,890 (-81.2%)
p384-wallet 1,963,300 -> 463,435 -> 272,678 (-86.1%)
ec-primitives 1,332,782 -> 433,880 -> 258,303 (-80.6%)
ec-unit 479,716 -> 157,129 -> 93,678 (-80.5%)
That is within 94 bytes of the measured ceiling for this transformation
(179,796 on p256-wallet, docs/experiments §3.7), so the lattice recovers
essentially all of the available win. Opt-in and inert by default: all 72
goldens still reproduce byte-for-byte and script-size-check is 72/72 ok.
WHY A LATTICE AND NOT A REWRITE
The two paths need different facts, and conflating them is not hypothetical —
the ceiling measurement did exactly that, passed 256 EC oracle assertions, and
was still wrong:
- multiply / add / mulconst need `dividend >= 0`. Unsigned coordinate
decoding already gives it, so ~70% of reductions qualify immediately.
- subtract's cheap `a - b + p` form needs the strictly stronger
`subtrahend < p`, which OP_BIN2NUM of 32 unsigned bytes does NOT imply: a
coordinate may exceed p by up to 2^32 + 977.
ecAdd((0, 1), (2^256 - 1, 1))
correct : ...fffffffdfffff85f
blanket : ...0001000003d0 0x1000003d0 = 2^32 + 977 = 2^256 - p
IMPLEMENTATION
`Dom` is a three-point lattice (Unknown < NonNegative < Reduced) carried as
`ECTracker.dm`, a SLOT-parallel array to `nm` rather than a name-keyed map:
names are reused (`_fmul_prod` is written by every multiply) and the same name
can be resident twice, so a map would go stale in exactly the cases that matter.
Every `nm` mutation mirrors into `dm` with the same splice, external mutation
now goes through pushTracked/popTracked/removeSlotAt, and `domainOf` throws if
the arrays ever differ in length — a silent desync would hand a transfer
function a fact about the wrong slot, which is the one failure mode that yields
a smaller script that quietly computes something else.
Transfer functions: add/mul are non-negative iff both operands are; a square is
non-negative unconditionally; mulconst keeps the operand's sign for positive c;
every reduction result is Reduced; a decoded coordinate is NonNegative but never
Reduced. Anything a rawBlock or an OP_IF produces stays Unknown, so an
un-analysed value can only fall back to the shipping reduction. Group-order
reductions are deliberately left at NonNegative rather than Reduced, so a value
reduced mod n can never be mistaken for one reduced mod p.
The cheap subtraction references the prime twice, so whether to use it is a cost
comparison (`cheapSubPays`) against the pooled push cost, not a flag: without
`--ec-constant-pool` it would make p256-wallet larger.
TESTING
`ec-reduction-sinking.test.ts` is a differential sweep, not a signature check —
that question was already answered wrongly once. It runs every emitter over the
coordinate values on the boundary (0, 1, 2, p-1, p, p+1, 2^256-1, G.x) including
the full 8x8 cross product for ecAdd and p256Add, and requires the sunk script's
RESULT to match the shipping one on every combination, including ones no valid
curve point could produce. The counterexample above is pinned by name. An
absolute OpenSSL oracle then re-checks accept plus seven rejection cases.
This was referenced Aug 29, 2026
Owner
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #156 (targets
feat/script-size-opt, notmain) because reduction sinking onlypays alongside the constant pool: the cheap subtraction references the prime twice, so
without a pooled slot it is a regression.
Implements brief Phases 4–5 for the EC codegen. Opt-in via
--ec-reduction-sinking; inert bydefault, all 72 goldens still reproduce byte-for-byte,
script-size-check72/72 ok.Results
p256-walletp384-walletp384-primitivesec-primitives/ec-demop256-primitivesschnorr-zkpec-unitconvergence-proofThe ceiling for this transformation was measured first (#156, §3.7) at 179,796 bytes on
p256-wallet. This lands 94 bytes off it, so the lattice recovers essentially all of theavailable win rather than a fraction of it. The residual is reductions whose dividend the
analysis cannot prove non-negative — values joined across an
OP_IF, which stayUnknownbydesign.
Why a lattice rather than a rewrite
The ceiling measurement was the blanket rewrite. It passed 256 EC oracle assertions —
OpenSSL signatures on both curves,
ec-on-curve-canonicity,ec-degenerate-add,ec-mul-scalars,p256-p384-scalars,p256-p384-ecdsa-verify— and was still wrong. The twopaths need different facts:
dividend ≥ 0. Unsigned coordinate decoding alreadygives it, so ~70 % of reductions qualify with almost no analysis.
a − b + pform needs the strictly strongersubtrahend < p, whichOP_BIN2NUMof 32 unsigned bytes does not imply — a coordinate can exceedpby up to2^32 + 977.
Reachable only through the unguarded bare builtins —
verifyECDSA_*andonCurverun acanonicity guard first — which is why no plausible-input test finds it.
Implementation
Domis a three-point lattice (Unknown < NonNegative < Reduced) carried asECTracker.dm, a slot-parallel array tonmrather than a name-keyed map. Names arereused (
_fmul_prodis written by every multiply) and the same name can be resident twice, soa map would go stale in exactly the cases that matter. Every
nmmutation mirrors intodmwith the same splice; external mutation now routes through
pushTracked/popTracked/removeSlotAt; anddomainOfthrows if the lengths ever differ, because a silent desynchands a transfer function a fact about the wrong slot — the one failure mode that produces a
smaller script that quietly computes something else.
Transfer functions:
fieldAdd/fieldMulfieldSqrfieldMulConstcReducedNonNegative, neverReducedrawBlock,OP_IFjoinUnknownmod n) reductionNonNegativeonly — neverReduced, so a value reduced mod n can never be mistaken for one reduced mod pWhether to use the cheap subtraction is a cost comparison (
cheapSubPays) against thepooled push cost, not a flag — it references the prime twice, so it is only a win once the
prime is a 2-byte pick.
Testing
ec-reduction-sinking.test.tsis deliberately a differential sweep over the boundary ratherthan a signature check — that question was answered wrongly once already. It runs every
emitter over
0, 1, 2, p−1, p, p+1, 2^256−1, G.x, including the full 8×8 cross product forecAddandp256Add, and requires the sunk script's result to equal the shipping one onevery combination — including ones no valid curve point could produce. The counterexample is
pinned by name. An absolute OpenSSL oracle then re-checks accept plus seven rejection cases,
and a non-vacuity test fails if any emitter did not actually shrink.
golden-invariance72/72,script-size-check72/72 okNot in scope
The 7-tier port. Same posture as #156: the flag ships off, and landing it for real means
porting to
compilers/{go,rust,python,ruby,zig,java}, regenerating 9 goldens, re-stampingconformance/script-size-baseline.json, and adding provenance entries.