Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 64 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ the Curve25519 and SHA-512 syscalls.
## Motivation

The goal is to migrate the native [ed25519 precompile] to SBF so it can be
maintained and deployed like any other on-chain program. The instruction format
is intentionally identical to the precompile for current-instruction data, so
clients can reuse the standard Ed25519 instruction layout.
maintained and deployed like any other on-chain program. This prototype uses the
compact `public_key || signature || message` instruction layout described below.

Being a regular SBF program also unlocks CPI: another program can invoke this
one and act on the explicit pass/fail result, rather than relying on
Expand All @@ -22,12 +21,13 @@ succeeded.
| Syscall | Wrapper or entry point |
| --------------------------- | --------------------------------------------------------------------------------------------------- |
| `sol_sha512` | `solana_sha512_hasher::hashv` |
| `sol_curve_group_op` | `solana_curve25519::edwards::{add_edwards, subtract_edwards}` |
| `sol_curve_multiscalar_mul` | Fixed two-term wrapper using `solana_define_syscall::definitions::sol_curve_multiscalar_mul` on SBF |
| `sol_curve_group_op` | `solana_curve25519::edwards::subtract_edwards` on the single-signature fallback path |
| `sol_curve_validate_point` | Selected malformed-input checks |
| `sol_curve_multiscalar_mul` | Fixed two-term wrapper for individual verification |

`sol_sha512` is not live on mainnet yet. The wrapper crate is published as
`solana-sha512-hasher`, and a local/custom VM must enable the SHA-512 syscall
feature before SBF execution will work.
The benchmark's published Mollusk/Agave runtime does not register `sol_sha512`,
so the tests install a metered shim using `solana-sha512-hasher`. Deployment
requires a VM that enables the SHA-512 syscall.

## Instruction format

Expand Down Expand Up @@ -83,19 +83,20 @@ knobs via `VerificationCriteria`:
| `cofactored` | Use `[8](S·B − H·A − R) == identity` instead of the cofactorless `S·B − H·A − R == identity` | none; torsion lookup on the fallback difference |
| `require_canonical_a` | Reject public keys whose `y`-coordinate is `≥ p` | none |
| `require_canonical_r` | Reject signature `R` whose `y`-coordinate is `≥ p` | none |
| `reject_small_order_a` | Reject small-order (torsion) public keys | +1 addition (473 CU), plus a torsion lookup |
| `reject_small_order_r` | Reject small-order signature `R` values | +1 addition (473 CU), plus a torsion lookup |
| `reject_small_order_a` | Reject small-order (torsion) public keys | none on the successful path |
| `reject_small_order_r` | Reject small-order signature `R` values | none on the successful path |

The verifier first compares the computed point encoding with `R`. If they differ,
it subtracts `R` and checks the resulting point. The cofactored profile tests this
the default profile subtracts `R` and checks the resulting point. It tests this
canonical difference against the torsion encodings, replacing three doublings
with a lookup. This lookup is skipped when the initial comparison succeeds.

Each small-order input check adds the identity to validate the input and produce
a canonical encoding before the lookup. This preserves support for valid
non-canonical encodings and rejects inputs that do not decompress. The 473 CU
figure above is the addition syscall charge in the benchmark runtime; the total
check also includes the surrounding SBF instructions.
Small-order checks inspect compressed encodings directly, including valid
non-canonical aliases. Curve validity is enforced by the MSM, a match to the
computed valid point, or a fallback curve operation. Strict verification with
canonical `R` can reject a byte mismatch after a 159-CU point-validation syscall,
avoiding the 475-CU subtraction. These are syscall charges; surrounding SBF
instructions also contribute to the measured totals.

Canonical `S` (`S < L`) has no knob. Every profile worth targeting requires it
because accepting `S ≥ L` reintroduces signature malleability. The syscall
Expand Down Expand Up @@ -154,14 +155,11 @@ Solana-specific type.
| `NonCanonicalR` | `R`'s `y`-coordinate is `≥ p` (`require_canonical_r` only) |
| `SmallOrderPublicKey` | `A` is a small-order (torsion) point (`reject_small_order_a` only) |
| `SmallOrderR` | `R` is a small-order (torsion) point (`reject_small_order_r` only) |
| `InvalidEncoding` | `A` doesn't decode to a valid point, or `S` is non-canonical (`S ≥ L`) |
| `InvalidEncoding` | `A` or `R` doesn't decode to a valid point, or `S` is non-canonical (`S ≥ L`) |
| `SignatureMismatch` | Every input decoded successfully, but the equation doesn't hold |

`InvalidEncoding` does not distinguish a malformed public key from a
non-canonical `S` scalar: the syscall that consumes both reports only overall
success or failure. Telling them apart would mean an explicit `S < L` comparison,
or decoding `A` ahead of the syscall — compute units spent on every signature for
precision that only helps malformed input.
The MSM checks `A` and `S` together and reports the same `InvalidEncoding`
error for malformed scalar or point encodings.

The on-chain program collapses all of these to
`ProgramError::InvalidInstructionData` — see [Constraints](#constraints).
Expand All @@ -172,7 +170,7 @@ The on-chain program collapses all of these to

| Feature | Unlocks | Pulls in |
| ------------- | --------------------------------------------------------------- | ------------------------------------------- |
| `verify` | `Ed25519Verifier`, `VerificationCriteria`, `Ed25519VerifyError` | `solana-curve25519`, `solana-sha512-hasher` |
| `verify` | `Ed25519Verifier`, criteria and errors | `solana-curve25519`, `solana-sha512-hasher` |
| `instruction` | `verify()`, `id()`, `ID` (the client-side instruction builder) | `solana-instruction`, `solana-address` |

A pure client that only needs to construct instructions for CPI or a
Expand All @@ -190,12 +188,15 @@ solana-ed25519-verify = { version = "0.1.0", default-features = false, features
Stable Rust `1.93.1` is pinned in `rust-toolchain.toml`. Some make targets
also require the nightly Rust chain `nightly-2026-01-22`.

SBF builds default to **v3** through `SBF_ARCH` in the Makefile, including CI
builds. Pass `--arch v3` when invoking `cargo build-sbf` directly.

```sh
# Unit tests (host, no SBF toolchain required)
cargo test --manifest-path program/Cargo.toml
# Library unit and integration tests (host, no SBF toolchain required)
cargo test --manifest-path ed25519-verify/Cargo.toml

# SBF build only
cargo build-sbf --arch v2 --manifest-path program/Cargo.toml
cargo build-sbf --arch v3 --manifest-path program/Cargo.toml

# SBF build via Makefile
make build-sbf-program
Expand All @@ -219,54 +220,63 @@ register the real `sol_sha512` syscall instead.

## Compute units

The measurements below were collected on September 8, 2026, using an SBF v2
release build, Mollusk `0.13.1`, and the metered SHA-512 test syscall shim. They
include execution of the program wrapper and the syscall charges in that
harness. These are compute-unit measurements, not host execution times.
The measurements below were collected on September 18, 2026, using an SBF v3
release build with `cargo-build-sbf 4.1.0`, platform-tools `v1.54` (Rust `1.89.0`),
Mollusk `0.13.1`, and the metered SHA-512 test syscall shim. They include execution
of the program wrapper and the syscall charges in that harness. These are
compute-unit measurements, not host execution times.

The signature corpus contains 32 cases: signing-key seeds `7`, `42`, `99`, and
`201`, each tested with the eight message lengths below. For each message length,
all four seeds consumed the same number of compute units in the final measured
build.
`201`, each tested with the eight message lengths below. Ranges show the observed
minimum and maximum across those seeds. Encoding checks and scalar correction
can cause small differences between signatures of the same message length.

| Message bytes | Default ZIP-215 CU | Strict CU |
| ----------------: | -----------------: | -----------: |
| 0 | 3,847 | 4,980 |
| 1 | 3,847 | 4,980 |
| 38 | 3,856 | 4,989 |
| 47 | 3,860 | 4,993 |
| 48 | 3,861 | 4,994 |
| 49 | 3,861 | 4,994 |
| 128 | 3,901 | 5,034 |
| 1,024 | 4,349 | 5,482 |
| **32-case total** | **125,528** | **161,784** |
| **Mean per case** | **3,922.75** | **5,055.75** |

The separate 38-byte signature fixture also consumes **3,856 CU** with ZIP-215
and **4,989 CU** with `VerificationCriteria::dalek_verify_strict()`. The strict
| 0 | 3,733 | 3,745 |
| 1 | 3,733 | 3,745–3,767 |
| 38 | 3,742 | 3,754 |
| 47 | 3,746 | 3,758–3,780 |
| 48 | 3,747 | 3,759 |
| 49 | 3,747–3,748 | 3,759–3,760 |
| 128 | 3,787 | 3,799 |
| 1,024 | 4,235 | 4,247 |
| **32-case total** | **121,881** | **122,309** |
| **Mean per case** | **3,808.78** | **3,822.16** |

The separate 38-byte signature fixture also consumes **3,742 CU** with ZIP-215
and **3,754 CU** with `VerificationCriteria::dalek_verify_strict()`. The strict
column was measured using a separate build of the same program wrapper with
that preset selected. The shipped program continues to use ZIP-215.

Other measured paths in the default ZIP-215 build:

| Test case | CU |
| ------------------------------------------------ | ---------------------------------: |
| Accepted small-order public-key fixture | 4,388 |
| Accepted torsion encodings, 14 cases | 3,848-4,400 per case; 60,898 total |
| Tampered message or public key | 4,403 each |
| Non-canonical `S` or invalid public-key encoding | 3,836 each |
| Unexpected accounts | 18 |
| Instruction shorter than 96 bytes | 21 |
| Accepted small-order public-key fixture | 4,281 |
| Accepted torsion encodings, 14 cases | 3,735–4,287 per case; 59,364 total |
| Tampered message / public key | 4,292 / 4,293 |
| Non-canonical `S` or invalid public-key encoding | 3,722 each |
| Unexpected accounts | 16 |
| Instruction shorter than 96 bytes | 19 |

To reproduce the default program measurements:

```sh
cargo build-sbf --arch v2 --manifest-path program/Cargo.toml \
--sbf-out-dir "$PWD/target/deploy"
cargo build-sbf --arch v3 --tools-version v1.54 --manifest-path program/Cargo.toml \
--sbf-out-dir "$PWD/target/deploy" -- --locked
SBF_OUT_DIR="$PWD/target/deploy" cargo test --locked \
-p solana-ed25519-program --test mollusk \
-- --nocapture --test-threads=1
```

Changing the SBF toolchain, dependencies, or syscall cost model can change these
results. Compare builds using the same corpus and metered syscall shim.

With matched strict criteria (`dalek_verify_strict()` plus
`require_canonical_a: true`), the 38-byte fixture consumes **3,757 CU**, versus
**4,853 CU** for [brine-ed25519 0.9.3] with its strict feature: **22.6% less** using
the same SBF v3 toolchain and syscall metering. Brine's default policy differs
from ZIP-215, so those defaults are not a like-for-like semantic comparison.

[brine-ed25519 0.9.3]: https://github.com/zfedoran/brine-ed25519/tree/3683ee1720db99df8252ecbf5b2924e8ab37a851
9 changes: 5 additions & 4 deletions ed25519-verify/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,14 @@ pub struct VerificationCriteria {
pub require_canonical_r: bool,
/// Reject public keys that lie in the small-order (torsion) subgroup.
///
/// Costs one point addition to validate the input and produce a canonical
/// encoding, followed by a torsion lookup, when enabled.
/// Checks the compressed encoding, including non-canonical aliases.
/// Point validity is enforced by the curve operations during verification.
pub reject_small_order_a: bool,
/// Reject signature `R` values that lie in the small-order subgroup.
///
/// Costs one point addition to validate the input and produce a canonical
/// encoding, followed by a torsion lookup, when enabled.
/// Checks the compressed encoding, including non-canonical aliases.
/// Point validity is enforced by the verification equation or, on a byte
/// mismatch, a curve operation.
pub reject_small_order_r: bool,
}

Expand Down
2 changes: 1 addition & 1 deletion ed25519-verify/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ pub(crate) const BASEPOINT_ORDER: [u8; 32] = [
///
/// Evaluated at compile time so that limb constants are derived from their byte
/// counterparts rather than transcribed by hand a second time.
const fn to_le_limbs(bytes: [u8; 32]) -> [u64; 4] {
pub(crate) const fn to_le_limbs(bytes: [u8; 32]) -> [u64; 4] {
let mut limbs = [0u64; 4];
let mut limb_index = 0;

Expand Down
6 changes: 3 additions & 3 deletions ed25519-verify/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ pub enum Ed25519VerifyError {
///
/// [`reject_small_order_r`]: crate::VerificationCriteria::reject_small_order_r
SmallOrderR,
/// The public key does not decode to a valid Edwards curve point, or the
/// signature's `S` scalar is not canonical (`S >= L`).
/// The public key or signature's `R` does not decode to a valid Edwards
/// curve point, or the signature's `S` scalar is not canonical (`S >= L`).
///
/// These two causes are not distinguished: the syscall that consumes both
/// Invalid `A` and `S` are not distinguished: the syscall that consumes both
/// reports only overall success or failure. Telling them apart would mean an
/// explicit `S < L` comparison, or decoding `A` ahead of the syscall — compute
/// units spent on every signature for precision that only helps malformed input.
Expand Down
Loading
Loading