Files
precompile/vrf/contract_test.go
zeekay 0ba85e6aaf 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.
2026-08-28 15:39:15 -07:00

663 lines
25 KiB
Go

// Copyright (C) 2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
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 = 10_000_000
// ─── Gas ──────────────────────────────────────────────────────────────────────
// 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
}
// 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)
})
}
}
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))
}
}
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))
}
// ─── 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)
}
// 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))
}
}
// 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)
}
}
// 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
}
// 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)
for _, tc := range []struct {
alpha []byte
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")
}
// 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))
// 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)
}
}
// ─── Encoding refusals ────────────────────────────────────────────────────────
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)
}
}
// 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))
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)
}
}
// 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)
})
}
}
// 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) {
alpha := []byte("uniqueness")
seen := map[string]bool{}
for _, v := range rfc9381TAI {
sk := mustHex(v.sk)
_, Y := expandSecretKey(sk)
pi := testProve(sk, Y, alpha)
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
}
}
// 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 {
panic(err)
}
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))
}
}
// 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)
for _, h := range smallOrderKeys {
copy(hash, mustHex(h))
require.Nilf(t, interpretHashAsPoint(hash), "torsion point %s was used as H", h)
}
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))
}
// 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)
}
_, _, _, 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 [AlphaLenSize]byte
binary.BigEndian.PutUint16(alphaLen[:], uint16(len(alpha)))
input := []byte{OpVerify}
input = append(input, pk...)
input = append(input, alphaLen[:]...)
input = append(input, alpha...)
input = append(input, proof...)
return input
}
func mustHex(s string) []byte {
b, err := hex.DecodeString(s)
if err != nil {
panic(err)
}
return b
}
// ─── Test-only prover (RFC 9381 Section 5.1) ─────────────────────────────────
// 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)
x, err := new(edwards.Scalar).SetBytesWithClamping(h[:32])
if err != nil {
panic(err)
}
return x, new(edwards.Point).ScalarBaseMult(x)
}
// 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 {
expanded := sha512.Sum512(sk)
x, err := new(edwards.Scalar).SetBytesWithClamping(expanded[:32])
if err != nil {
panic(err)
}
H := encodeToCurve(Y.Bytes(), alpha)
if H == nil {
panic("encode_to_curve found no point")
}
Gamma := new(edwards.Point).ScalarMult(x, H)
// 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 := new(edwards.Point).ScalarBaseMult(k)
V := new(edwards.Point).ScalarMult(k, H)
c := challengeGeneration(Y, H, Gamma, U, V)
s := new(edwards.Scalar).Add(k, new(edwards.Scalar).Multiply(c, x))
pi := make([]byte, ProofSize)
copy(pi[:32], Gamma.Bytes())
cBytes := c.Bytes()
copy(pi[32:48], cBytes[:16])
copy(pi[48:80], s.Bytes())
return pi
}
// ─── Benchmarks ───────────────────────────────────────────────────────────────
func BenchmarkVerify(b *testing.B) {
v := rfc9381TAI[1]
input := buildVerifyInput(mustHex(v.pk), mustHex(v.alpha), mustHex(v.pi))
b.ResetTimer()
for b.Loop() {
VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true) //nolint:errcheck
}
}
func BenchmarkProofToHash(b *testing.B) {
input := append([]byte{OpProofToHash}, mustHex(rfc9381TAI[1].pi)...)
b.ResetTimer()
for b.Loop() {
VRFPrecompile.Run(nil, ContractAddress, ContractAddress, input, testGas, true) //nolint:errcheck
}
}