Skip to content

Store cv and rk in their compressed encodings - #194

Draft
elicbarbieri wants to merge 1 commit into
zcash:mainfrom
elicbarbieri:perf/lazy-point-decompression
Draft

Store cv and rk in their compressed encodings#194
elicbarbieri wants to merge 1 commit into
zcash:mainfrom
elicbarbieri:perf/lazy-point-decompression

Conversation

@elicbarbieri

Copy link
Copy Markdown
scenario, per description main after
parse a Spend (from disk) 2 sqrt 0
parse an Output (from disk) 1 sqrt 0
serialize a bundle (p2p, RPC) 1 inversion per cv 0
txid / sighash digest 1 inversion per cv 0
verify a Spend 4 sqrt + 1 inversion 3 sqrt
verify an Output 2 sqrt + 2 inversions 2 sqrt

Same shape as the companion orchard PR. Sapling bundle costs 2 sqrt per Spend and 1 per Output to read off disk & parse, plus one inversion per cv to write back to the wire. The peer receiving them does not use the decompressed points

This PR brings parse/serialize down to 0 elliptic curve arithmetic, and moves to verify path.

Curve operations, per description

operation main after where
cv decompress (sqrt) 1 1 parse -> check_spend / check_output
rk decompress (sqrt) 2 1 parse + check_spend -> check_spend
rk decompress in the reddsa batch 1 1 batch::Verifier::verify, unchanged
epk decompress (sqrt) 1 1 the caller -> check_output
cv -> affine, for the circuit 1 0 cv.as_inner().to_affine()
epk -> affine, for the circuit 1 0 epk.to_affine(), Output only

This also removes 288 bytes per Spend (160 for rk, 128 for cv) and 128 bytes per Output. A Jubjub ExtendedPoint carries five field elements (U, V, Z, T1, T2) so that group arithmetic is fast during verification. Not worth it for types sent over the wire then stored

// before
pub struct SpendDescription<A: Authorization> {
    cv: ValueCommitment,                          // jubjub::ExtendedPoint, 160 bytes
    rk: redjubjub::VerificationKey<SpendAuth>,    // point + cached bytes, 192 bytes
    zkproof: A::SpendProof,
    ..
}
pub struct OutputDescription<Proof> {
    cv: ValueCommitment,
    ephemeral_key: EphemeralKeyBytes,             // <- already bytes, rules in check_output
    ..
}

// after — cv and rk stored as Bytes
pub struct SpendDescription<A: Authorization> {
    cv: ValueCommitmentBytes,
    rk: redjubjub::VerificationKeyBytes<SpendAuth>,
    zkproof: A::SpendProof,
    ..
}

SpendDescriptionV5, OutputDescription and OutputDescriptionV5 change the same way.
ValueCommitment itself is untouched

API

// new
primitives::InvalidPoint
primitives::decompress_not_small_order(..)                // crate-internal; one rule, one place
value::ValueCommitmentBytes
    to_bytes()   -> [u8; 32]
    decompress() -> Result<(ValueCommitment, ValueCommitmentCoords), InvalidPoint>
value::ValueCommitmentCoords                              // u(), v() no inversion per access

// changed — every field of a description now enters verification as wire bytes
SaplingVerificationContext::check_spend
    cv      -> &ValueCommitmentBytes
    rk      -> &redjubjub::VerificationKeyBytes<SpendAuth>
    zkproof -> &GrothProofBytes                           // was bellman::groth16::Proof<Bls12>
SaplingVerificationContext::check_output
    cv      -> &ValueCommitmentBytes
    epk     -> &zcash_note_encryption::EphemeralKeyBytes  // was jubjub::ExtendedPoint
    zkproof -> &GrothProofBytes
bundle::SpendDescription::{from_parts, cv, rk}
bundle::SpendDescriptionV5::from_parts
bundle::OutputDescription::{from_parts, cv}
bundle::OutputDescriptionV5::from_parts
note_encryption::prf_ock                                  // cv -> &ValueCommitmentBytes
note_encryption::SaplingDomain::ValueCommitment           // now ValueCommitmentBytes

// removed — a second constructor enforcing the same two rules, with no coordinates
ValueCommitment::from_bytes_not_small_order               // -> ValueCommitmentBytes::decompress

Validation Rules

rule before after
cv canonical encoding deserialization check_spend / check_output
cv not small order deserialization check_spend / check_output
rk canonical encoding deserialization check_spend
rk not small order check_spend unmoved
epk not small order check_output unmoved
epk canonical encoding the caller check_output
zkproof canonical length the caller check_spend / check_output
// batch.rs, before — two consensus rules enforced in the caller
let epk = match jubjub::ExtendedPoint::from_bytes(&output.ephemeral_key().0).into() {
    Some(p) => p,
    None => return false,
};
let zkproof = match groth16::Proof::read(&output.zkproof()[..]) {
    Ok(p) => p,
    Err(_) => return false,
};
let consensus_rules_passed = ctx.check_output(output.cv(), *output.cmu(), epk, zkproof, ..);

// after — hand over the bytes; check_output owns every rule
let consensus_rules_passed = ctx.check_output(
    output.cv(), *output.cmu(), output.ephemeral_key(), output.zkproof(), ..);

Verification context

// before — check_spend trusted the parser
fn check_spend(&mut self, cv: &ValueCommitment, .., rk: &VerificationKey<SpendAuth>, ..) -> bool {
    // The "cv is not small order" happens when a SpendDescription is deserialized.
    // This happens when transactions or blocks are received over the network, [...]
    let rk_affine = jubjub::AffinePoint::from_bytes((*rk).into()).unwrap();   // <- re-derives
    if rk_affine.is_small_order().into() { return false; }
    self.cv_sum += cv;

// after — same rules, enforced instead of assumed
fn check_spend(&mut self, cv: &ValueCommitmentBytes, .., rk: &VerificationKeyBytes<SpendAuth>, ..) -> bool {
    let (cv, cv_coords) = match cv.decompress() {       // canonical AND not small order
        Ok(cv) => cv,
        Err(_) => return false,
    };
    let rk_affine = match decompress_not_small_order(&(*rk).into()) {   // canonical AND not small order
        Ok(rk_affine) => rk_affine,                     // canonical
        Err(_) => return false,                         // not small order (unmoved)
    };
    self.cv_sum += &cv;

Building the circuit public inputs

// before — once per Spend and once per Output
{
    let affine = cv.as_inner().to_affine();          // field inversion, undoing the decompression
    public_input[0] = affine.get_u();
    public_input[1] = affine.get_v();
}
{
    let affine = epk.to_affine();                    // field inversion (Output only)
    public_input[2] = affine.get_u();
    public_input[3] = affine.get_v();
}

// after — both already affine
public_input[0] = cv_coords.u;
public_input[1] = cv_coords.v;
public_input[2] = epk_affine.get_u();
public_input[3] = epk_affine.get_v();

The batch validator

Peer-triggerable Batch verification Errors exist via small-order rk on the second Spend after the first Spend's items are queued. This PR does widen the trigger set to include a non-canonical cv/rk, which previously could not get past the parser.

The companion orchard PR closes its equivalent by hoisting all fallible work above every Bundle::push, which it could do cheaply because it had to build its instances up front anyway via Action::to_instance. Here there is no Action -> Instance operation. This uses the check_spend for those same operations, and consumers should think about the batch poisioning attacks. Beyond my depth of knowledge...

Downstream changes

Zebra

zebra-consensus should require no changes — it reaches Sapling verification only through check_bundle, and that signature is unchanged:

// zebra-consensus/src/primitives/sapling.rs:111 and :183 — unchanged
self.batch.check_bundle(item.bundle, item.sighash.into())

zebra-chain and zebra-rpc will need to update because they wrap the Sapling value-commitment in their own type

// zebra-chain/src/sapling/commitment.rs
- pub struct ValueCommitment(pub sapling_crypto::value::ValueCommitment);
+ pub struct ValueCommitment(pub sapling_crypto::value::ValueCommitmentBytes);

I dont see any arithmetic anywhere w/ the Zebra ValueCommitment type, so this should just be a simple change that avoids de-compressing the point when reading from disk, and re-compressing when sending over wire. Zaino and some light-clients will likely all need to update as well to the ValueCommitmentBytes, but that is a small fix for a big improvement

Behaviour change

  • A non-canonical or small-order cv, and a non-canonical rk, are rejected at verification rather than at parse. Parse-then-verify is unaffected; parsing alone now accepts encodings it used to reject. A consumer relying on parsing alone must now verify.
  • check_bundle's documented partial-contribution behaviour is now reachable via a malformed cv/rk instead of just the existing small-order rk rule
  • SaplingVerificationContext::{check_spend, check_output} take four fields in different types. Callers pass the description's own accessors directly and delete their decompression and Proof::read preamble.

Only verification needs the points.

- Parse: 2 sqrt -> 0 per Spend, 1 -> 0 per Output; serialize: 1 inversion -> 0 per `cv`
- `SpendDescription` 288 B smaller, `OutputDescription` 128 B
- `check_spend`/`check_output` take every field as wire bytes and own every rule
  (`epk` and `zkproof` encodings were the caller's)
- New `primitives`: `InvalidPoint` + `decompress_not_small_order`, one per cv/rk/epk
- Removed: `ValueCommitment::from_bytes_not_small_order`

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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