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:
parent
8e3a46e90f
commit
32f0057700
8 changed files with 705 additions and 0 deletions
7
go.mod
7
go.mod
|
|
@ -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
10
go.sum
|
|
@ -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
143
internal/stm/aggregate.go
Normal 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
81
internal/stm/bls.go
Normal 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
|
||||
}
|
||||
111
internal/stm/bls_live_test.go
Normal file
111
internal/stm/bls_live_test.go
Normal 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
210
internal/stm/types.go
Normal 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
|
||||
}
|
||||
83
internal/stm/types_live_test.go
Normal file
83
internal/stm/types_live_test.go
Normal 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))
|
||||
}
|
||||
60
internal/stm/types_test.go
Normal file
60
internal/stm/types_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue