Skip to content

Sign lattice + EC reduction sinking: p256-wallet 304,463 → 179,890 B - #158

Closed
icellan wants to merge 1 commit into
feat/script-size-optfrom
feat/ec-reduction-sinking
Closed

Sign lattice + EC reduction sinking: p256-wallet 304,463 → 179,890 B#158
icellan wants to merge 1 commit into
feat/script-size-optfrom
feat/ec-reduction-sinking

Conversation

@icellan

@icellan icellan commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Stacked on #156 (targets feat/script-size-opt, not main) because reduction sinking only
pays 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 by
default, all 72 goldens still reproduce byte-for-byte, script-size-check 72/72 ok.

Results

fixture shipping + pool (#156) + sinking
p256-wallet 958,792 304,463 179,890 (−81.2 %)
p384-wallet 1,963,300 463,435 272,678 (−86.1 %)
p384-primitives 1,883,767 447,707 261,534 (−86.1 %)
ec-primitives / ec-demo 1,332,782 433,880 258,303 (−80.6 %)
p256-primitives 928,219 296,303 173,640 (−81.3 %)
schnorr-zkp 875,189 281,675 165,848 (−81.1 %)
ec-unit 479,716 157,129 93,678 (−80.5 %)
convergence-proof 452,386 147,052 87,302 (−80.7 %)

The 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 the
available 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 stay Unknown by
design.

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 two
paths need different facts:

  • multiply / add / mulconst need dividend ≥ 0. Unsigned coordinate decoding already
    gives it, so ~70 % of reductions qualify with almost no analysis.
  • 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 can 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

Reachable only through the unguarded bare builtins — verifyECDSA_* and onCurve run a
canonicity guard first — which is why no plausible-input test finds it.

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 routes through pushTracked / popTracked /
removeSlotAt; and domainOf throws if the lengths ever differ, because a silent desync
hands 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:

producer fact
fieldAdd / fieldMul non-negative iff both operands are
fieldSqr non-negative unconditionally (a·a ≥ 0 for any a)
fieldMulConst keeps the operand's sign for positive c
any reduction Reduced
decoded coordinate NonNegative, never Reduced
rawBlock, OP_IF join Unknown
group-order (mod n) reduction NonNegative only — never Reduced, so a value reduced mod n can never be mistaken for one reduced mod p

Whether to use the cheap subtraction is a cost comparison (cheapSubPays) against the
pooled 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.ts is deliberately a differential sweep over the boundary rather
than 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 for
ecAdd and p256Add, and requires the sunk script's result to equal the shipping one on
every 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.

  • 4,103 compiler tests pass
  • 1,685 testing / CLI / conformance tests pass
  • golden-invariance 72/72, script-size-check 72/72 ok

Not 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-stamping
conformance/script-size-baseline.json, and adding provenance entries.

…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.
@icellan

icellan commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #160, which consolidates this branch with the other two into a single PR. No commits are lost — #160 contains all seven, and the combined measurement is in its description.

@icellan icellan closed this Aug 29, 2026
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