vrf: an algorithm from one RFC 9381 suite, labelled with another's byte

The package documented ECVRF-EDWARDS25519-SHA512-ELL2 and sent suite byte 0x04,
while the code ran try-and-increment — which is TAI, suite 0x03. The suite byte
is hashed into both the encode-to-curve and challenge digests, so the
combination matched no conforming prover and could not verify a proof from one.
Aligned on TAI throughout, with the RFC's front/back domain separators.

Verification also now runs with validate_key = TRUE (Section 5.4.5). The public
key arrives in calldata, so it is attacker-chosen, and that check is what buys
the collision resistance and unpredictability-under-malicious-key-generation an
on-chain lottery is paying for (Sections 7.1.1, 7.1.3). Point decoding refuses
non-canonical encodings.

Gas follows the work: encode_to_curve hashes the whole alpha string once per
counter, so the fee carries a per-word term, and a call that does no work is
charged rather than free.
This commit is contained in:
2026-08-28 15:39:15 -07:00
parent 3da812d7d9
commit 0ba85e6aaf
4 changed files with 977 additions and 433 deletions
+193 -128
View File
@@ -1,17 +1,24 @@
// Copyright (C) 2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package vrf implements an EVM precompile for ECVRF-EDWARDS25519-SHA512-ELL2
// Package vrf implements an EVM precompile for ECVRF-EDWARDS25519-SHA512-TAI
// verification per RFC 9381. This enables on-chain verifiable randomness for
// lotteries, leader election, NFT minting, and privacy-preserving protocols.
//
// Only verification and proof-to-hash extraction are exposed. Proving requires
// the secret key and happens off-chain.
//
// The public key is supplied by the caller and is therefore never trusted:
// ECVRF_verify runs with validate_key = TRUE (RFC 9381, Section 5.4.5), which
// is what buys "full collision resistance" and "unpredictability under
// malicious key generation" (RFC 9381, Sections 7.1.1 and 7.1.3) -- the two
// properties an on-chain lottery is actually paying for.
//
// Precompile address: 0x0000000000000000000000000000000000003213 (LP-3213)
package vrf
import (
"bytes"
"crypto/sha512"
"errors"
@@ -29,29 +36,56 @@ const (
// OpProofToHash extracts beta_string from a proof without verification.
OpProofToHash = 0x02
// Gas costs.
GasVerify = 20_000 // One scalar mul + two hash-to-curve ops
GasProofToHash = 1_000 // One SHA-512
// MinCallGas is charged for any call that performs no work -- empty input
// or an unrecognised selector -- so that no call is ever free.
MinCallGas = 21_000
// Sizes per RFC 9381 section 5.5 (ECVRF-EDWARDS25519-SHA512-ELL2).
// GasVerify covers the size-independent work: one double-scalar base
// multiplication and two scalar multiplications.
GasVerify = 20_000
// GasVerifyPerWord prices the size-dependent work. ECVRF_encode_to_curve
// hashes the whole alpha string once per counter, and the counter loop
// advances only when a candidate fails to decode -- probability 1/2 each --
// so forcing n passes over alpha costs an offline search of 2^n. This is
// eight times the EVM's own SHA3 word price (6), which covers a grind of
// that scale given SHA-512's throughput advantage over Keccak.
GasVerifyPerWord = 48
// GasProofToHash covers one point decompression and one SHA-512 of a
// fixed-size input.
GasProofToHash = 1_000
// Sizes per RFC 9381 Section 5.5 (ECVRF-EDWARDS25519-SHA512-TAI).
PublicKeySize = 32 // Compressed Edwards Y coordinate
ProofSize = 80 // Gamma(32) + c(16) + s(32)
BetaSize = 64 // SHA-512 output
// RFC 9381 suite string for ECVRF-EDWARDS25519-SHA512-ELL2.
suiteString = 0x04
// AlphaLenSize is the width of the big-endian alpha_string length prefix.
AlphaLenSize = 2
// Domain separation prefixes (RFC 9381 section 5.4.1.2).
dsHashToTryCAndIncrement = 0x01
dsHashPoints = 0x02
dsProofToHash = 0x03
// RFC 9381 suite string for ECVRF-EDWARDS25519-SHA512-TAI.
suiteString = 0x03
// Domain separators (RFC 9381 Sections 5.4.1.1, 5.4.3 and 5.2). Each
// hashed string opens with suite_string || front and closes with back.
dsEncodeToCurve = 0x01
dsChallenge = 0x02
dsProofToHash = 0x03
dsBack = 0x00
// maxCounter bounds the encode_to_curve counter to the one octet RFC 9381
// gives it.
maxCounter = 256
)
// ─── Errors ───────────────────────────────────────────────────────────────────
var (
ErrInvalidInput = contract.ErrInvalidInput
ErrInvalidOperation = errors.New("vrf: invalid operation selector")
ErrInvalidOperation = contract.ErrInvalidOp
errNonCanonicalPoint = errors.New("vrf: non-canonical point encoding")
)
// ─── Addresses ────────────────────────────────────────────────────────────────
@@ -70,18 +104,26 @@ var (
type vrfPrecompile struct{}
// words returns the number of 32-byte words spanned by n bytes.
func words(n int) uint64 {
return (uint64(n) + 31) / 32
}
// RequiredGas returns the gas cost for the given input.
func (p *vrfPrecompile) RequiredGas(input []byte) uint64 {
if len(input) < 1 {
return 0
return MinCallGas
}
switch input[0] {
case OpVerify:
return GasVerify
// alpha_string is variable length and is hashed, so the fee grows
// with it. len(input) bounds alpha from above and is available here,
// before the input has been parsed.
return GasVerify + words(len(input)-1)*GasVerifyPerWord
case OpProofToHash:
return GasProofToHash
default:
return 0
return MinCallGas
}
}
@@ -89,13 +131,16 @@ func (p *vrfPrecompile) RequiredGas(input []byte) uint64 {
//
// OpVerify (0x01):
//
// Input: opcode(1) || pk(32) || alpha_len(2 big-endian) || alpha(variable) || proof(80)
// Output: beta_string(64) on success, empty on failure
// Input: opcode(1) || pk(32) || alpha_len(2 big-endian) || alpha(alpha_len) || proof(80)
// Output: beta_string(64) if the proof is valid, empty if it is not
//
// OpProofToHash (0x02):
//
// Input: opcode(1) || proof(80)
// Output: beta_string(64)
// Output: beta_string(64), empty if the proof does not decode
//
// Both operations require an exact-length input; an unrecognised selector or
// an empty input is an error, never an empty success.
func (p *vrfPrecompile) Run(
accessibleState contract.AccessibleState,
caller common.Address,
@@ -111,37 +156,38 @@ func (p *vrfPrecompile) Run(
}
if len(input) < 1 {
return nil, remainingGas, nil
return nil, remainingGas, ErrInvalidInput
}
switch input[0] {
case OpVerify:
result := p.opVerify(input[1:])
return result, remainingGas, nil
return p.opVerify(input[1:]), remainingGas, nil
case OpProofToHash:
result := p.opProofToHash(input[1:])
return result, remainingGas, nil
return p.opProofToHash(input[1:]), remainingGas, nil
default:
return nil, remainingGas, nil
return nil, remainingGas, ErrInvalidOperation
}
}
// opVerify implements ECVRF_verify per RFC 9381 section 5.3.
// opVerify implements ECVRF_verify per RFC 9381 Section 5.3.
//
// Input: pk(32) || alpha_len(2) || alpha(variable) || proof(80)
// Input: pk(32) || alpha_len(2) || alpha(alpha_len) || proof(80)
func (p *vrfPrecompile) opVerify(data []byte) []byte {
// Minimum: pk(32) + alpha_len(2) + alpha(0) + proof(80) = 114
if len(data) < PublicKeySize+2+ProofSize {
// The header carries the length that describes the rest.
if len(data) < PublicKeySize+AlphaLenSize {
return nil
}
pk := data[:PublicKeySize]
alphaLen := int(data[PublicKeySize])<<8 | int(data[PublicKeySize+1])
offset := PublicKeySize + 2
offset := PublicKeySize + AlphaLenSize
if len(data) < offset+alphaLen+ProofSize {
// alpha_len must place the proof exactly at the end of the body: further
// overruns the buffer, nearer leaves trailing bytes, and either way one
// query would have more than one encoding.
if len(data) != offset+alphaLen+ProofSize {
return nil
}
@@ -155,15 +201,12 @@ func (p *vrfPrecompile) opVerify(data []byte) []byte {
return beta
}
// opProofToHash implements ECVRF_proof_to_hash per RFC 9381 section 5.2.
// opProofToHash implements ECVRF_proof_to_hash per RFC 9381 Section 5.2.
// decodeProof owns the length of pi_string, so there is nothing to check here.
//
// Input: proof(80)
func (p *vrfPrecompile) opProofToHash(data []byte) []byte {
if len(data) < ProofSize {
return nil
}
gamma, _, _, ok := decodeProof(data[:ProofSize])
gamma, _, _, ok := decodeProof(data)
if !ok {
return nil
}
@@ -171,164 +214,186 @@ func (p *vrfPrecompile) opProofToHash(data []byte) []byte {
return proofToHash(gamma)
}
// ─── RFC 9381 ECVRF-EDWARDS25519-SHA512-ELL2 ─────────────────────────────────
// ─── RFC 9381 ECVRF-EDWARDS25519-SHA512-TAI ──────────────────────────────────
// ecvrfVerify implements ECVRF_verify (RFC 9381 section 5.3).
// Returns (beta_string, true) on success.
// ecvrfVerify implements ECVRF_verify (RFC 9381 Section 5.3) with
// validate_key = TRUE. Returns (beta_string, true) on success.
func ecvrfVerify(pkBytes, alpha, piBytes []byte) ([]byte, bool) {
// Step 1: Decode public key Y
Y, err := new(edwards25519.Point).SetBytes(pkBytes)
// Step 1-2: Y = string_to_point(PK_string)
Y, err := decodePoint(pkBytes)
if err != nil {
return nil, false
}
// Step 2: Decode proof (Gamma, c, s)
// Step 3: ECVRF_validate_key(Y)
if !validateKey(Y) {
return nil, false
}
// Step 4-6: (Gamma, c, s) = ECVRF_decode_proof(pi_string)
Gamma, c, s, ok := decodeProof(piBytes)
if !ok {
return nil, false
}
// Step 3: Hash to curve H = ECVRF_encode_to_curve(encode_to_curve_salt=pk, alpha)
H := hashToCurveELL2(pkBytes, alpha)
// Step 7: H = ECVRF_encode_to_curve(encode_to_curve_salt = PK_string, alpha)
H := encodeToCurve(pkBytes, alpha)
if H == nil {
return nil, false
}
// Step 4: U = s*B - c*Y
// Step 8: U = s*B - c*Y
cNeg := new(edwards25519.Scalar).Negate(c)
U := new(edwards25519.Point).VarTimeDoubleScalarBaseMult(cNeg, Y, s)
// Step 5: V = s*H - c*Gamma
// Step 9: V = s*H - c*Gamma
sH := new(edwards25519.Point).ScalarMult(s, H)
cGamma := new(edwards25519.Point).ScalarMult(c, Gamma)
cGammaNeg := new(edwards25519.Point).Negate(cGamma)
V := new(edwards25519.Point).Add(sH, cGammaNeg)
V := new(edwards25519.Point).Subtract(sH, cGamma)
// Step 6: Derive c' = ECVRF_challenge_generation(Y, H, Gamma, U, V)
// Step 10: c' = ECVRF_challenge_generation(Y, H, Gamma, U, V)
cPrime := challengeGeneration(Y, H, Gamma, U, V)
// Step 7: Check c == c'
// Step 11: c == c' ?
if c.Equal(cPrime) != 1 {
return nil, false
}
// Step 8: beta = ECVRF_proof_to_hash(pi)
beta := proofToHash(Gamma)
return beta, true
return proofToHash(Gamma), true
}
// proofToHash implements ECVRF_proof_to_hash (RFC 9381 section 5.2).
// beta_string = SHA-512(suite_string || proof_to_hash_domain_separator || cofactor*Gamma || zero)
// decodePoint implements string_to_point (RFC 8032 Section 5.1.3), which
// RFC 9381 Section 5.5 names for this suite and which MUST reject an octet
// string that is not the encoding of a point.
//
// edwards25519.Point.SetBytes deliberately does not: it accepts an unreduced y
// and accepts a set sign bit where x is zero, matching the wider ecosystem
// rather than RFC 8032. Under those rules a Gamma has more than one pi_string
// and a public key has more than one address. Re-encoding and comparing is the
// canonicality test, and it covers both cases at once.
func decodePoint(b []byte) (*edwards25519.Point, error) {
P, err := new(edwards25519.Point).SetBytes(b)
if err != nil {
return nil, err
}
if !bytes.Equal(P.Bytes(), b) {
return nil, errNonCanonicalPoint
}
return P, nil
}
// validateKey implements ECVRF_validate_key (RFC 9381 Section 5.4.5): the key
// is valid iff cofactor*Y is not the identity.
//
// Y arrives in calldata, so a caller is free to pick it. Without this check a
// caller who picks the identity produces a proof that verifies for every
// alpha_string and whose beta_string is a fixed value known in advance, which
// is exactly the outcome a verifiable random function exists to prevent.
func validateKey(Y *edwards25519.Point) bool {
cofY := new(edwards25519.Point).MultByCofactor(Y)
return cofY.Equal(edwards25519.NewIdentityPoint()) == 0
}
// proofToHash implements ECVRF_proof_to_hash (RFC 9381 Section 5.2).
//
// beta_string = Hash(suite_string || 0x03 || point_to_string(cofactor*Gamma) || 0x00)
func proofToHash(Gamma *edwards25519.Point) []byte {
// Cofactor multiply: cofGamma = cofactor * Gamma
// For Ed25519, cofactor = 8. We multiply by 8 via three doublings.
cofGamma := new(edwards25519.Point).Add(Gamma, Gamma) // 2*Gamma
cofGamma = new(edwards25519.Point).Add(cofGamma, cofGamma) // 4*Gamma
cofGamma = new(edwards25519.Point).Add(cofGamma, cofGamma) // 8*Gamma
cofGamma := new(edwards25519.Point).MultByCofactor(Gamma)
h := sha512.New()
h.Write([]byte{suiteString, dsProofToHash})
h.Write(cofGamma.Bytes())
h.Write([]byte{0x00})
h.Write([]byte{dsBack})
return h.Sum(nil)
}
// hashToCurveELL2 implements ECVRF_encode_to_curve using the
// Elligator2 method per RFC 9381 section 5.4.1.2.
// encodeToCurve implements ECVRF_encode_to_curve_try_and_increment (RFC 9381
// Section 5.4.1.1) with interpret_hash_value_as_a_point(s) =
// string_to_point(s[0]...s[31]), as fixed for this suite by Section 5.5.
//
// This is the "try-and-increment" variant. It hashes the input to a
// field element, applies Elligator2 to obtain a point, and multiplies
// by the cofactor.
func hashToCurveELL2(pkBytes, alpha []byte) *edwards25519.Point {
// Hash: SHA-512(suite_string || one || pk || alpha || ctr || zero)
// Try ctr = 0, 1, 2, ... until we get a valid point.
for ctr := range byte(255) {
// The counter advances while the candidate fails to decode to a point or
// decodes to the identity; the hash input is the full string every time, which
// is why the verify fee is charged per word of input.
func encodeToCurve(salt, alpha []byte) *edwards25519.Point {
for ctr := range maxCounter {
h := sha512.New()
h.Write([]byte{suiteString, dsHashToTryCAndIncrement})
h.Write(pkBytes)
h.Write([]byte{suiteString, dsEncodeToCurve})
h.Write(salt)
h.Write(alpha)
h.Write([]byte{ctr})
h.Write([]byte{0x00})
hash := h.Sum(nil)
h.Write([]byte{byte(ctr), dsBack})
// Take the first 32 bytes, clear the high bit (MSB of last byte
// when using little-endian encoding), and try to decompress.
var candidate [32]byte
copy(candidate[:], hash[:32])
candidate[31] &= 0x7F // Clear sign bit
point, err := new(edwards25519.Point).SetBytes(candidate[:])
if err != nil {
continue
if H := interpretHashAsPoint(h.Sum(nil)); H != nil {
return H
}
// Multiply by cofactor (8) to ensure we're in the prime-order subgroup.
cofPoint := new(edwards25519.Point).Add(point, point) // 2
cofPoint = new(edwards25519.Point).Add(cofPoint, cofPoint) // 4
cofPoint = new(edwards25519.Point).Add(cofPoint, cofPoint) // 8
// Reject identity.
if cofPoint.Equal(edwards25519.NewIdentityPoint()) == 1 {
continue
}
return cofPoint
}
return nil
}
// challengeGeneration implements ECVRF_challenge_generation (RFC 9381 section 5.4.3).
// c = SHA-512(suite_string || challenge_generation_domain_separator || encode(Y) || encode(H) || encode(Gamma) || encode(U) || encode(V) || zero)[0:16]
func challengeGeneration(Y, H, Gamma, U, V *edwards25519.Point) *edwards25519.Scalar {
h := sha512.New()
h.Write([]byte{suiteString, dsHashPoints})
h.Write(Y.Bytes())
h.Write(H.Bytes())
h.Write(Gamma.Bytes())
h.Write(U.Bytes())
h.Write(V.Bytes())
h.Write([]byte{0x00})
hash := h.Sum(nil)
// c is the first 16 bytes, interpreted as a little-endian scalar.
var truncated [32]byte
copy(truncated[:16], hash[:16])
scalar, err := new(edwards25519.Scalar).SetCanonicalBytes(truncated[:])
// interpretHashAsPoint is interpret_hash_value_as_a_point as RFC 9381
// Section 5.5 fixes it for this suite: string_to_point over the first 32
// octets, then multiplication by the cofactor.
//
// It returns nil where the counter must advance: the octets are not a point
// encoding, or the cofactor kills them. The high bit of the last octet is the
// sign of x (RFC 8032 Section 5.1.3), not padding, and is carried through.
func interpretHashAsPoint(hash []byte) *edwards25519.Point {
point, err := decodePoint(hash[:32])
if err != nil {
// Should not happen with a 128-bit value in a 256-bit field.
return new(edwards25519.Scalar)
return nil
}
return scalar
cofPoint := new(edwards25519.Point).MultByCofactor(point)
if cofPoint.Equal(edwards25519.NewIdentityPoint()) == 1 {
return nil
}
return cofPoint
}
// decodeProof splits pi_string into (Gamma, c, s).
// challengeGeneration implements ECVRF_challenge_generation (RFC 9381
// Section 5.4.3) over the five points ECVRF_verify passes it.
//
// c = Hash(suite_string || 0x02 || point_to_string(P1) || ... || 0x00)[0..cLen-1]
func challengeGeneration(Y, H, Gamma, U, V *edwards25519.Point) *edwards25519.Scalar {
h := sha512.New()
h.Write([]byte{suiteString, dsChallenge})
for _, P := range []*edwards25519.Point{Y, H, Gamma, U, V} {
h.Write(P.Bytes())
}
h.Write([]byte{dsBack})
hash := h.Sum(nil)
// c = string_to_int(hash[0..cLen-1]), little-endian, cLen = 16. A 128-bit
// value is below the group order for every input, so the canonical-encoding
// check cannot reject it and there is nothing to branch on.
var truncated [32]byte
copy(truncated[:16], hash[:16])
c, _ := new(edwards25519.Scalar).SetCanonicalBytes(truncated[:])
return c
}
// decodeProof implements ECVRF_decode_proof (RFC 9381 Section 5.4.4), splitting
// pi_string into (Gamma, c, s).
//
// pi_string layout (80 bytes):
//
// Gamma: 32 bytes (compressed Edwards point)
// c: 16 bytes (little-endian scalar, zero-padded to 32 for SetCanonicalBytes)
// s: 32 bytes (little-endian scalar)
// c: 16 bytes (little-endian integer)
// s: 32 bytes (little-endian integer, rejected if >= the group order)
func decodeProof(pi []byte) (*edwards25519.Point, *edwards25519.Scalar, *edwards25519.Scalar, bool) {
if len(pi) != ProofSize {
return nil, nil, nil, false
}
// Decode Gamma
Gamma, err := new(edwards25519.Point).SetBytes(pi[:32])
Gamma, err := decodePoint(pi[:32])
if err != nil {
return nil, nil, nil, false
}
// Decode c (16 bytes, little-endian, padded to 32)
var cBytes [32]byte
copy(cBytes[:16], pi[32:48])
c, err := new(edwards25519.Scalar).SetCanonicalBytes(cBytes[:])
if err != nil {
return nil, nil, nil, false
}
c, _ := new(edwards25519.Scalar).SetCanonicalBytes(cBytes[:])
// Decode s (32 bytes, little-endian)
// s >= q is rejected here; without it a proof would have a second encoding.
s, err := new(edwards25519.Scalar).SetCanonicalBytes(pi[48:80])
if err != nil {
return nil, nil, nil, false
+520 -305
View File
@@ -4,328 +4,569 @@
package vrf
import (
"bytes"
"crypto/sha512"
"encoding/binary"
"encoding/hex"
"testing"
edwards "filippo.io/edwards25519"
"github.com/luxfi/precompile/contract"
"github.com/stretchr/testify/require"
)
const testGas = 100_000
const testGas = 10_000_000
// ─── Gas ──────────────────────────────────────────────────────────────────────
func TestRequiredGas(t *testing.T) {
tests := []struct {
name string
input []byte
want uint64
}{
{"empty", nil, 0},
{"verify", []byte{OpVerify}, GasVerify},
{"proof_to_hash", []byte{OpProofToHash}, GasProofToHash},
{"unknown_op", []byte{0xFF}, 0},
// TestGas_VerifyGrowsWithInput is the regression test for a flat verify fee.
// ECVRF_encode_to_curve hashes the whole alpha_string, once per counter, so a
// fee that ignores the length lets a caller buy unbounded hashing at a fixed
// price.
func TestGas_VerifyGrowsWithInput(t *testing.T) {
alphaLens := []int{0, 32, 64, 1024, 65535}
prev := uint64(0)
for _, n := range alphaLens {
input := buildVerifyInput(make([]byte, PublicKeySize), make([]byte, n), make([]byte, ProofSize))
got := VRFPrecompile.RequiredGas(input)
want := GasVerify + words(len(input)-1)*GasVerifyPerWord
require.Equalf(t, want, got, "alpha_len=%d", n)
require.Greaterf(t, got, prev, "fee must rise with alpha_len (%d)", n)
prev = got
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := VRFPrecompile.RequiredGas(tt.input); got != tt.want {
t.Errorf("RequiredGas = %d, want %d", got, tt.want)
}
// The largest alpha the two-byte length prefix can describe must cost
// materially more than the size-independent part alone.
maxInput := buildVerifyInput(make([]byte, PublicKeySize), make([]byte, 65535), make([]byte, ProofSize))
require.Greater(t, VRFPrecompile.RequiredGas(maxInput), 3*uint64(GasVerify))
}
// TestGas_FlatVerifyFeeNoLongerBuysMaxAlpha states the same property through
// Run: the old flat fee is not enough to verify a maximum-length alpha_string.
func TestGas_FlatVerifyFeeNoLongerBuysMaxAlpha(t *testing.T) {
input := buildVerifyInput(make([]byte, PublicKeySize), make([]byte, 65535), make([]byte, ProofSize))
_, remaining, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, GasVerify, true)
require.ErrorIs(t, err, contract.ErrOutOfGas)
require.Zero(t, remaining)
// It is affordable at the advertised price and not one gas below it.
need := VRFPrecompile.RequiredGas(input)
_, _, err = VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, need-1, true)
require.ErrorIs(t, err, contract.ErrOutOfGas)
_, remaining, err = VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, need, true)
require.NoError(t, err)
require.Zero(t, remaining)
}
// TestGas_NoCallIsEverFree is the regression test for a successful zero-gas
// call. Every selector, including the 254 that do nothing, must either charge
// gas or fail; a nil error with the full gas returned is a free call.
func TestGas_NoCallIsEverFree(t *testing.T) {
for op := range 256 {
input := []byte{byte(op)}
charged := VRFPrecompile.RequiredGas(input)
require.Positivef(t, charged, "selector 0x%02x is free", op)
if op != OpVerify && op != OpProofToHash {
require.GreaterOrEqualf(t, charged, uint64(MinCallGas),
"selector 0x%02x does no work and is priced below the floor", op)
}
_, remaining, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
if err != nil {
continue
}
require.Lessf(t, remaining, uint64(testGas), "selector 0x%02x succeeded without charging gas", op)
}
}
func TestGas_UnknownSelectorIsAnError(t *testing.T) {
for _, op := range []byte{0x00, 0x03, 0x10, 0xFF} {
out, remaining, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, []byte{op}, testGas, true)
require.ErrorIsf(t, err, contract.ErrInvalidOp, "selector 0x%02x", op)
require.Nil(t, out)
require.Equal(t, uint64(testGas-MinCallGas), remaining)
}
}
func TestGas_EmptyInputIsAnError(t *testing.T) {
require.Equal(t, uint64(MinCallGas), VRFPrecompile.RequiredGas(nil))
out, remaining, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, nil, testGas, true)
require.ErrorIs(t, err, contract.ErrInvalidInput)
require.Nil(t, out)
require.Equal(t, uint64(testGas-MinCallGas), remaining)
}
// TestGas_RunChargesTheAdvertisedPrice ties Run's accounting to RequiredGas
// for every operation, so the two can never drift apart.
func TestGas_RunChargesTheAdvertisedPrice(t *testing.T) {
v := rfc9381TAI[1]
inputs := map[string][]byte{
"verify": buildVerifyInput(mustHex(v.pk), mustHex(v.alpha), mustHex(v.pi)),
"proof_to_hash": append([]byte{OpProofToHash}, mustHex(v.pi)...),
"unknown": {0xFF},
"empty": {},
}
for name, input := range inputs {
t.Run(name, func(t *testing.T) {
want := VRFPrecompile.RequiredGas(input)
_, remaining, _ := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
require.Equal(t, uint64(testGas)-want, remaining)
})
}
}
// ─── Out of Gas ───────────────────────────────────────────────────────────────
func TestOutOfGas(t *testing.T) {
input := append([]byte{OpVerify}, make([]byte, 200)...)
_, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, GasVerify-1, true)
if err == nil {
t.Error("expected out of gas error")
func TestGas_ProofToHashIsFixedPrice(t *testing.T) {
for _, n := range []int{0, ProofSize, 4096} {
input := append([]byte{OpProofToHash}, make([]byte, n)...)
require.Equal(t, uint64(GasProofToHash), VRFPrecompile.RequiredGas(input))
}
}
// ─── Empty / Short Input ──────────────────────────────────────────────────────
func TestGas_Words(t *testing.T) {
require.Equal(t, uint64(0), words(0))
require.Equal(t, uint64(1), words(1))
require.Equal(t, uint64(1), words(32))
require.Equal(t, uint64(2), words(33))
}
func TestEmptyInput(t *testing.T) {
result, gas, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, nil, testGas, true)
if err != nil {
t.Fatal(err)
// ─── Key validation (RFC 9381 Section 5.4.5) ─────────────────────────────────
// smallOrderKeys are the encodings of the Ed25519 points killed by the
// cofactor: the identity, the point of order 2, the two of order 4 and the
// four of order 8. Each is checked to be exactly that below rather than
// trusted, so a wrong constant fails loudly instead of weakening the test.
var smallOrderKeys = []string{
"0100000000000000000000000000000000000000000000000000000000000000",
"ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
"0000000000000000000000000000000000000000000000000000000000000000",
"0000000000000000000000000000000000000000000000000000000000000080",
"26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05",
"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a",
"26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85",
"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa",
}
func TestValidateKey_RejectsExactlyTheSmallOrderPoints(t *testing.T) {
for _, h := range smallOrderKeys {
P, err := new(edwards.Point).SetBytes(mustHex(h))
require.NoErrorf(t, err, "%s is not a point encoding", h)
require.Equalf(t, 1, new(edwards.Point).MultByCofactor(P).Equal(edwards.NewIdentityPoint()),
"%s is not killed by the cofactor", h)
require.Falsef(t, validateKey(P), "%s must fail ECVRF_validate_key", h)
}
if result != nil {
t.Error("expected nil result for empty input")
}
if gas != testGas {
t.Errorf("expected full gas returned, got %d", gas)
// A real public key survives.
for _, v := range rfc9381TAI {
Y, err := new(edwards.Point).SetBytes(mustHex(v.pk))
require.NoError(t, err)
require.True(t, validateKey(Y))
}
}
func TestVerifyShortInput(t *testing.T) {
input := []byte{OpVerify, 0x01, 0x02}
result, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
if err != nil {
t.Fatal(err)
}
if result != nil {
t.Error("expected nil for short verify input")
// TestVerify_RejectsSmallOrderKey drives the same keys through the precompile.
func TestVerify_RejectsSmallOrderKey(t *testing.T) {
v := rfc9381TAI[0]
for _, h := range smallOrderKeys {
input := buildVerifyInput(mustHex(h), mustHex(v.alpha), mustHex(v.pi))
out, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
require.NoError(t, err)
require.Nilf(t, out, "small-order key %s was accepted", h)
}
}
func TestProofToHashShortInput(t *testing.T) {
input := append([]byte{OpProofToHash}, make([]byte, 10)...)
result, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
if err != nil {
t.Fatal(err)
// TestVerify_IdentityKeyForgeryIsRefused builds the proof that an unvalidated
// key buys and requires the precompile to refuse it.
//
// With Y and Gamma both the identity, U = s*B and V = s*H no longer depend on
// c, so the challenge can simply be computed and written into the proof: the
// c == c' comparison passes for any alpha_string with no secret key at all,
// and beta_string is Hash(suite || 0x03 || point_to_string(identity) || 0x00),
// the same constant every time. ECVRF_validate_key is the only step that
// stands between a caller and a VRF output they picked in advance.
func TestVerify_IdentityKeyForgeryIsRefused(t *testing.T) {
identity := edwards.NewIdentityPoint()
pk := identity.Bytes()
s := edwards.NewScalar()
_, err := s.SetCanonicalBytes(append([]byte{1}, make([]byte, 31)...))
require.NoError(t, err)
forge := func(alpha []byte) []byte {
H := encodeToCurve(pk, alpha)
require.NotNil(t, H)
U := new(edwards.Point).ScalarBaseMult(s) // s*B - c*identity
V := new(edwards.Point).ScalarMult(s, H) // s*H - c*identity
c := challengeGeneration(identity, H, identity, U, V)
pi := make([]byte, ProofSize)
copy(pi[:32], identity.Bytes())
cBytes := c.Bytes()
copy(pi[32:48], cBytes[:16])
copy(pi[48:80], s.Bytes())
return pi
}
if result != nil {
t.Error("expected nil for short proof_to_hash input")
}
}
// ─── Unknown Operation ────────────────────────────────────────────────────────
// The forgery is arithmetically sound: every step of ECVRF_verify after
// key validation accepts it.
alphaA, alphaB := []byte("lottery round 1"), []byte("lottery round 2")
piA, piB := forge(alphaA), forge(alphaB)
func TestUnknownOp(t *testing.T) {
input := []byte{0xFF}
result, gas, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
if err != nil {
t.Fatal(err)
}
if result != nil {
t.Error("expected nil for unknown op")
}
if gas != testGas {
t.Errorf("expected full gas on unknown op, got %d", gas)
}
}
// ─── Invalid Public Key ───────────────────────────────────────────────────────
func TestVerifyInvalidPublicKey(t *testing.T) {
pk := make([]byte, 32)
alpha := []byte("test")
proof := make([]byte, ProofSize)
input := buildVerifyInput(pk, alpha, proof)
result, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
if err != nil {
t.Fatal(err)
}
if result != nil {
t.Error("expected nil for invalid public key")
}
}
// ─── Invalid Proof ────────────────────────────────────────────────────────────
func TestVerifyInvalidProof(t *testing.T) {
// Generate a proof with one key, try to verify against a different key.
sk1 := mustHex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
_, pk1 := expandSecretKey(sk1)
sk2 := mustHex("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb")
_, pk2 := expandSecretKey(sk2)
alpha := []byte("test message")
// Prove with sk1
pi := testProve(sk1, pk1, alpha)
// Verify against pk2 -- must fail
input := buildVerifyInput(pk2.Bytes(), alpha, pi)
result, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
if err != nil {
t.Fatal(err)
}
if result != nil {
t.Error("expected nil when verifying proof against wrong public key")
}
}
// ─── Self-Consistency Tests ───────────────────────────────────────────────────
func TestProveAndVerify(t *testing.T) {
tests := []struct {
name string
skHex string
for _, tc := range []struct {
alpha []byte
}{
{
name: "empty_alpha",
skHex: "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
alpha: []byte{},
},
{
name: "single_byte",
skHex: "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb",
alpha: []byte{0x72},
},
{
name: "two_bytes",
skHex: "c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7",
alpha: []byte{0xaf, 0x82},
},
{
name: "ascii_string",
skHex: "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
alpha: []byte("Example of ECVRF-EDWARDS25519-SHA512-ELL2"),
},
{
name: "long_alpha",
skHex: "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb",
alpha: make([]byte, 256),
},
pi []byte
}{{alphaA, piA}, {alphaB, piB}} {
Gamma, c, sc, ok := decodeProof(tc.pi)
require.True(t, ok)
Y, err := new(edwards.Point).SetBytes(pk)
require.NoError(t, err)
H := encodeToCurve(pk, tc.alpha)
cNeg := new(edwards.Scalar).Negate(c)
U := new(edwards.Point).VarTimeDoubleScalarBaseMult(cNeg, Y, sc)
V := new(edwards.Point).Subtract(new(edwards.Point).ScalarMult(sc, H), new(edwards.Point).ScalarMult(c, Gamma))
require.Equal(t, 1, c.Equal(challengeGeneration(Y, H, Gamma, U, V)),
"forgery does not satisfy c == c'; the test no longer models the attack")
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sk := mustHex(tt.skHex)
x, Y := expandSecretKey(sk)
// The output it would have produced is one constant, known before either
// alpha_string was chosen.
predicted := proofToHash(identity)
require.Equal(t, predicted, proofToHash(identity))
// Prove
pi := testProve(sk, Y, tt.alpha)
if len(pi) != ProofSize {
t.Fatalf("proof size = %d, want %d", len(pi), ProofSize)
}
// And the precompile refuses both.
for _, tc := range []struct {
alpha []byte
pi []byte
}{{alphaA, piA}, {alphaB, piB}} {
input := buildVerifyInput(pk, tc.alpha, tc.pi)
out, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
require.NoError(t, err)
require.Nil(t, out, "identity public key produced a valid VRF output")
require.NotEqual(t, predicted, out)
}
}
// Verify via precompile
input := buildVerifyInput(Y.Bytes(), tt.alpha, pi)
result, gas, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
if err != nil {
t.Fatalf("Run error: %v", err)
}
if gas != testGas-GasVerify {
t.Errorf("remaining gas = %d, want %d", gas, testGas-GasVerify)
}
if result == nil {
t.Fatal("verification returned nil (failed)")
}
if len(result) != BetaSize {
t.Fatalf("beta length = %d, want %d", len(result), BetaSize)
}
// ─── Encoding refusals ────────────────────────────────────────────────────────
// ProofToHash must return the same beta
p2hInput := append([]byte{OpProofToHash}, pi...)
p2hResult, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, p2hInput, testGas, true)
if err != nil {
t.Fatalf("ProofToHash error: %v", err)
}
if hex.EncodeToString(p2hResult) != hex.EncodeToString(result) {
t.Errorf("ProofToHash beta differs from Verify beta")
}
func TestVerify_RejectsShortInput(t *testing.T) {
minLen := PublicKeySize + AlphaLenSize + ProofSize
for n := 0; n < minLen; n += 7 {
input := append([]byte{OpVerify}, make([]byte, n)...)
out, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
require.NoError(t, err)
require.Nilf(t, out, "accepted a %d-byte verify body", n)
}
}
// Determinism: same input, same output
result2, _, _ := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
if hex.EncodeToString(result) != hex.EncodeToString(result2) {
t.Error("VRF verify is not deterministic")
}
// TestVerify_RejectsAlphaLenOverrun covers a declared alpha_len that runs past
// the end of the supplied buffer.
func TestVerify_RejectsAlphaLenOverrun(t *testing.T) {
v := rfc9381TAI[2]
base := buildVerifyInput(mustHex(v.pk), mustHex(v.alpha), mustHex(v.pi))
// Different alpha must produce different beta
otherAlpha := append(tt.alpha, 0xFF)
otherPi := testProve(sk, Y, otherAlpha)
otherInput := buildVerifyInput(Y.Bytes(), otherAlpha, otherPi)
otherResult, _, _ := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, otherInput, testGas, true)
if otherResult != nil && hex.EncodeToString(result) == hex.EncodeToString(otherResult) {
t.Error("different alpha produced same beta")
}
for _, declared := range []uint16{3, 100, 0xFFFF} {
input := bytes.Clone(base)
binary.BigEndian.PutUint16(input[1+PublicKeySize:], declared)
out, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
require.NoError(t, err)
require.Nilf(t, out, "accepted alpha_len=%d over a %d-byte body", declared, len(base)-1)
}
}
_ = x // used in prove
// TestVerify_RejectsTrailingBytes: a declared alpha_len shorter than the body,
// or bytes appended after the proof, would give one query two encodings.
func TestVerify_RejectsTrailingBytes(t *testing.T) {
v := rfc9381TAI[2]
base := buildVerifyInput(mustHex(v.pk), mustHex(v.alpha), mustHex(v.pi))
// Sanity: the exact encoding is accepted.
out, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, base, testGas, true)
require.NoError(t, err)
require.NotNil(t, out)
appended := append(bytes.Clone(base), 0x00)
out, _, err = VRFPrecompile.Run(nil, ContractAddress, ContractAddress, appended, testGas, true)
require.NoError(t, err)
require.Nil(t, out, "accepted a trailing byte")
// alpha_len one short: the last alpha byte would be read as proof.
short := bytes.Clone(base)
binary.BigEndian.PutUint16(short[1+PublicKeySize:], 1)
out, _, err = VRFPrecompile.Run(nil, ContractAddress, ContractAddress, short, testGas, true)
require.NoError(t, err)
require.Nil(t, out, "accepted a short alpha_len over a longer body")
}
// notOnCurve is a y with no matching x: (y^2-1)/(dy^2+1) is not a square.
const notOnCurve = "0200000000000000000000000000000000000000000000000000000000000000"
// nonCanonicalPoints are octet strings that edwards25519.Point.SetBytes accepts
// but RFC 8032 string_to_point does not: an unreduced y, and a set sign bit
// where x is zero. Each aliases a point that already has its own encoding, so
// accepting them would give one Gamma two pi_strings and one key two addresses.
var nonCanonicalPoints = map[string]string{
"y_equal_to_p": "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
"y_above_p": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"identity_sign_set": "0100000000000000000000000000000000000000000000000000000000000080",
"order_two_sign_set": "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
}
func TestDecodePoint_RejectsNonCanonicalEncodings(t *testing.T) {
for name, h := range nonCanonicalPoints {
t.Run(name, func(t *testing.T) {
raw := mustHex(h)
// The leniency being closed here is real: the library accepts it.
P, err := new(edwards.Point).SetBytes(raw)
require.NoError(t, err, "test constant is not accepted even leniently")
require.NotEqual(t, h, hex.EncodeToString(P.Bytes()), "test constant is canonical after all")
_, err = decodePoint(raw)
require.ErrorIs(t, err, errNonCanonicalPoint)
// The canonical encoding of the same point is accepted.
canonical, err := decodePoint(P.Bytes())
require.NoError(t, err)
require.Equal(t, 1, canonical.Equal(P))
})
}
_, err := decodePoint(mustHex(notOnCurve))
require.Error(t, err)
require.NotErrorIs(t, err, errNonCanonicalPoint)
_, err = decodePoint(make([]byte, 31))
require.Error(t, err)
}
func TestVerify_RejectsUndecodableGamma(t *testing.T) {
v := rfc9381TAI[0]
pi := mustHex(v.pi)
mutants := map[string]string{"not_on_curve": notOnCurve}
for name, h := range nonCanonicalPoints {
mutants[name] = h
}
for name, h := range mutants {
t.Run(name, func(t *testing.T) {
mutant := bytes.Clone(pi)
copy(mutant[:32], mustHex(h))
_, _, _, ok := decodeProof(mutant)
require.False(t, ok)
input := buildVerifyInput(mustHex(v.pk), mustHex(v.alpha), mutant)
out, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
require.NoError(t, err)
require.Nil(t, out)
p2h, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, append([]byte{OpProofToHash}, mutant...), testGas, true)
require.NoError(t, err)
require.Nil(t, p2h)
})
}
}
// ─── Uniqueness: Different keys produce different outputs ─────────────────────
// TestVerify_RejectsNonCanonicalS pins the s < q check in ECVRF_decode_proof.
// Without it a proof has a second encoding, s + q, which verifies identically.
func TestVerify_RejectsNonCanonicalS(t *testing.T) {
v := rfc9381TAI[0]
pi := bytes.Clone(mustHex(v.pi))
for i := 48; i < 80; i++ {
pi[i] = 0xFF
}
_, _, _, ok := decodeProof(pi)
require.False(t, ok, "s >= q must not decode")
input := buildVerifyInput(mustHex(v.pk), mustHex(v.alpha), pi)
out, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
require.NoError(t, err)
require.Nil(t, out)
}
func TestVerify_RejectsUndecodablePublicKey(t *testing.T) {
v := rfc9381TAI[0]
keys := map[string]string{"not_on_curve": notOnCurve}
for name, h := range nonCanonicalPoints {
keys[name] = h
}
for name, h := range keys {
t.Run(name, func(t *testing.T) {
input := buildVerifyInput(mustHex(h), mustHex(v.alpha), mustHex(v.pi))
out, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
require.NoError(t, err)
require.Nil(t, out)
})
}
}
func TestProofToHash_RequiresExactProofLength(t *testing.T) {
pi := mustHex(rfc9381TAI[0].pi)
for _, n := range []int{0, 1, ProofSize - 1, ProofSize + 1, 2 * ProofSize} {
body := make([]byte, n)
copy(body, pi)
out, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, append([]byte{OpProofToHash}, body...), testGas, true)
require.NoError(t, err)
require.Nilf(t, out, "accepted a %d-byte proof", n)
}
}
func TestProofToHash_RejectsUndecodableGamma(t *testing.T) {
pi := bytes.Clone(mustHex(rfc9381TAI[0].pi))
copy(pi[:32], mustHex("0200000000000000000000000000000000000000000000000000000000000000"))
out, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, append([]byte{OpProofToHash}, pi...), testGas, true)
require.NoError(t, err)
require.Nil(t, out)
}
// ─── Round trip ───────────────────────────────────────────────────────────────
// TestProveAndVerify covers alpha_string lengths the published vectors do not
// reach. The published vectors are what pin the construction; this pins that
// nothing breaks as alpha grows.
func TestProveAndVerify(t *testing.T) {
sk := mustHex(rfc9381TAI[1].sk)
_, Y := expandSecretKey(sk)
for _, alpha := range [][]byte{
[]byte("ECVRF-EDWARDS25519-SHA512-TAI"),
make([]byte, 256),
bytes.Repeat([]byte{0xA5}, 4096),
} {
pi := testProve(sk, Y, alpha)
require.Len(t, pi, ProofSize)
input := buildVerifyInput(Y.Bytes(), alpha, pi)
beta, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
require.NoError(t, err)
require.Len(t, beta, BetaSize)
// proof_to_hash agrees with verify, and both are deterministic.
p2h, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, append([]byte{OpProofToHash}, pi...), testGas, true)
require.NoError(t, err)
require.Equal(t, beta, p2h)
again, _, _ := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
require.Equal(t, beta, again)
}
}
// TestUniqueness: distinct keys over one alpha_string give distinct outputs.
func TestUniqueness(t *testing.T) {
sk1 := mustHex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
sk2 := mustHex("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb")
alpha := []byte("uniqueness test")
alpha := []byte("uniqueness")
seen := map[string]bool{}
_, Y1 := expandSecretKey(sk1)
_, Y2 := expandSecretKey(sk2)
for _, v := range rfc9381TAI {
sk := mustHex(v.sk)
_, Y := expandSecretKey(sk)
pi := testProve(sk, Y, alpha)
pi1 := testProve(sk1, Y1, alpha)
pi2 := testProve(sk2, Y2, alpha)
// Both must verify
input1 := buildVerifyInput(Y1.Bytes(), alpha, pi1)
result1, _, _ := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input1, testGas, true)
if result1 == nil {
t.Fatal("sk1 proof failed to verify")
}
input2 := buildVerifyInput(Y2.Bytes(), alpha, pi2)
result2, _, _ := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input2, testGas, true)
if result2 == nil {
t.Fatal("sk2 proof failed to verify")
}
// Different keys must produce different betas
if hex.EncodeToString(result1) == hex.EncodeToString(result2) {
t.Error("different secret keys produced the same beta")
input := buildVerifyInput(Y.Bytes(), alpha, pi)
beta, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
require.NoError(t, err)
require.NotNil(t, beta)
require.False(t, seen[hex.EncodeToString(beta)], "two keys produced one beta")
seen[hex.EncodeToString(beta)] = true
}
}
// ─── Truncated / Corrupted Proofs ─────────────────────────────────────────────
func TestCorruptedProof(t *testing.T) {
sk := mustHex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
_, Y := expandSecretKey(sk)
alpha := []byte("corruption test")
pi := testProve(sk, Y, alpha)
// Flip a byte in the proof
corrupted := make([]byte, len(pi))
copy(corrupted, pi)
corrupted[40] ^= 0xFF // corrupt the c scalar
input := buildVerifyInput(Y.Bytes(), alpha, corrupted)
result, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
// inPrimeOrderSubgroup reports whether q*P is the identity. Scalars reduce mod
// q, so q itself is not representable; (q-1)*P + P is.
func inPrimeOrderSubgroup(P *edwards.Point) bool {
one, err := new(edwards.Scalar).SetCanonicalBytes(append([]byte{1}, make([]byte, 31)...))
if err != nil {
t.Fatal(err)
panic(err)
}
if result != nil {
t.Error("corrupted proof should not verify")
qMinus1 := new(edwards.Scalar).Negate(one)
qP := new(edwards.Point).Add(new(edwards.Point).ScalarMult(qMinus1, P), P)
return qP.Equal(edwards.NewIdentityPoint()) == 1
}
// TestEncodeToCurve_IsInThePrimeOrderSubgroup: encode_to_curve multiplies by
// the cofactor and rejects the identity, so H always lies in the group the
// proof is checked in.
func TestEncodeToCurve_IsInThePrimeOrderSubgroup(t *testing.T) {
identity := edwards.NewIdentityPoint()
// The predicate has teeth: a torsion point fails it.
for _, h := range smallOrderKeys[1:] {
P, err := new(edwards.Point).SetBytes(mustHex(h))
require.NoError(t, err)
require.Falsef(t, inPrimeOrderSubgroup(P), "%s should not be in the prime-order subgroup", h)
}
for i := range 64 {
H := encodeToCurve([]byte{byte(i)}, []byte("subgroup"))
require.NotNil(t, H)
require.Equal(t, 0, H.Equal(identity), "encode_to_curve returned the identity")
require.True(t, inPrimeOrderSubgroup(H))
}
}
// ─── Gas Accounting ───────────────────────────────────────────────────────────
// TestInterpretHashAsPoint_AdvancesTheCounterOnATorsionPoint pins the identity
// rejection inside encode_to_curve. If a counter's octets decode to a point the
// cofactor kills, H would be the identity and V = s*H would carry no binding to
// alpha_string at all, so the counter has to advance instead.
func TestInterpretHashAsPoint_AdvancesTheCounterOnATorsionPoint(t *testing.T) {
hash := make([]byte, 64)
func TestGasAccountingVerify(t *testing.T) {
sk := mustHex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
_, Y := expandSecretKey(sk)
alpha := []byte{}
pi := testProve(sk, Y, alpha)
for _, h := range smallOrderKeys {
copy(hash, mustHex(h))
require.Nilf(t, interpretHashAsPoint(hash), "torsion point %s was used as H", h)
}
input := buildVerifyInput(Y.Bytes(), alpha, pi)
_, gas, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
if err != nil {
t.Fatal(err)
}
if gas != testGas-GasVerify {
t.Errorf("remaining gas = %d, want %d", gas, testGas-GasVerify)
}
copy(hash, mustHex(notOnCurve))
require.Nil(t, interpretHashAsPoint(hash))
copy(hash, mustHex(nonCanonicalPoints["y_above_p"]))
require.Nil(t, interpretHashAsPoint(hash))
// A hash that does decode gives a point in the prime-order subgroup.
H := encodeToCurve(mustHex(rfc9381TAI[0].pk), nil)
require.NotNil(t, H)
require.True(t, inPrimeOrderSubgroup(H))
}
func TestGasAccountingProofToHash(t *testing.T) {
sk := mustHex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
_, Y := expandSecretKey(sk)
pi := testProve(sk, Y, []byte{})
// TestDecodeProof_RequiresExactLength: ECVRF_decode_proof splits pi_string at
// fixed offsets, so a short or long buffer is refused rather than padded.
func TestDecodeProof_RequiresExactLength(t *testing.T) {
pi := mustHex(rfc9381TAI[0].pi)
for _, n := range []int{0, ProofSize - 1, ProofSize + 1} {
buf := make([]byte, n)
copy(buf, pi)
_, _, _, ok := decodeProof(buf)
require.Falsef(t, ok, "decoded a %d-byte pi_string", n)
}
input := append([]byte{OpProofToHash}, pi...)
_, gas, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
if err != nil {
t.Fatal(err)
}
if gas != testGas-GasProofToHash {
t.Errorf("remaining gas = %d, want %d", gas, testGas-GasProofToHash)
_, _, _, ok := decodeProof(pi)
require.True(t, ok)
}
// TestChallengeGeneration_AcceptsEveryTruncation: the challenge is 16 bytes
// widened to a 32-byte scalar, which is below the group order for every
// possible value, so the encoding check it goes through cannot reject one.
func TestChallengeGeneration_AcceptsEveryTruncation(t *testing.T) {
var maxTruncation [32]byte
for i := range 16 {
maxTruncation[i] = 0xFF
}
c, err := new(edwards.Scalar).SetCanonicalBytes(maxTruncation[:])
require.NoError(t, err)
require.NotNil(t, c)
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
func buildVerifyInput(pk, alpha, proof []byte) []byte {
var alphaLen [2]byte
var alphaLen [AlphaLenSize]byte
binary.BigEndian.PutUint16(alphaLen[:], uint16(len(alpha)))
input := []byte{OpVerify}
@@ -344,26 +585,24 @@ func mustHex(s string) []byte {
return b
}
// ─── Test-only VRF Prove (RFC 9381 section 5.1) ──────────────────────────────
// ─── Test-only prover (RFC 9381 Section 5.1) ─────────────────────────────────
// expandSecretKey expands a 32-byte Ed25519 secret key into the scalar x
// and public point Y = x*B, using the same derivation as Ed25519.
// expandSecretKey derives (x, Y) from an Ed25519 seed exactly as RFC 9381
// Section 5.5 specifies for this suite.
func expandSecretKey(sk []byte) (*edwards.Scalar, *edwards.Point) {
h := sha512.Sum512(sk)
// SetBytesWithClamping applies RFC 8032 clamping and reduces mod L.
x, err := new(edwards.Scalar).SetBytesWithClamping(h[:32])
if err != nil {
panic(err)
}
Y := new(edwards.Point).ScalarBaseMult(x)
return x, Y
return x, new(edwards.Point).ScalarBaseMult(x)
}
// testProve implements ECVRF_prove (RFC 9381 section 5.1) for testing.
// testProve implements ECVRF_prove (RFC 9381 Section 5.1) with the nonce
// generation of Section 5.4.2.2. TestRFC9381_Prove requires it to reproduce
// the published pi_string, so it is an implementation of the RFC rather than a
// mirror of the verifier.
func testProve(sk []byte, Y *edwards.Point, alpha []byte) []byte {
// Expand SK using Ed25519 key derivation.
expanded := sha512.Sum512(sk)
x, err := new(edwards.Scalar).SetBytesWithClamping(expanded[:32])
@@ -371,77 +610,53 @@ func testProve(sk []byte, Y *edwards.Point, alpha []byte) []byte {
panic(err)
}
pkBytes := Y.Bytes()
// H = hash_to_curve(pk, alpha)
H := hashToCurveELL2(pkBytes, alpha)
H := encodeToCurve(Y.Bytes(), alpha)
if H == nil {
panic("hash_to_curve returned nil")
panic("encode_to_curve found no point")
}
// Gamma = x * H
Gamma := new(edwards.Point).ScalarMult(x, H)
// Nonce generation (RFC 9381 section 5.4.2.1):
// k_string = SHA-512(truncated_hashed_sk || encode(H))
// truncated_hashed_sk = expanded[32:64]
nonceHash := sha512.New()
nonceHash.Write(expanded[32:64])
nonceHash.Write(H.Bytes())
nonceDigest := nonceHash.Sum(nil)
// Reduce to scalar (mod L)
k, err := edwards.NewScalar().SetUniformBytes(nonceDigest)
// k_string = Hash(truncated_hashed_sk_string || point_to_string(H))
nonce := sha512.New()
nonce.Write(expanded[32:64])
nonce.Write(H.Bytes())
k, err := edwards.NewScalar().SetUniformBytes(nonce.Sum(nil))
if err != nil {
panic(err)
}
// U = k * B
U := new(edwards.Point).ScalarBaseMult(k)
// V = k * H
V := new(edwards.Point).ScalarMult(k, H)
// c = challenge_generation(Y, H, Gamma, U, V)
c := challengeGeneration(Y, H, Gamma, U, V)
// s = k + c * x (mod L)
cx := new(edwards.Scalar).Multiply(c, x)
s := new(edwards.Scalar).Add(k, cx)
s := new(edwards.Scalar).Add(k, new(edwards.Scalar).Multiply(c, x))
// Encode proof: Gamma(32) || c(16) || s(32)
pi := make([]byte, ProofSize)
copy(pi[:32], Gamma.Bytes())
cBytes := c.Bytes()
copy(pi[32:48], cBytes[:16]) // First 16 bytes of c
copy(pi[32:48], cBytes[:16])
copy(pi[48:80], s.Bytes())
return pi
}
// ─── Benchmark ────────────────────────────────────────────────────────────────
// ─── Benchmarks ───────────────────────────────────────────────────────────────
func BenchmarkVerify(b *testing.B) {
sk := mustHex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
_, Y := expandSecretKey(sk)
alpha := []byte{}
pi := testProve(sk, Y, alpha)
input := buildVerifyInput(Y.Bytes(), alpha, pi)
v := rfc9381TAI[1]
input := buildVerifyInput(mustHex(v.pk), mustHex(v.alpha), mustHex(v.pi))
b.ResetTimer()
for i := 0; i < b.N; i++ {
for b.Loop() {
VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true) //nolint:errcheck
}
}
func BenchmarkProofToHash(b *testing.B) {
sk := mustHex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
_, Y := expandSecretKey(sk)
pi := testProve(sk, Y, []byte{})
input := append([]byte{OpProofToHash}, pi...)
input := append([]byte{OpProofToHash}, mustHex(rfc9381TAI[1].pi)...)
b.ResetTimer()
for i := 0; i < b.N; i++ {
for b.Loop() {
VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true) //nolint:errcheck
}
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright (C) 2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package vrf
import (
"encoding/json"
"testing"
"github.com/luxfi/precompile/modules"
"github.com/luxfi/precompile/precompileconfig"
"github.com/stretchr/testify/require"
)
func TestModule_RegisteredAtTheAdvertisedAddress(t *testing.T) {
m, ok := modules.GetPrecompileModuleByAddress(ContractAddress)
require.True(t, ok, "vrf is not registered at %s", ContractAddress)
require.Equal(t, ConfigKey, m.ConfigKey)
require.Equal(t, VRFPrecompile, m.Contract)
byKey, ok := modules.GetPrecompileModule(ConfigKey)
require.True(t, ok)
require.Equal(t, ContractAddress, byKey.Address)
}
// TestModule_RegisteringTwiceIsRejected pins the guard that stops a second
// module claiming this address or config key.
func TestModule_RegisteringTwiceIsRejected(t *testing.T) {
require.Error(t, modules.RegisterModule(Module))
}
func TestConfig_MakeConfigMatchesKey(t *testing.T) {
cfg := Module.Configurator.MakeConfig()
require.IsType(t, &Config{}, cfg)
require.Equal(t, ConfigKey, cfg.Key())
}
func TestConfig_Timestamp(t *testing.T) {
var cfg Config
require.Nil(t, cfg.Timestamp())
at := uint64(1700000000)
cfg.Upgrade.BlockTimestamp = &at
require.NotNil(t, cfg.Timestamp())
require.Equal(t, at, *cfg.Timestamp())
}
func TestConfig_IsDisabled(t *testing.T) {
var cfg Config
require.False(t, cfg.IsDisabled())
cfg.Upgrade.Disable = true
require.True(t, cfg.IsDisabled())
}
func TestConfig_Equal(t *testing.T) {
at, other := uint64(10), uint64(11)
a := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: &at}}
same := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: &at}}
later := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: &other}}
disabled := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: &at, Disable: true}}
require.True(t, a.Equal(same))
require.False(t, a.Equal(later))
require.False(t, a.Equal(disabled))
require.False(t, a.Equal(nil))
require.False(t, a.Equal(otherConfig{}))
}
// otherConfig is a Config of a different concrete type, which Equal must
// refuse rather than treat as a match.
type otherConfig struct{ precompileconfig.Config }
func (otherConfig) Key() string { return "not-vrf" }
func TestConfig_VerifyAndConfigureAreTotal(t *testing.T) {
cfg := &Config{}
require.NoError(t, cfg.Verify(nil))
require.NoError(t, Module.Configurator.Configure(nil, cfg, nil, nil))
}
func TestConfig_JSONRoundTrip(t *testing.T) {
at := uint64(42)
in := &Config{Upgrade: precompileconfig.Upgrade{BlockTimestamp: &at}}
raw, err := json.Marshal(in)
require.NoError(t, err)
out := Module.Configurator.MakeConfig()
require.NoError(t, json.Unmarshal(raw, out))
require.True(t, in.Equal(out))
}
+171
View File
@@ -0,0 +1,171 @@
// Copyright (C) 2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package vrf
import (
"encoding/hex"
"testing"
edwards "filippo.io/edwards25519"
"github.com/stretchr/testify/require"
)
// RFC 9381 Appendix B.3 -- ECVRF-EDWARDS25519-SHA512-TAI.
//
// These are the only oracle in this package that does not come from the
// implementation itself: a prove/verify round trip agrees with itself even
// when every domain separator, the suite octet and the point encoding are all
// wrong together. Each field below is transcribed from the RFC.
var rfc9381TAI = []struct {
name string
sk string // secret key (Ed25519 seed)
pk string // public key, point_to_string(Y)
alpha string // alpha_string
h string // ECVRF_encode_to_curve(PK, alpha)
pi string // pi_string
beta string // beta_string
}{
{
name: "example16_empty_alpha",
sk: "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
pk: "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
alpha: "",
h: "91bbed02a99461df1ad4c6564a5f5d829d0b90cfc7903e7a5797bd658abf3318",
pi: "8657106690b5526245a92b003bb079ccd1a92130477671f6fc01ad16f26f723f26f8a57ccaed74ee1b190bed1f479d9727d2d0f9b005a6e456a35d4fb0daab1268a1b0db10836d9826a528ca76567805",
beta: "90cf1df3b703cce59e2a35b925d411164068269d7b2d29f3301c03dd757876ff66b71dda49d2de59d03450451af026798e8f81cd2e333de5cdf4f3e140fdd8ae",
},
{
name: "example17_one_byte_alpha",
sk: "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb",
pk: "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",
alpha: "72",
h: "5b659fc3d4e9263fd9a4ed1d022d75eaacc20df5e09f9ea937502396598dc551",
pi: "f3141cd382dc42909d19ec5110469e4feae18300e94f304590abdced48aed5933bf0864a62558b3ed7f2fea45c92a465301b3bbf5e3e54ddf2d935be3b67926da3ef39226bbc355bdc9850112c8f4b02",
beta: "eb4440665d3891d668e7e0fcaf587f1b4bd7fbfe99d0eb2211ccec90496310eb5e33821bc613efb94db5e5b54c70a848a0bef4553a41befc57663b56373a5031",
},
{
name: "example18_two_byte_alpha",
sk: "c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7",
pk: "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025",
alpha: "af82",
h: "bf4339376f5542811de615e3313d2b36f6f53c0acfebb482159711201192576a",
pi: "9bc0f79119cc5604bf02d23b4caede71393cedfbb191434dd016d30177ccbf8096bb474e53895c362d8628ee9f9ea3c0e52c7a5c691b6c18c9979866568add7a2d41b00b05081ed0f58ee5e31b3a970e",
beta: "645427e5d00c62a23fb703732fa5d892940935942101e456ecca7bb217c61c452118fec1219202a0edcf038bb6373241578be7217ba85a2687f7a0310b2df19f",
},
}
// TestRFC9381_Verify runs each published proof through the precompile and
// requires the published beta_string back.
func TestRFC9381_Verify(t *testing.T) {
for _, v := range rfc9381TAI {
t.Run(v.name, func(t *testing.T) {
input := buildVerifyInput(mustHex(v.pk), mustHex(v.alpha), mustHex(v.pi))
got, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
require.NoError(t, err)
require.Equal(t, v.beta, hex.EncodeToString(got))
})
}
}
// TestRFC9381_ProofToHash pins ECVRF_proof_to_hash on its own, so a break in
// the cofactor multiplication or the 0x03 domain separator is attributable.
func TestRFC9381_ProofToHash(t *testing.T) {
for _, v := range rfc9381TAI {
t.Run(v.name, func(t *testing.T) {
input := append([]byte{OpProofToHash}, mustHex(v.pi)...)
got, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
require.NoError(t, err)
require.Equal(t, v.beta, hex.EncodeToString(got))
})
}
}
// TestRFC9381_EncodeToCurve pins ECVRF_encode_to_curve. This is the value the
// suite octet, both domain separators, the sign bit of the candidate y and the
// cofactor multiplication all feed into, so it localises a break that
// TestRFC9381_Verify would only report as "invalid".
func TestRFC9381_EncodeToCurve(t *testing.T) {
for _, v := range rfc9381TAI {
t.Run(v.name, func(t *testing.T) {
H := encodeToCurve(mustHex(v.pk), mustHex(v.alpha))
require.NotNil(t, H)
require.Equal(t, v.h, hex.EncodeToString(H.Bytes()))
})
}
}
// TestRFC9381_Prove requires the test prover to reproduce the published
// pi_string bit for bit. The prover shares encodeToCurve and
// challengeGeneration with the verifier, so this pins the challenge
// derivation and the proof encoding as well.
func TestRFC9381_Prove(t *testing.T) {
for _, v := range rfc9381TAI {
t.Run(v.name, func(t *testing.T) {
Y, err := new(edwards.Point).SetBytes(mustHex(v.pk))
require.NoError(t, err)
_, derived := expandSecretKey(mustHex(v.sk))
require.Equal(t, v.pk, hex.EncodeToString(derived.Bytes()),
"public key derived from SK must match the published PK")
pi := testProve(mustHex(v.sk), Y, mustHex(v.alpha))
require.Equal(t, v.pi, hex.EncodeToString(pi))
})
}
}
// TestRFC9381_ProofIsBoundToAlpha flips one bit of alpha_string and requires
// the published proof to stop verifying. A verifier that ignored alpha would
// pass every test above.
func TestRFC9381_ProofIsBoundToAlpha(t *testing.T) {
for _, v := range rfc9381TAI {
t.Run(v.name, func(t *testing.T) {
alpha := append(mustHex(v.alpha), 0x00)
input := buildVerifyInput(mustHex(v.pk), alpha, mustHex(v.pi))
got, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
require.NoError(t, err)
require.Nil(t, got)
})
}
}
// TestRFC9381_ProofIsBoundToKey verifies each proof against the other two
// published public keys and requires refusal.
func TestRFC9381_ProofIsBoundToKey(t *testing.T) {
for i, v := range rfc9381TAI {
for j, other := range rfc9381TAI {
if i == j {
continue
}
t.Run(v.name+"_under_"+other.name, func(t *testing.T) {
input := buildVerifyInput(mustHex(other.pk), mustHex(v.alpha), mustHex(v.pi))
got, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
require.NoError(t, err)
require.Nil(t, got)
})
}
}
}
// TestRFC9381_EveryProofBitIsLoadBearing flips one bit in every byte of each
// published proof and requires all 80 mutants to be refused. A verifier that
// skipped the c == c' comparison, or that only compared a prefix of the
// scalars, survives a single hand-picked corruption but not this.
func TestRFC9381_EveryProofBitIsLoadBearing(t *testing.T) {
for _, v := range rfc9381TAI {
t.Run(v.name, func(t *testing.T) {
pi := mustHex(v.pi)
for i := range pi {
mutant := make([]byte, len(pi))
copy(mutant, pi)
mutant[i] ^= 0x01
input := buildVerifyInput(mustHex(v.pk), mustHex(v.alpha), mutant)
got, _, err := VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true)
require.NoError(t, err)
require.Nilf(t, got, "flipping bit 0 of proof byte %d still verified", i)
}
})
}
}