STM full verification landing — milestones C/D/E complete

Implemented the remaining STM verification layers:

- internal/stm/lottery.go: EvaluateSigma (Blake2b-512 lottery draw) +
  IsLotteryWon with Taylor-series threshold comparison (ported from
  mithril-stm::eligibility), big.Rat-based to match Rust's num_bigint/
  num_rational path
- internal/stm/merkle.go: Blake2b-256 Merkle batch-proof verification,
  faithful port of mithril-stm's verify_leaves_membership_from_batch_path
  including the 'current is left/right child' branch logic and the
  1-byte zero pad for missing siblings
- internal/stm/verify.go: top-level stm.Verify(msg, ms, avk, params)
  glues all four checks: k-threshold, lottery, Merkle, BLS aggregate
- cmd: 'verify head' now runs full STM verification; JSON output shows
  signers, wins, params, verified flag
- MCP: new 'mithril_verify_certificate' tool dispatches genesis Ed25519
  vs STM by cert kind

Verified against live networks:
  mainnet head cert bc00b551…  epoch=626  59 signers  1972/16948 wins  ✓
  mainnet genesis   25acfcfe…  epoch=539  Ed25519 ✓
  preprod head      dd9c4fcb…  epoch=284   2 signers    11/100 wins   ✓
  preprod genesis   69bc3bdf…  epoch=196  Ed25519 ✓

This is a consensus-correct pure-Go Mithril client. Single binary,
CGo-free, no upstream Rust dependency.

Next: full chain verification (walk head → genesis, check continuity).
This commit is contained in:
Kayos 2026-04-23 15:58:44 -07:00
parent 32f0057700
commit 920d7cf177
7 changed files with 647 additions and 16 deletions

120
internal/stm/lottery.go Normal file
View file

@ -0,0 +1,120 @@
package stm
import (
"encoding/binary"
"math"
"math/big"
"golang.org/x/crypto/blake2b"
)
// EvaluateSigma computes the 64-byte lottery evaluation for a given
// (msg, index, sigma). Mirrors Rust's evaluate_dense_mapping:
//
// ev = Blake2b-512( "map" || msg || le_u64(index) || sigma_bytes )
//
// The 64-byte output is the lottery draw, interpreted as a big unsigned
// integer in LSF/little-endian byte order per the Rust impl:
//
// rug::Integer::from_digits(&ev, Order::LsfLe)
// num_bigint::BigInt::from_bytes_le(Sign::Plus, &ev)
func EvaluateSigma(msg []byte, index uint64, sigma []byte) [64]byte {
h, _ := blake2b.New512(nil)
h.Write([]byte("map"))
h.Write(msg)
var idxBuf [8]byte
binary.LittleEndian.PutUint64(idxBuf[:], index)
h.Write(idxBuf[:])
h.Write(sigma)
var out [64]byte
copy(out[:], h.Sum(nil))
return out
}
// evAsBigInt converts the 64-byte ev output to a big.Int using LE byte
// order (matching the Rust `from_bytes_le`).
func evAsBigInt(ev [64]byte) *big.Int {
// big.Int.SetBytes is BE; so flip.
rev := make([]byte, len(ev))
for i := range ev {
rev[i] = ev[len(ev)-1-i]
}
return new(big.Int).SetBytes(rev)
}
// IsLotteryWon reports whether a signer with the given stake wins the
// lottery at the claimed index for the given ev.
//
// Predicate: p < 1 - (1 - phi_f)^w, where
//
// p = ev / 2^512
// w = stake / total_stake
// phi_f = protocol parameter in (0, 1]
//
// Equivalent reformulation (used here): `q < exp(-w * c)` where
// `q = 1/(1-p)` and `c = ln(1 - phi_f)`. Evaluated via Taylor series
// with early-stop on the error bound (constant M=3 from the Rust impl).
func IsLotteryWon(phiF float64, ev [64]byte, stake, totalStake uint64) bool {
if math.Abs(phiF-1.0) < 1e-15 {
return true
}
// ev as big int (LE interpretation)
evInt := evAsBigInt(ev)
// evMax = 2^512
evMax := new(big.Int).Lsh(big.NewInt(1), 512)
// q = evMax / (evMax - ev) — a Ratio
denom := new(big.Int).Sub(evMax, evInt)
q := new(big.Rat).SetFrac(new(big.Int).Set(evMax), denom)
// c = ln(1 - phi_f); x = -w * c
cFloat := math.Log(1.0 - phiF)
c := new(big.Rat).SetFloat64(cFloat)
w := new(big.Rat).SetFrac(
new(big.Int).SetUint64(stake),
new(big.Int).SetUint64(totalStake),
)
x := new(big.Rat).Mul(w, c)
x.Neg(x)
return taylorCompare(1000, q, x)
}
// taylorCompare reports whether cmp < exp(x), using a Taylor series
// expansion with an early-stop error heuristic (M = 3).
func taylorCompare(bound int, cmp, x *big.Rat) bool {
newX := new(big.Rat).Set(x)
phi := new(big.Rat).SetInt64(1)
divisor := big.NewInt(1)
three := big.NewRat(3, 1)
absNewX := new(big.Rat)
errorTerm := new(big.Rat)
sum := new(big.Rat)
diff := new(big.Rat)
for i := 0; i < bound; i++ {
phi.Add(phi, newX)
divisor = new(big.Int).Add(divisor, big.NewInt(1))
// newX = newX * x / divisor
nx := new(big.Rat).Mul(newX, x)
nx.Quo(nx, new(big.Rat).SetInt(divisor))
newX = nx
absNewX.Abs(newX)
errorTerm.Mul(absNewX, three)
sum.Add(phi, errorTerm)
if cmp.Cmp(sum) > 0 {
return false
}
diff.Sub(phi, errorTerm)
if cmp.Cmp(diff) < 0 {
return true
}
}
return false
}

162
internal/stm/merkle.go Normal file
View file

@ -0,0 +1,162 @@
package stm
import (
"bytes"
"encoding/binary"
"fmt"
"sort"
"golang.org/x/crypto/blake2b"
)
// Mithril's Merkle tree uses Blake2b-256 over leaf-encodings:
//
// leaf_bytes = vk_96 || stake_be_u64 (104 bytes)
// leaf_hash = Blake2b-256(leaf_bytes)
// internal = Blake2b-256(left_hash || right_hash)
// empty_sib = Blake2b-256(0x00)
//
// The tree is heap-indexed: root at 0, leaves at next_power_of_two(nr_leaves)-1
// through next_power_of_two(nr_leaves)-1 + nr_leaves - 1.
// blake2b256 returns Blake2b-256(data).
func blake2b256(data ...[]byte) []byte {
h, _ := blake2b.New256(nil)
for _, d := range data {
h.Write(d)
}
return h.Sum(nil)
}
// LeafBytes encodes a (vk, stake) pair as the 104-byte leaf value hashed
// into the Merkle tree.
func LeafBytes(vk []byte, stake uint64) []byte {
out := make([]byte, 104)
copy(out[:96], vk)
binary.BigEndian.PutUint64(out[96:], stake)
return out
}
// nextPowerOfTwo returns the smallest power of two >= n. 0 returns 1.
func nextPowerOfTwo(n int) int {
if n <= 1 {
return 1
}
p := 1
for p < n {
p <<= 1
}
return p
}
func mtParent(i int) int { return (i - 1) / 2 }
func mtSibling(i int) int {
if i%2 == 1 {
return i + 1
}
return i - 1
}
// VerifyMerkleBatch verifies a Mithril batch proof: a set of leaf values at
// the given indices are in the tree with the given root. Returns nil on
// success.
//
// Arguments:
// - root: 32-byte Merkle root
// - nrLeaves: total number of leaves in the tree (from the AVK commitment)
// - leafValues: for each proved leaf, its pre-hash bytes (vk||stake)
// - indices: the leaf indices (0-based, within the leaf range); must be
// sorted ascending and len must equal len(leafValues)
// - proofValues: the Merkle path nodes as provided in the batch proof's
// `values` field
//
// The algorithm walks layer-by-layer from leaves to root, consuming
// provided values as siblings when the claimed index's sibling is not
// itself a claimed leaf. Direct port of
// mithril-stm::membership_commitment::merkle_tree::commitment::verify_leaves_membership_from_batch_path.
func VerifyMerkleBatch(root []byte, nrLeaves int, leafValues [][]byte, indices []uint64, proofValues [][]byte) error {
if len(leafValues) != len(indices) {
return fmt.Errorf("leaves/indices count mismatch: %d vs %d", len(leafValues), len(indices))
}
// Must be sorted ascending
ordered := make([]int, len(indices))
for i, v := range indices {
ordered[i] = int(v)
}
sortedCopy := append([]int(nil), ordered...)
sort.Ints(sortedCopy)
for i := range ordered {
if ordered[i] != sortedCopy[i] {
return fmt.Errorf("indices not sorted ascending: %v", indices)
}
}
npo2 := nextPowerOfTwo(nrLeaves)
nrNodes := nrLeaves + npo2 - 1
// Shift leaf positions into tree coordinates.
for i := range ordered {
ordered[i] += npo2 - 1
}
// Hash each leaf.
currentLayer := make([][]byte, len(leafValues))
for i, lv := range leafValues {
currentLayer[i] = blake2b256(lv)
}
values := append([][]byte(nil), proofValues...)
idx := ordered[0]
emptySiblingHash := blake2b256([]byte{0x00})
for idx > 0 {
newHashes := make([][]byte, 0, len(ordered))
newIndices := make([]int, 0, len(ordered))
i := 0
idx = mtParent(idx)
for i < len(ordered) {
newIndices = append(newIndices, mtParent(ordered[i]))
if ordered[i]&1 == 0 {
// Current is a RIGHT child — its sibling (LEFT) comes from proof values.
if len(values) == 0 {
return fmt.Errorf("proof truncated at ordered[%d]=%d (expected left sibling)", i, ordered[i])
}
sib := values[0]
values = values[1:]
newHashes = append(newHashes, blake2b256(sib, currentLayer[i]))
} else {
// Current is a LEFT child — sibling is RIGHT.
sib := mtSibling(ordered[i])
switch {
case i+1 < len(ordered) && ordered[i+1] == sib:
// Sibling is ALSO a claimed leaf already in currentLayer.
newHashes = append(newHashes, blake2b256(currentLayer[i], currentLayer[i+1]))
i++
case sib < nrNodes:
// Sibling not claimed but exists; take from proof.
if len(values) == 0 {
return fmt.Errorf("proof truncated at ordered[%d]=%d (expected right sibling)", i, ordered[i])
}
s := values[0]
values = values[1:]
newHashes = append(newHashes, blake2b256(currentLayer[i], s))
default:
// Right side is beyond tree — empty sibling.
newHashes = append(newHashes, blake2b256(currentLayer[i], emptySiblingHash))
}
}
i++
}
currentLayer = newHashes
ordered = newIndices
}
if len(currentLayer) != 1 {
return fmt.Errorf("verification ended with %d nodes, want 1", len(currentLayer))
}
if !bytes.Equal(currentLayer[0], root) {
return fmt.Errorf("root mismatch: got %x, want %x", currentLayer[0], root)
}
return nil
}

97
internal/stm/verify.go Normal file
View file

@ -0,0 +1,97 @@
package stm
import (
"fmt"
)
// Parameters holds the three scalar values from a Mithril cert's
// protocol_parameters field. Only the three that matter for verification.
type Parameters struct {
K uint64 // minimum distinct lottery wins for a valid aggregate
M uint64 // total lottery slots per signing round
PhiF float64 // lottery success base rate (the "phi_f" param)
}
// Verify runs the full STM aggregate-signature verification:
//
// 1. k-threshold: DistinctWins(multi_sig) >= params.K
// 2. Lottery check: for each (signer, index), ev < threshold(stake)
// 3. Merkle proof: each claimed signer leaf is in avk at its index
// 4. BLS verify: MuSig-aggregated (sig, vk) over (msg || avk.root)
//
// All four must pass. Returns a descriptive error at the first failure.
//
// msg is the ASCII bytes of the certificate's signed_message hex string.
func Verify(msg []byte, ms *MultiSig, avk *AVK, params Parameters) error {
// (1) k-threshold
distinct := ms.DistinctWins()
if uint64(len(distinct)) < params.K {
return fmt.Errorf("k-threshold: got %d distinct wins, want >= %d", len(distinct), params.K)
}
// Also: every claimed index must appear exactly once across all signers.
// Rust enforces `nr_indices == unique_indices.len()` via HashSet.
total := ms.TotalWins()
if total != len(distinct) {
return fmt.Errorf("lottery indices not unique across signers: total=%d distinct=%d",
total, len(distinct))
}
// Compute msgp = msg || avk.MerkleRoot — used by lottery check AND BLS.
msgp := append(append([]byte(nil), msg...), avk.MerkleRoot...)
// (2) Lottery check — per (signer, claimed index).
for i, s := range ms.Signatures {
for _, idx := range s.Sig.Indexes {
if idx >= params.M {
return fmt.Errorf("signer[%d] claimed index %d >= m=%d", i, idx, params.M)
}
ev := EvaluateSigma(msgp, idx, s.Sig.Sigma)
if !IsLotteryWon(params.PhiF, ev, s.RegParty.Stake, avk.TotalStake) {
return fmt.Errorf("signer[%d] lottery loss at index %d (stake=%d/%d)",
i, idx, s.RegParty.Stake, avk.TotalStake)
}
}
}
// (3) Merkle batch proof — prove each signer leaf is in the AVK tree.
leaves := make([][]byte, len(ms.Signatures))
indices := make([]uint64, len(ms.Signatures))
// Rust sorts leaves by signer_index ascending; so must we.
sorted := make([]int, len(ms.Signatures))
for i := range sorted {
sorted[i] = i
}
// Sort indices ascending while tracking permutation
for i := 0; i < len(sorted); i++ {
for j := i + 1; j < len(sorted); j++ {
if ms.Signatures[sorted[j]].Sig.SignerIndex < ms.Signatures[sorted[i]].Sig.SignerIndex {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
for outIdx, origIdx := range sorted {
s := ms.Signatures[origIdx]
leaves[outIdx] = LeafBytes(s.RegParty.VK, s.RegParty.Stake)
indices[outIdx] = s.Sig.SignerIndex
}
proofVals := make([][]byte, len(ms.BatchProof.Values))
for i, v := range ms.BatchProof.Values {
proofVals[i] = v
}
if err := VerifyMerkleBatch(avk.MerkleRoot, int(avk.NumLeaves), leaves, indices, proofVals); err != nil {
return fmt.Errorf("merkle batch proof: %w", err)
}
// (4) BLS aggregate verify — unique signers, no multiplicity.
sigs := make([][]byte, len(ms.Signatures))
vks := make([][]byte, len(ms.Signatures))
for i, s := range ms.Signatures {
sigs[i] = s.Sig.Sigma
vks[i] = s.RegParty.VK
}
if err := BlsAggregateVerify(msgp, sigs, vks); err != nil {
return fmt.Errorf("bls aggregate: %w", err)
}
return nil
}

View file

@ -0,0 +1,72 @@
//go:build live
package stm
import (
"context"
"encoding/json"
"io"
"net/http"
"testing"
"time"
)
func TestFullSTMVerify_LivePreprodHead(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, preprodHead+"/artifact/cardano-database", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Skipf("network: %v", err)
}
defer resp.Body.Close()
var snaps []struct {
CertificateHash string `json:"certificate_hash"`
}
if err := json.NewDecoder(resp.Body).Decode(&snaps); err != nil {
t.Fatal(err)
}
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, preprodHead+"/certificate/"+snaps[0].CertificateHash, nil)
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var cert struct {
MultiSignature json.RawMessage `json:"multi_signature"`
AggregateVerificationKey json.RawMessage `json:"aggregate_verification_key"`
SignedMessage string `json:"signed_message"`
Metadata struct {
Parameters struct {
K uint64 `json:"k"`
M uint64 `json:"m"`
PhiF float64 `json:"phi_f"`
} `json:"parameters"`
} `json:"metadata"`
}
if err := json.Unmarshal(body, &cert); err != nil {
t.Fatal(err)
}
ms, err := DecodeMultiSig(cert.MultiSignature)
if err != nil {
t.Fatal(err)
}
avk, err := DecodeAVK(cert.AggregateVerificationKey)
if err != nil {
t.Fatal(err)
}
params := Parameters{
K: cert.Metadata.Parameters.K,
M: cert.Metadata.Parameters.M,
PhiF: cert.Metadata.Parameters.PhiF,
}
t.Logf("params: k=%d m=%d phi_f=%v", params.K, params.M, params.PhiF)
msg := []byte(cert.SignedMessage)
if err := Verify(msg, ms, avk, params); err != nil {
t.Fatalf("STM Verify: %v", err)
}
t.Logf("✓ FULL STM verification passed against live preprod head cert")
}