Skip to content

Repository files navigation

solana-ed25519-program: on-chain signature verification for Solana

A minimal Solana SBF program that re-verifies Ed25519 signatures on-chain using 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. 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 sysvar::instructions inspection to confirm a parallel precompile instruction succeeded.

Syscalls used

Syscall Wrapper or entry point
sol_sha512 solana_sha512_hasher::hashv
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

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

The program verifies a single signature. Instruction data is:

[0 .. 32]     public key A (32 bytes)
[32 .. 96]    signature R‖S (64 bytes)
[96 ..]       message

The verify helper in solana-ed25519-verify builds this layout. The crate also declares the program's canonical on-chain address via declare_id!, exposed as ID and id():

use solana_ed25519_verify::{verify, ID};

let instruction = verify(&ID, &public_key, &signature, message);

Constraints

  • Verification criteria. The program always applies ZIP-215: the cofactored equation [8](S·B − H(R‖A‖M)·A − R) == identity. Small-order and non-canonical points are accepted. Programs needing a different variant (e.g. verify_strict) should depend on the solana-ed25519-verify library directly (see Verification criteria).
  • No accounts. The program takes no account arguments and returns InvalidArgument if any are supplied.
  • Minimum length. Instruction data shorter than the 96-byte A || R‖S header is rejected with InvalidInstructionData.
  • Error surface. Every signature-verification failure — malformed encoding, a small-order or non-canonical rejection, or a signature that simply doesn't verify — surfaces uniformly as InvalidInstructionData, regardless of the underlying cause. Callers needing to distinguish failure reasons should depend on solana-ed25519-verify directly and inspect the Ed25519VerifyError returned by Ed25519Verifier::verify_signature.

Verification criteria (library)

Ed25519 "validity" is not one definition — implementations differ on cofactoring, non-canonical encodings, and small-order rejection (see Henry de Valence's It's 255:19AM). The solana-ed25519-verify crate exposes these as independent knobs via VerificationCriteria:

Knob Effect when enabled Extra curve syscalls
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 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, 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.

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 sol_curve_multiscalar_mul enforces it regardless, converting scalars through Scalar::from_canonical_bytes and rejecting out-of-range values before any group operation runs.

use solana_ed25519_verify::{Ed25519Verifier, VerificationCriteria};

// Default: the ZIP-215 preset (cofactored).
let verifier = Ed25519Verifier::new();

// `ed25519-dalek`'s verify_strict semantics.
let strict = Ed25519Verifier::with_criteria(VerificationCriteria::dalek_verify_strict());

// Or compose a variant by overriding individual knobs.
let custom = Ed25519Verifier::with_criteria(VerificationCriteria {
    reject_small_order_a: true,
    ..VerificationCriteria::zip215()
});

// See "Error handling" below for the possible failure reasons.
verifier.verify_signature(&signature, &public_key, message)?;

Named presets:

Preset cofactored canonical_a canonical_r small_order_a small_order_r
zip215() (default)
dalek_verify_strict()

dalek_verify_strict() matches ed25519_dalek::VerifyingKey::verify_strict exactly (cross-checked in the test suite), including the detail that a non-canonically encoded public key A is not rejected. Further presets (libsodium, RFC 8032 / FIPS 186-5) can be added in follow-ups.

The on-chain program always applies the zip215() preset. A program needing a different variant should depend on this crate directly and build an Ed25519Verifier from the desired VerificationCriteria.

Error handling (library)

Ed25519Verifier::verify_signature returns Result<(), Ed25519VerifyError>. The library crate has no dependency on solana-program-error or any other Solana-runtime error type — Ed25519VerifyError is a plain, dependency-free enum, so consumers outside a Solana program aren't forced into a Solana-specific type.

Variant Meaning
NonCanonicalPublicKey A's y-coordinate is ≥ p (require_canonical_a only)
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 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

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.

Cargo features

solana-ed25519-verify has two independent features, both enabled by default:

Feature Unlocks Pulls in
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 transaction — and never verifies a signature itself — can depend on instruction alone, without pulling in the curve/hash syscall wrappers:

solana-ed25519-verify = { version = "0.1.0", default-features = false, features = [
    "instruction",
] }

Build and test

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.

# 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 v3 --manifest-path program/Cargo.toml

# SBF build via Makefile
make build-sbf-program

# Confirm the pure-client configuration compiles without the curve/hash
# syscall wrappers
cargo check --manifest-path ed25519-verify/Cargo.toml --no-default-features --features instruction

# Host unit tests, then SBF integration tests via Mollusk
make test-program

# Print Mollusk compute-unit measurements for the SBF program
make cu-program

The Mollusk tests execute target/deploy/solana_ed25519_program.so. They skip unless SBF_OUT_DIR is set. Because published Mollusk/Agave crates do not yet register sol_sha512, program/tests/mollusk.rs installs a local SHA-512 syscall shim before loading the SBF program. A production/localnet VM must register the real sol_sha512 syscall instead.

Compute units

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. 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,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,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:

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.

About

solana program for ed25519 signature verification

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages