STM BLS verification WORKING against live preprod (milestones A, B, partial C)

Key findings from upstream:
- Mithril's BLS msg is NOT signed_message alone — it's
  msgp = signed_message_ascii_bytes || mt_commitment_root_32_bytes
- Mithril uses EMPTY DST for hash-to-G1 (not the IETF BLS suite string)
- Aggregation is NOT plain summation — it's MuSig-style weighted:
  t_i = Blake2b-128(Blake2b-128(sigs_concat) || be_u64(i))
  aggr_sig = Σ t_i · sig_i      (in G1)
  aggr_vk  = Σ t_i · vk_i       (in G2)
  This blocks rogue-key attacks.

Shipped:
- internal/stm/types.go: MultiSig + AVK decoders (hex-of-ASCII-JSON wrapping,
  polymorphic tuple JSON handling via ByteArray + custom UnmarshalJSON)
- internal/stm/bls.go: BlsVerify (pairing check with gnark-crypto)
- internal/stm/aggregate.go: MuSig-style AggregateBLS + BlsAggregateVerify
- synthetic test + live test (build tag 'live') both green

Live preprod head cert (epoch 284, cert 175051cf…):
- 2 signers, 11 total lottery wins
- aggregate verify: PASS ✓
- single-signer verify: PASS ✓

Next: lottery threshold check, Merkle batch-proof verification, glue into
top-level Verify(msg, multi_sig, avk, params) + wire to 'verify' subcommand.
This commit is contained in:
Kayos 2026-04-23 15:53:00 -07:00
parent 8e3a46e90f
commit 32f0057700
8 changed files with 705 additions and 0 deletions

7
go.mod
View file

@ -3,3 +3,10 @@ module git.sulkta.coop/Sulkta-Coop/mithril-go
go 1.26
require github.com/klauspost/compress v1.18.5
require (
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/consensys/gnark-crypto v0.20.1 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/sys v0.43.0 // indirect
)

10
go.sum
View file

@ -1,2 +1,12 @@
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/consensys/gnark-crypto v0.20.1 h1:PXDUBvk8AzhvWowHLWBEAfUQcV1/aZgWIqD6eMpXmDg=
github.com/consensys/gnark-crypto v0.20.1/go.mod h1:RBWrSgy+IDbGR69RRV313th3M/aZU1ubk2om+qHuTSc=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=

143
internal/stm/aggregate.go Normal file
View file

@ -0,0 +1,143 @@
package stm
import (
"encoding/binary"
"fmt"
"math/big"
bls12381 "github.com/consensys/gnark-crypto/ecc/bls12-381"
"golang.org/x/crypto/blake2b"
)
// AggregateBLS performs Mithril's MuSig-style weighted aggregation.
//
// For each signer i with signature σ_i and verification key vk_i:
//
// h₀ = Blake2b-128(σ_0 ‖ σ_1 ‖ … ‖ σ_{n-1})
// t_i = Blake2b-128(h₀ ‖ i_be_u64) // 128-bit scalar
// aggr_σ = Σ t_i · σ_i // in G1
// aggr_vk = Σ t_i · vk_i // in G2
//
// Matches mithril-stm::bls_multi_signature::signature::BlsSignature::aggregate.
// The per-signer scalar prevents rogue-key attacks where an adversary chooses
// their vk to cancel the contribution of honest signers.
//
// Single-signer short-circuit: when n == 1 the function returns the raw
// sig/vk unchanged, matching blst's `if vks.len() < 2 { return (vks[0], sigs[0]) }`.
func AggregateBLS(sigs, vks [][]byte) (aggSig, aggVK []byte, err error) {
if len(sigs) == 0 || len(sigs) != len(vks) {
return nil, nil, fmt.Errorf("sig/vk mismatch: %d/%d", len(sigs), len(vks))
}
if len(sigs) == 1 {
// No aggregation needed; blst returns the single contribution as-is.
return sigs[0], vks[0], nil
}
// Decode all points + subgroup-check up front.
sigPts := make([]bls12381.G1Affine, len(sigs))
vkPts := make([]bls12381.G2Affine, len(vks))
for i := range sigs {
if len(sigs[i]) != bls12381.SizeOfG1AffineCompressed {
return nil, nil, fmt.Errorf("sig[%d] size %d", i, len(sigs[i]))
}
if _, err := sigPts[i].SetBytes(sigs[i]); err != nil {
return nil, nil, fmt.Errorf("sig[%d] decode: %w", i, err)
}
if !sigPts[i].IsInSubGroup() {
return nil, nil, fmt.Errorf("sig[%d] not in subgroup", i)
}
if len(vks[i]) != bls12381.SizeOfG2AffineCompressed {
return nil, nil, fmt.Errorf("vk[%d] size %d", i, len(vks[i]))
}
if _, err := vkPts[i].SetBytes(vks[i]); err != nil {
return nil, nil, fmt.Errorf("vk[%d] decode: %w", i, err)
}
if !vkPts[i].IsInSubGroup() {
return nil, nil, fmt.Errorf("vk[%d] not in subgroup", i)
}
}
// Step 1: h₀ = Blake2b-128(σ_0 ‖ σ_1 ‖ … ‖ σ_{n-1})
//
// Note: the Rust impl updates the hasher with `sig.to_bytes()`, which is
// the compressed 48-byte encoding. We use the input bytes directly —
// they're the same thing, the signer ships compressed form on the wire.
base, err := blake2b.New(16, nil) // 128-bit output
if err != nil {
return nil, nil, err
}
for _, s := range sigs {
base.Write(s)
}
baseDigest := base.Sum(nil) // 16 bytes
// Step 2: scalars t_i = Blake2b-128(h₀ ‖ be_u64(i))
//
// Critical detail: the Rust uses `index.to_be_bytes()` where `index` is
// `usize`. On 64-bit platforms that's 8 BE bytes. blst_lib then treats
// the resulting 128-bit scalar as little-endian when doing the mult.
scalars := make([]*big.Int, len(sigs))
idxBuf := make([]byte, 8)
for i := range sigs {
h, err := blake2b.New(16, nil)
if err != nil {
return nil, nil, err
}
h.Write(baseDigest)
binary.BigEndian.PutUint64(idxBuf, uint64(i))
h.Write(idxBuf)
digest := h.Sum(nil) // 16 bytes LE
// blst interprets the scalar blob as little-endian; gnark's
// ScalarMultiplication takes a big.Int (treated as absolute
// value mod r). Interpret as LE to match blst.
scalars[i] = new(big.Int).SetBytes(reverseBytes(digest))
}
// Step 3: aggr_sig = Σ t_i · σ_i
var aggSigJac bls12381.G1Jac
for i := range sigPts {
var scaled bls12381.G1Affine
scaled.ScalarMultiplication(&sigPts[i], scalars[i])
var jac bls12381.G1Jac
jac.FromAffine(&scaled)
aggSigJac.AddAssign(&jac)
}
var aggSigAff bls12381.G1Affine
aggSigAff.FromJacobian(&aggSigJac)
sigBytes := aggSigAff.Bytes()
// aggr_vk = Σ t_i · vk_i
var aggVKJac bls12381.G2Jac
for i := range vkPts {
var scaled bls12381.G2Affine
scaled.ScalarMultiplication(&vkPts[i], scalars[i])
var jac bls12381.G2Jac
jac.FromAffine(&scaled)
aggVKJac.AddAssign(&jac)
}
var aggVKAff bls12381.G2Affine
aggVKAff.FromJacobian(&aggVKJac)
vkBytes := aggVKAff.Bytes()
return sigBytes[:], vkBytes[:], nil
}
func reverseBytes(b []byte) []byte {
out := make([]byte, len(b))
for i, v := range b {
out[len(b)-1-i] = v
}
return out
}
// BlsAggregateVerify aggregates per-signer contributions (with the
// Mithril MuSig-style scalar weighting) and does one pairing check.
// Does NOT check lottery wins, Merkle membership, or the k-threshold —
// those are separate STM-layer checks.
func BlsAggregateVerify(msg []byte, sigs, vks [][]byte) error {
aggSig, aggVK, err := AggregateBLS(sigs, vks)
if err != nil {
return fmt.Errorf("aggregate: %w", err)
}
return BlsVerify(msg, aggSig, aggVK)
}

81
internal/stm/bls.go Normal file
View file

@ -0,0 +1,81 @@
package stm
import (
"crypto/subtle"
"fmt"
bls12381 "github.com/consensys/gnark-crypto/ecc/bls12-381"
)
// BLS12-381 ciphersuite DST — Mithril uses an EMPTY DST. The Rust impl
// calls `blst_sig.verify(sig_gc, msg, &[], &[], pk, pk_gc)` with an
// empty `dst` slice. hash-to-curve uses no domain separation. Any
// non-empty DST here breaks interop.
var blsDST = []byte{}
// BlsVerify validates a BLS12-381 min_sig signature:
//
// e(sig, G2_gen) == e(H(msg), vk)
//
// sig must be 48 bytes (compressed G1), vk must be 96 bytes (compressed G2).
// Returns nil on success or a typed error on any failure.
func BlsVerify(msg, sig, vk []byte) error {
if len(sig) != bls12381.SizeOfG1AffineCompressed {
return fmt.Errorf("sig wrong size: got %d, want %d", len(sig), bls12381.SizeOfG1AffineCompressed)
}
if len(vk) != bls12381.SizeOfG2AffineCompressed {
return fmt.Errorf("vk wrong size: got %d, want %d", len(vk), bls12381.SizeOfG2AffineCompressed)
}
var sigPt bls12381.G1Affine
if _, err := sigPt.SetBytes(sig); err != nil {
return fmt.Errorf("sig decode: %w", err)
}
// Subgroup check — critical to prevent small-subgroup attacks.
if !sigPt.IsInSubGroup() {
return fmt.Errorf("sig not in prime-order subgroup")
}
var vkPt bls12381.G2Affine
if _, err := vkPt.SetBytes(vk); err != nil {
return fmt.Errorf("vk decode: %w", err)
}
if !vkPt.IsInSubGroup() {
return fmt.Errorf("vk not in prime-order subgroup")
}
// Reject the neutral element as a verification key (would accept anything).
var zero bls12381.G2Affine
zeroBytes := zero.Bytes()
vkBytes := vkPt.Bytes()
if subtle.ConstantTimeCompare(vkBytes[:], zeroBytes[:]) == 1 {
return fmt.Errorf("vk is the identity element")
}
// H(msg) into G1
hPt, err := bls12381.HashToG1(msg, blsDST)
if err != nil {
return fmt.Errorf("hash-to-G1: %w", err)
}
// G2 generator
_, _, _, g2Gen := bls12381.Generators()
// Pairing equation: e(sig, G2_gen) * e(-H(msg), vk) == 1
// gnark's PairingCheck returns true iff the product is 1. We negate
// H(msg) on the G1 side so the equation collapses to identity.
var negH bls12381.G1Affine
negH.Neg(&hPt)
ok, err := bls12381.PairingCheck(
[]bls12381.G1Affine{sigPt, negH},
[]bls12381.G2Affine{g2Gen, vkPt},
)
if err != nil {
return fmt.Errorf("pairing: %w", err)
}
if !ok {
return fmt.Errorf("BLS signature invalid")
}
return nil
}

View file

@ -0,0 +1,111 @@
//go:build live
package stm
import (
"context"
"encoding/json"
"io"
"net/http"
"testing"
"time"
)
// Fetches the head preprod cert, pulls out the (msg, vk_i, sigma_i) triple,
// and tries to BLS-verify ONE signer's sigma against signed_message.as_bytes().
//
// Expectation: this FAILS for a single signer because Mithril's STM layer
// aggregates sigs/vks before BLS-verifying, so an individual (sigma_i, vk_i)
// pair isn't a valid BLS signature of signed_message on its own.
//
// What we learn from this test:
//
// - If a single-signer verify passes: BLS works as expected (unlikely).
// - If it fails with "signature invalid" but no decode errors: our BLS impl
// is correct and the failure is a real property of STM (curve points
// decoded fine, pairing ran, result doesn't match — exactly what the
// aggregation layer fixes).
// - If it fails with a decode error: something in the wire format is off.
//
// This test documents expected behavior rather than asserting pass.
func TestBlsVerify_SingleSignerAgainstSignedMessage(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"`
SignedMessage string `json:"signed_message"`
AggregateVerificationKey json.RawMessage `json:"aggregate_verification_key"`
}
if err := json.Unmarshal(body, &cert); err != nil {
t.Fatal(err)
}
ms, err := DecodeMultiSig(cert.MultiSignature)
if err != nil {
t.Fatal(err)
}
if len(ms.Signatures) == 0 {
t.Fatal("no signers")
}
// Extract the AVK's mt_commitment.root — the BLS-verified message is
// msg || root, where msg is the ASCII bytes of signed_message hex.
avk, err := DecodeAVK(cert.AggregateVerificationKey)
if err != nil {
t.Fatalf("avk decode: %v", err)
}
t.Logf("avk mt_root=%x total_stake=%d nr_leaves=%d",
avk.MerkleRoot, avk.TotalStake, avk.NumLeaves)
msg := append([]byte(cert.SignedMessage), avk.MerkleRoot...)
s0 := ms.Signatures[0]
err = BlsVerify(msg, s0.Sig.Sigma, s0.RegParty.VK)
t.Logf("single-signer[0] BLS verify over signed_message: err=%v", err)
// Don't assert pass/fail here — documenting behavior.
// Aggregate all signers and verify — one entry per signer per lottery
// win (a signer with N wins contributes N copies of the same sigma/vk).
var sigs, vks [][]byte
for _, s := range ms.Signatures {
for range s.Sig.Indexes {
sigs = append(sigs, s.Sig.Sigma)
vks = append(vks, s.RegParty.VK)
}
}
t.Logf("aggregating %d (signer × win) pairs", len(sigs))
if err := BlsAggregateVerify(msg, sigs, vks); err != nil {
t.Logf("aggregate verify (with multiplicity = lottery wins): %v", err)
} else {
t.Logf("aggregate verify (with multiplicity = lottery wins): PASS ✓")
}
// Alternate: one entry per unique signer (no multiplicity)
sigs, vks = nil, nil
for _, s := range ms.Signatures {
sigs = append(sigs, s.Sig.Sigma)
vks = append(vks, s.RegParty.VK)
}
if err := BlsAggregateVerify(msg, sigs, vks); err != nil {
t.Logf("aggregate verify (unique signers only): %v", err)
} else {
t.Logf("aggregate verify (unique signers only): PASS ✓")
}
}

210
internal/stm/types.go Normal file
View file

@ -0,0 +1,210 @@
// Package stm implements Mithril Stake-based Threshold Multi-signature
// decoding and verification.
//
// The wire format of a Mithril multi_signature field is:
//
// hex( ASCII( JSON( ... ) ) )
//
// i.e. hex-encoded bytes that are the UTF-8 of a JSON object. The JSON
// contents are documented in DecodeMultiSig below.
//
// Verification phases:
//
// 1. DecodeMultiSig — parse the wrapped JSON
// 2. BLS single-sig verification of each (signer, sigma) over the msg
// 3. Merkle proof verification: each signer index is a registered party
// 4. Lottery check: for each (index, sigma), evaluate_dense_mapping < threshold(stake)
// 5. Threshold: total distinct lottery wins >= k
//
// Phases 2-5 are stubbed in verify.go pending the BLS crypto sprint.
// This package's current role is rock-solid decoding.
package stm
import (
"encoding/hex"
"encoding/json"
"fmt"
)
// ByteArray decodes from either a JSON array of ints [1,2,3] (Mithril's
// on-wire shape) or a base64 string (Go's default []byte handling).
// Always emits an array of ints for forward compatibility.
type ByteArray []byte
func (b *ByteArray) UnmarshalJSON(data []byte) error {
// Try array-of-ints first — this is what Mithril ships.
var ints []int
if err := json.Unmarshal(data, &ints); err == nil {
out := make([]byte, len(ints))
for i, v := range ints {
if v < 0 || v > 255 {
return fmt.Errorf("byte out of range at %d: %d", i, v)
}
out[i] = byte(v)
}
*b = out
return nil
}
// Fallback: base64 string.
var s string
if err := json.Unmarshal(data, &s); err == nil {
*b = []byte(s)
return nil
}
return fmt.Errorf("ByteArray: neither int-array nor string")
}
// MultiSig is the decoded top-level shape.
type MultiSig struct {
Signatures []SignerEntry `json:"signatures"`
BatchProof BatchProof `json:"batch_proof"`
}
// SignerEntry is a 2-tuple serialized as a JSON array: (StmSig, RegParty).
// We decode it via a custom UnmarshalJSON because JSON heterogeneous
// arrays don't map to Go structs directly.
type SignerEntry struct {
Sig StmSig
RegParty RegParty
}
// StmSig is one signer's contribution: their BLS sig, the lottery
// indices they won, and their index in the registered party list.
type StmSig struct {
Sigma ByteArray `json:"sigma"` // 48-byte BLS G1 compressed sig
Indexes []uint64 `json:"indexes"` // winning lottery indices
SignerIndex uint64 `json:"signer_index"` // party position in registered list
}
// RegParty is also a 2-tuple in JSON: (vk_bytes, stake).
type RegParty struct {
VK ByteArray // 96-byte BLS G2 compressed verification key
Stake uint64
}
// AVK is the cert's aggregate verification key — a Merkle commitment over
// the registered (vk, stake) parties plus the total stake. Shipped on the
// wire as hex-of-ASCII-of-JSON, same wrapping as MultiSig.
type AVK struct {
MerkleRoot ByteArray
NumLeaves uint64
TotalStake uint64
}
// DecodeAVK decodes the wrapped JSON of a Mithril aggregate_verification_key
// field.
func DecodeAVK(rawJSON []byte) (*AVK, error) {
hexStr := string(rawJSON)
if len(hexStr) >= 2 && hexStr[0] == '"' && hexStr[len(hexStr)-1] == '"' {
hexStr = hexStr[1 : len(hexStr)-1]
}
data, err := hex.DecodeString(hexStr)
if err != nil {
return nil, fmt.Errorf("AVK hex: %w", err)
}
var wire struct {
MTCommitment struct {
Root ByteArray `json:"root"`
NrLeaves uint64 `json:"nr_leaves"`
Hasher any `json:"hasher"`
} `json:"mt_commitment"`
TotalStake uint64 `json:"total_stake"`
}
if err := json.Unmarshal(data, &wire); err != nil {
return nil, fmt.Errorf("AVK json: %w", err)
}
if len(wire.MTCommitment.Root) != 32 {
return nil, fmt.Errorf("AVK root: got %d bytes, want 32", len(wire.MTCommitment.Root))
}
return &AVK{
MerkleRoot: wire.MTCommitment.Root,
NumLeaves: wire.MTCommitment.NrLeaves,
TotalStake: wire.TotalStake,
}, nil
}
// BatchProof is a Merkle multi-proof over the registered parties.
type BatchProof struct {
Values []ByteArray `json:"values"` // proof nodes, each 32 bytes (BLAKE2b-256)
Indices []uint64 `json:"indices"` // signer indices being proven
Hasher any `json:"hasher"` // null => BLAKE2b-256 default
}
// UnmarshalJSON for SignerEntry — decode the [StmSig, RegParty] tuple.
func (s *SignerEntry) UnmarshalJSON(b []byte) error {
var raw [2]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
return fmt.Errorf("SignerEntry tuple: %w", err)
}
if err := json.Unmarshal(raw[0], &s.Sig); err != nil {
return fmt.Errorf("SignerEntry.Sig: %w", err)
}
if err := s.RegParty.UnmarshalJSON(raw[1]); err != nil {
return fmt.Errorf("SignerEntry.RegParty: %w", err)
}
return nil
}
// UnmarshalJSON for RegParty — decode the [vk_bytes, stake] tuple.
func (r *RegParty) UnmarshalJSON(b []byte) error {
var raw [2]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
return fmt.Errorf("RegParty tuple: %w", err)
}
if err := r.VK.UnmarshalJSON(raw[0]); err != nil {
return fmt.Errorf("RegParty.VK: %w", err)
}
if err := json.Unmarshal(raw[1], &r.Stake); err != nil {
return fmt.Errorf("RegParty.Stake: %w", err)
}
return nil
}
// DecodeMultiSig takes the raw `multi_signature` field value from a
// Mithril certificate (a JSON string whose contents are hex-encoded
// UTF-8 JSON) and returns the decoded struct.
//
// If rawJSON begins with a JSON string quote, the quotes are stripped
// first; this lets callers pass either the json.RawMessage form or an
// already-unquoted hex string.
func DecodeMultiSig(rawJSON []byte) (*MultiSig, error) {
hexStr := string(rawJSON)
if len(hexStr) >= 2 && hexStr[0] == '"' && hexStr[len(hexStr)-1] == '"' {
hexStr = hexStr[1 : len(hexStr)-1]
}
data, err := hex.DecodeString(hexStr)
if err != nil {
return nil, fmt.Errorf("hex decode: %w", err)
}
var ms MultiSig
if err := json.Unmarshal(data, &ms); err != nil {
return nil, fmt.Errorf("json decode: %w", err)
}
return &ms, nil
}
// TotalWins counts the total number of lottery wins across all signers.
func (m *MultiSig) TotalWins() int {
n := 0
for _, s := range m.Signatures {
n += len(s.Sig.Indexes)
}
return n
}
// DistinctWins returns the set of distinct lottery indices claimed
// across all signers. The Mithril STM spec requires total DISTINCT
// indices >= k for a valid aggregate.
func (m *MultiSig) DistinctWins() []uint64 {
seen := make(map[uint64]struct{})
for _, s := range m.Signatures {
for _, ix := range s.Sig.Indexes {
seen[ix] = struct{}{}
}
}
out := make([]uint64, 0, len(seen))
for ix := range seen {
out = append(out, ix)
}
return out
}

View file

@ -0,0 +1,83 @@
//go:build live
// Live tests — run with: go test -tags live ./internal/stm/
// These hit the real preprod Mithril aggregator and exercise the full decode
// path. Skipped in regular CI because they require network.
package stm
import (
"context"
"encoding/json"
"io"
"net/http"
"testing"
"time"
)
const preprodHead = "https://aggregator.release-preprod.api.mithril.network/aggregator"
func TestDecodeMultiSig_LivePreprodHead(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
// Resolve head cert hash via the latest snapshot
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, preprodHead+"/artifact/cardano-database", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Skipf("network unreachable: %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.Fatalf("decode snapshots: %v", err)
}
if len(snaps) == 0 {
t.Fatal("no snapshots returned")
}
// Fetch the head cert
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, preprodHead+"/certificate/"+snaps[0].CertificateHash, nil)
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("fetch cert: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var cert struct {
MultiSignature json.RawMessage `json:"multi_signature"`
}
if err := json.Unmarshal(body, &cert); err != nil {
t.Fatalf("cert decode: %v", err)
}
ms, err := DecodeMultiSig(cert.MultiSignature)
if err != nil {
t.Fatalf("decode multi_signature: %v", err)
}
if len(ms.Signatures) == 0 {
t.Fatal("zero signers in multi_signature")
}
for i, s := range ms.Signatures {
if len(s.Sig.Sigma) != 48 {
t.Errorf("signer[%d] sigma length: got %d, want 48 (BLS G1 compressed)", i, len(s.Sig.Sigma))
}
if len(s.RegParty.VK) != 96 {
t.Errorf("signer[%d] vk length: got %d, want 96 (BLS G2 compressed)", i, len(s.RegParty.VK))
}
if len(s.Sig.Indexes) == 0 {
t.Errorf("signer[%d] has zero lottery wins", i)
}
}
for i, v := range ms.BatchProof.Values {
if len(v) != 32 {
t.Errorf("batch_proof.values[%d]: got %d bytes, want 32 (BLAKE2b-256)", i, len(v))
}
}
t.Logf("preprod head: %d signers, %d total wins, %d distinct wins, %d proof nodes",
len(ms.Signatures), ms.TotalWins(), len(ms.DistinctWins()), len(ms.BatchProof.Values))
}

View file

@ -0,0 +1,60 @@
package stm
import (
"encoding/json"
"testing"
)
// Minimal synthetic multi_signature JSON (hex-wrapped) exercising the
// 2-tuple unmarshal paths. Covers the JSON → struct shape without
// needing live network calls.
func TestDecodeMultiSig_Synthetic(t *testing.T) {
inner := `{
"signatures": [
[
{"sigma": [1,2,3], "indexes": [0, 42], "signer_index": 7},
[[170, 187], 100]
],
[
{"sigma": [4,5,6], "indexes": [1], "signer_index": 3},
[[204, 221], 250]
]
],
"batch_proof": {
"values": [[1,2,3,4]],
"indices": [7, 3],
"hasher": null
}
}`
// wrap in hex-of-bytes, then JSON-string
hexed := ""
for _, b := range []byte(inner) {
hexed += string("0123456789abcdef"[b>>4]) + string("0123456789abcdef"[b&0x0f])
}
raw, _ := json.Marshal(hexed)
ms, err := DecodeMultiSig(raw)
if err != nil {
t.Fatalf("decode: %v", err)
}
if len(ms.Signatures) != 2 {
t.Fatalf("want 2 signers, got %d", len(ms.Signatures))
}
s0 := ms.Signatures[0]
if s0.Sig.SignerIndex != 7 {
t.Errorf("signer0 index: want 7, got %d", s0.Sig.SignerIndex)
}
if s0.RegParty.Stake != 100 {
t.Errorf("signer0 stake: want 100, got %d", s0.RegParty.Stake)
}
if len(s0.RegParty.VK) != 2 || s0.RegParty.VK[0] != 170 {
t.Errorf("signer0 vk unexpected: %v", s0.RegParty.VK)
}
if ms.TotalWins() != 3 {
t.Errorf("total wins: want 3, got %d", ms.TotalWins())
}
distinct := ms.DistinctWins()
if len(distinct) != 3 {
t.Errorf("distinct wins: want 3, got %d", len(distinct))
}
}