- internal/chain: end-to-end chain verification. Walks head → genesis,
verifies every cert (Ed25519 or STM as appropriate), and checks
continuity at every boundary:
epoch: same or +1 from previous
hash: current.previous_hash == previous.hash
AVK: same epoch → equal aggregate_verification_key
new epoch → matches previous.protocol_message.next_aggregate_verification_key
- cmd: 'verify chain' subcommand + 'verify manifest <dir>' for SHA-checking
downloaded immutable files
- internal/manifest: per-file SHA-256 verification against the digests.json
shipped in the snapshot's digests archive
- MCP: 8th tool 'mithril_verify_chain' for agent-driven full-chain verify
- README: complete rewrite — status table, architecture, gotchas, MCP
tool surface, exit code contract, build instructions
- LICENSE: Apache-2.0 (matches upstream Mithril)
Verified end to end against live networks:
preprod chain 90 certs (89 STM + 1 genesis) 1124 wins ✓
mainnet chain 89 certs (88 STM + 1 genesis) 210921 wins ✓
That's the wrap. Pure-Go consensus-correct Mithril client, single 10 MB
static binary, MCP-native, no CGo, no upstream Rust runtime.
707 lines
21 KiB
Go
707 lines
21 KiB
Go
// Command mithril-go is a pure-Go client for the Cardano Mithril protocol.
|
|
//
|
|
// It downloads, verifies, and extracts Mithril-certified snapshots of the
|
|
// Cardano database without requiring the upstream Rust mithril-client.
|
|
//
|
|
// Subcommands:
|
|
// list — list available cardano-database snapshots on an aggregator
|
|
// show — show full detail for a snapshot (or "latest")
|
|
// download — fetch a snapshot (digests + ancillary; optionally immutables)
|
|
// verify — verify an already-downloaded snapshot
|
|
// info — show aggregator + network details
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"syscall"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/aggregator"
|
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/artifact"
|
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/chain"
|
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/manifest"
|
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/mcp"
|
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/networks"
|
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/stm"
|
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/verify"
|
|
)
|
|
|
|
const version = "0.0.3-dev"
|
|
|
|
// Stable exit codes. Any addition goes at the end; existing values
|
|
// don't renumber. LLM/automation-friendly contract.
|
|
const (
|
|
exitOK = 0
|
|
exitGeneric = 1
|
|
exitUsage = 2
|
|
exitNetwork = 3 // aggregator unreachable / HTTP error
|
|
exitIntegrity = 4 // SHA mismatch, archive truncated, tar-slip
|
|
exitBadSig = 5 // genesis Ed25519 or STM BLS verification failed
|
|
exitCanceled = 130
|
|
)
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
cmd := os.Args[1]
|
|
args := os.Args[2:]
|
|
switch cmd {
|
|
case "list":
|
|
os.Exit(cmdList(ctx, args))
|
|
case "show":
|
|
os.Exit(cmdShow(ctx, args))
|
|
case "download":
|
|
os.Exit(cmdDownload(ctx, args))
|
|
case "verify":
|
|
os.Exit(cmdVerify(ctx, args))
|
|
case "cert":
|
|
os.Exit(cmdCert(ctx, args))
|
|
case "mcp":
|
|
os.Exit(cmdMCP(ctx, args))
|
|
case "info":
|
|
os.Exit(cmdInfo(args))
|
|
case "version", "--version", "-v":
|
|
fmt.Println("mithril-go", version)
|
|
case "help", "--help", "-h":
|
|
usage()
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", cmd)
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Fprintln(os.Stderr, `mithril-go — pure Go Mithril snapshot client
|
|
|
|
Usage:
|
|
mithril-go <command> [flags]
|
|
|
|
Commands:
|
|
list List available cardano-database snapshots
|
|
show Show detail for one snapshot (hash or "latest")
|
|
cert Show a certificate or walk the chain back to genesis
|
|
download Download a snapshot to a target directory
|
|
verify Verify certificates (genesis / head / chain / <hash>)
|
|
mcp Run as a Model Context Protocol server over stdio
|
|
info Show network + aggregator info
|
|
version Print version
|
|
help Show this help
|
|
|
|
Common flags:
|
|
-network mainnet | preprod | preview (default: preprod)
|
|
-json emit structured JSON (info, list, show, cert)
|
|
|
|
Exit codes:
|
|
0 success
|
|
1 generic error
|
|
2 usage error
|
|
3 network / aggregator error
|
|
4 integrity failure (SHA, archive, tar-slip)
|
|
5 signature verification failure (genesis or STM)
|
|
130 canceled (SIGINT)`)
|
|
}
|
|
|
|
func resolveNetwork(fs *flag.FlagSet, args []string) (networks.Network, []string, error) {
|
|
networkName := fs.String("network", "preprod", "Cardano network: mainnet | preprod | preview")
|
|
if err := fs.Parse(args); err != nil {
|
|
return networks.Network{}, nil, err
|
|
}
|
|
n, ok := networks.ByName(*networkName)
|
|
if !ok {
|
|
return networks.Network{}, nil, fmt.Errorf("unknown network: %s", *networkName)
|
|
}
|
|
return n, fs.Args(), nil
|
|
}
|
|
|
|
func cmdList(ctx context.Context, args []string) int {
|
|
fs := flag.NewFlagSet("list", flag.ExitOnError)
|
|
asJSON := fs.Bool("json", false, "emit structured JSON")
|
|
n, _, err := resolveNetwork(fs, args)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return 2
|
|
}
|
|
c := aggregator.New(n.AggregatorURL)
|
|
snaps, err := c.ListCardanoDBSnapshots(ctx)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "list:", err)
|
|
return exitNetwork
|
|
}
|
|
if *asJSON {
|
|
return emitJSON(map[string]any{"network": n.Name, "snapshots": snaps})
|
|
}
|
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(tw, "HASH\tEPOCH\tIMMUTABLE\tSIZE\tCREATED")
|
|
for _, s := range snaps {
|
|
fmt.Fprintf(tw, "%.16s\t%d\t%d\t%s\t%s\n",
|
|
s.Hash, s.Beacon.Epoch, s.Beacon.ImmutableFileNumber,
|
|
humanSize(s.TotalDBSizeUncompressed),
|
|
s.CreatedAt.UTC().Format("2006-01-02 15:04 MST"))
|
|
}
|
|
if err := tw.Flush(); err != nil {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func cmdShow(ctx context.Context, args []string) int {
|
|
fs := flag.NewFlagSet("show", flag.ExitOnError)
|
|
asJSON := fs.Bool("json", false, "emit structured JSON")
|
|
n, rest, err := resolveNetwork(fs, args)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return exitUsage
|
|
}
|
|
hash := "latest"
|
|
if len(rest) > 0 {
|
|
hash = rest[0]
|
|
}
|
|
c := aggregator.New(n.AggregatorURL)
|
|
snap, err := resolveSnapshot(ctx, c, hash)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "show:", err)
|
|
return exitNetwork
|
|
}
|
|
if *asJSON {
|
|
return emitJSON(snap)
|
|
}
|
|
fmt.Printf("hash: %s\n", snap.Hash)
|
|
fmt.Printf("network: %s\n", snap.Network)
|
|
fmt.Printf("epoch/immutable: %d / %d\n", snap.Beacon.Epoch, snap.Beacon.ImmutableFileNumber)
|
|
fmt.Printf("certificate: %s\n", snap.CertificateHash)
|
|
fmt.Printf("cardano version: %s\n", snap.CardanoNodeVersion)
|
|
fmt.Printf("created: %s\n", snap.CreatedAt.UTC().Format(time.RFC3339))
|
|
fmt.Printf("size uncompressed: %s\n", humanSize(snap.TotalDBSizeUncompressed))
|
|
fmt.Printf("digests size: %s locations: %d\n", humanSize(snap.Digests.SizeUncompressed), len(snap.Digests.Locations))
|
|
fmt.Printf("ancillary size: %s locations: %d\n", humanSize(snap.Ancillary.SizeUncompressed), len(snap.Ancillary.Locations))
|
|
fmt.Printf("immutable avg: %s files: %d locations: %d\n",
|
|
humanSize(snap.Immutables.AverageSizeUncompressed), snap.Beacon.ImmutableFileNumber, len(snap.Immutables.Locations))
|
|
return exitOK
|
|
}
|
|
|
|
func cmdDownload(ctx context.Context, args []string) int {
|
|
fs := flag.NewFlagSet("download", flag.ExitOnError)
|
|
out := fs.String("out", "./db", "output directory")
|
|
includeAncillary := fs.Bool("ancillary", true, "download the ancillary archive")
|
|
includeImmuts := fs.Bool("immutables", false, "download all immutable files (huge on mainnet — off by default)")
|
|
n, rest, err := resolveNetwork(fs, args)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return 2
|
|
}
|
|
hash := "latest"
|
|
if len(rest) > 0 {
|
|
hash = rest[0]
|
|
}
|
|
c := aggregator.New(n.AggregatorURL)
|
|
snap, err := resolveSnapshot(ctx, c, hash)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "download:", err)
|
|
return 1
|
|
}
|
|
|
|
fmt.Printf("Target: %s\n", snap.Hash)
|
|
fmt.Printf("Network: %s\n", snap.Network)
|
|
fmt.Printf("Epoch/Imm: %d / %d\n", snap.Beacon.Epoch, snap.Beacon.ImmutableFileNumber)
|
|
fmt.Printf("Output dir: %s\n", *out)
|
|
fmt.Println()
|
|
|
|
if err := os.MkdirAll(*out, 0o755); err != nil {
|
|
fmt.Fprintln(os.Stderr, "mkdir:", err)
|
|
return 1
|
|
}
|
|
|
|
// 1. Download + extract digests archive (few MB — always)
|
|
fmt.Println("=== digests ===")
|
|
digestsURIs := cloudURIs(snap.Digests.Locations)
|
|
if len(digestsURIs) == 0 {
|
|
fmt.Fprintln(os.Stderr, "no cloud_storage digest location available")
|
|
return 1
|
|
}
|
|
digestsArchive := filepath.Join(*out, "digests.tar.zst")
|
|
if err := downloadWithBar(ctx, digestsURIs[0], digestsArchive); err != nil {
|
|
fmt.Fprintln(os.Stderr, "digests download:", err)
|
|
return 1
|
|
}
|
|
if err := artifact.ExtractZstdTar(ctx, digestsArchive, filepath.Join(*out, "digests")); err != nil {
|
|
fmt.Fprintln(os.Stderr, "digests extract:", err)
|
|
return 1
|
|
}
|
|
fmt.Println(" extracted to", filepath.Join(*out, "digests"))
|
|
|
|
// 2. Ancillary archive
|
|
if *includeAncillary {
|
|
fmt.Println("\n=== ancillary ===")
|
|
anciURIs := cloudURIs(snap.Ancillary.Locations)
|
|
if len(anciURIs) == 0 {
|
|
fmt.Fprintln(os.Stderr, "no cloud_storage ancillary location available")
|
|
return 1
|
|
}
|
|
anciArchive := filepath.Join(*out, "ancillary.tar.zst")
|
|
if err := downloadWithBar(ctx, anciURIs[0], anciArchive); err != nil {
|
|
fmt.Fprintln(os.Stderr, "ancillary download:", err)
|
|
return 1
|
|
}
|
|
if err := artifact.ExtractZstdTar(ctx, anciArchive, filepath.Join(*out, "db")); err != nil {
|
|
fmt.Fprintln(os.Stderr, "ancillary extract:", err)
|
|
return 1
|
|
}
|
|
fmt.Println(" extracted to", filepath.Join(*out, "db"))
|
|
}
|
|
|
|
// 3. Immutables (optional, huge on mainnet)
|
|
if *includeImmuts {
|
|
fmt.Fprintln(os.Stderr, "immutables download: not yet wired (will come in v0.0.3)")
|
|
return 1
|
|
}
|
|
|
|
fmt.Println("\nDone.")
|
|
return 0
|
|
}
|
|
|
|
func cmdVerify(ctx context.Context, args []string) int {
|
|
fs := flag.NewFlagSet("verify", flag.ExitOnError)
|
|
asJSON := fs.Bool("json", false, "emit structured JSON")
|
|
n, rest, err := resolveNetwork(fs, args)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return exitUsage
|
|
}
|
|
if len(rest) == 0 {
|
|
fmt.Fprintln(os.Stderr, "verify: cert hash required (or 'head' / 'genesis')")
|
|
return exitUsage
|
|
}
|
|
mode := rest[0] // "head" = verify head cert (STM, not yet), "genesis" = walk chain + verify genesis, or a specific hash
|
|
c := aggregator.New(n.AggregatorURL)
|
|
|
|
switch mode {
|
|
case "genesis":
|
|
return runVerifyGenesis(ctx, c, n, *asJSON)
|
|
case "head":
|
|
return runVerifyHead(ctx, c, n, *asJSON)
|
|
case "chain":
|
|
return runVerifyChain(ctx, n, *asJSON)
|
|
case "manifest":
|
|
return runVerifyManifest(rest[1:], *asJSON)
|
|
default:
|
|
// Treat as a literal cert hash: fetch + verify
|
|
return runVerifySingle(ctx, c, n, mode, *asJSON)
|
|
}
|
|
}
|
|
|
|
func runVerifyManifest(args []string, asJSON bool) int {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(os.Stderr, "verify manifest: needs path to download dir (with digests/ + db/)")
|
|
return exitUsage
|
|
}
|
|
dir := args[0]
|
|
digestsPath, err := manifest.LocateDigests(filepath.Join(dir, "digests"))
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "locate digests.json:", err)
|
|
return exitGeneric
|
|
}
|
|
entries, err := manifest.Load(digestsPath)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "load manifest:", err)
|
|
return exitIntegrity
|
|
}
|
|
res, err := manifest.Verify(entries, filepath.Join(dir, "db"))
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "verify manifest:", err)
|
|
return exitGeneric
|
|
}
|
|
if asJSON {
|
|
code := emitJSON(res)
|
|
if !res.OK {
|
|
return exitIntegrity
|
|
}
|
|
return code
|
|
}
|
|
if !res.OK {
|
|
fmt.Fprintf(os.Stderr, "manifest verify FAILED: verified=%d/%d missing=%d mismatched=%d unverifiable=%d\n",
|
|
res.Verified, res.TotalEntries, len(res.Missing), len(res.Mismatched), len(res.Unverifiable))
|
|
return exitIntegrity
|
|
}
|
|
fmt.Printf("manifest verify ✓ %d/%d files match (extra: %d)\n",
|
|
res.Verified, res.TotalEntries, len(res.Extra))
|
|
return exitOK
|
|
}
|
|
|
|
func runVerifyChain(ctx context.Context, n networks.Network, asJSON bool) int {
|
|
c := aggregator.New(n.AggregatorURL)
|
|
snap, err := resolveSnapshot(ctx, c, "latest")
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "resolve:", err)
|
|
return exitNetwork
|
|
}
|
|
res, err := chain.Verify(ctx, nil, n, snap.CertificateHash, 2048)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "chain verify:", err)
|
|
return exitNetwork
|
|
}
|
|
if asJSON {
|
|
code := emitJSON(res)
|
|
if !res.Verified {
|
|
return exitBadSig
|
|
}
|
|
return code
|
|
}
|
|
if !res.Verified {
|
|
fmt.Fprintf(os.Stderr, "chain verify FAILED at step %d (%s): %s\n",
|
|
res.FailureIndex, res.FailureKind, res.Error)
|
|
return exitBadSig
|
|
}
|
|
fmt.Printf("chain verify ✓ network=%s length=%d head=%s genesis=%s\n",
|
|
res.Network, res.Length, res.HeadHash, res.GenesisHash)
|
|
// Quick summary of STM/genesis breakdown
|
|
stmCount, genCount, totalWins := 0, 0, 0
|
|
for _, step := range res.Steps {
|
|
if step.Kind == "stm" {
|
|
stmCount++
|
|
totalWins += step.TotalWins
|
|
} else {
|
|
genCount++
|
|
}
|
|
}
|
|
fmt.Printf(" %d STM certs + %d genesis cert | %d total lottery wins across chain\n",
|
|
stmCount, genCount, totalWins)
|
|
return exitOK
|
|
}
|
|
|
|
func runVerifyGenesis(ctx context.Context, c *aggregator.Client, n networks.Network, asJSON bool) int {
|
|
// Find the head snapshot's cert, walk to genesis, verify Ed25519 on the genesis cert.
|
|
snap, err := resolveSnapshot(ctx, c, "latest")
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "resolve:", err)
|
|
return exitNetwork
|
|
}
|
|
chain, err := c.CertChain(ctx, snap.CertificateHash, 2048)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "chain:", err)
|
|
return exitNetwork
|
|
}
|
|
if len(chain) == 0 {
|
|
fmt.Fprintln(os.Stderr, "empty chain")
|
|
return exitGeneric
|
|
}
|
|
gen := chain[len(chain)-1]
|
|
if gen.GenesisSignature == "" {
|
|
fmt.Fprintln(os.Stderr, "tail of chain is not a genesis certificate")
|
|
return exitGeneric
|
|
}
|
|
return verifyGenesisCert(n, gen, asJSON)
|
|
}
|
|
|
|
func runVerifyHead(ctx context.Context, c *aggregator.Client, n networks.Network, asJSON bool) int {
|
|
snap, err := resolveSnapshot(ctx, c, "latest")
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "resolve:", err)
|
|
return exitNetwork
|
|
}
|
|
return runVerifySingle(ctx, c, n, snap.CertificateHash, asJSON)
|
|
}
|
|
|
|
func runVerifySingle(ctx context.Context, c *aggregator.Client, n networks.Network, hash string, asJSON bool) int {
|
|
cert, err := c.GetCertificate(ctx, hash)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "cert:", err)
|
|
return exitNetwork
|
|
}
|
|
if cert.GenesisSignature != "" {
|
|
return verifyGenesisCert(n, cert, asJSON)
|
|
}
|
|
return verifySTMCert(ctx, c, n, hash, cert, asJSON)
|
|
}
|
|
|
|
// verifySTMCert fetches the raw cert JSON (we need fields our minimal
|
|
// Certificate struct doesn't capture — aggregate_verification_key, metadata
|
|
// params), decodes, and runs the full STM verification.
|
|
func verifySTMCert(ctx context.Context, c *aggregator.Client, n networks.Network, hash string, cert *aggregator.Certificate, asJSON bool) int {
|
|
// Re-fetch as raw JSON to access the AVK + params fields.
|
|
raw, err := fetchCertRaw(ctx, n.AggregatorURL, hash)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "fetch raw cert:", err)
|
|
return exitNetwork
|
|
}
|
|
ms, err := stm.DecodeMultiSig(raw.MultiSignature)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "decode multi_signature:", err)
|
|
return exitIntegrity
|
|
}
|
|
avk, err := stm.DecodeAVK(raw.AggregateVerificationKey)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "decode avk:", err)
|
|
return exitIntegrity
|
|
}
|
|
msg := []byte(cert.SignedMessage)
|
|
params := stm.Parameters{K: raw.Metadata.Parameters.K, M: raw.Metadata.Parameters.M, PhiF: raw.Metadata.Parameters.PhiF}
|
|
verr := stm.Verify(msg, ms, avk, params)
|
|
if asJSON {
|
|
out := map[string]any{
|
|
"cert_hash": cert.Hash,
|
|
"kind": "stm",
|
|
"epoch": cert.Epoch,
|
|
"signed_message": cert.SignedMessage,
|
|
"signers": len(ms.Signatures),
|
|
"total_wins": ms.TotalWins(),
|
|
"distinct_wins": len(ms.DistinctWins()),
|
|
"params": params,
|
|
"verified": verr == nil,
|
|
}
|
|
if verr != nil {
|
|
out["error"] = verr.Error()
|
|
}
|
|
code := emitJSON(out)
|
|
if verr != nil {
|
|
return exitBadSig
|
|
}
|
|
return code
|
|
}
|
|
if verr != nil {
|
|
fmt.Fprintln(os.Stderr, "STM verify:", verr)
|
|
return exitBadSig
|
|
}
|
|
fmt.Printf("STM cert %s epoch=%d signers=%d wins=%d/%d BLS+lottery+merkle ✓\n",
|
|
cert.Hash, cert.Epoch, len(ms.Signatures), ms.TotalWins(), params.M)
|
|
return exitOK
|
|
}
|
|
|
|
type rawCert struct {
|
|
MultiSignature json.RawMessage `json:"multi_signature"`
|
|
AggregateVerificationKey json.RawMessage `json:"aggregate_verification_key"`
|
|
Metadata struct {
|
|
Parameters struct {
|
|
K uint64 `json:"k"`
|
|
M uint64 `json:"m"`
|
|
PhiF float64 `json:"phi_f"`
|
|
} `json:"parameters"`
|
|
} `json:"metadata"`
|
|
}
|
|
|
|
func fetchCertRaw(ctx context.Context, aggregatorURL, hash string) (*rawCert, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, aggregatorURL+"/certificate/"+hash, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
var r rawCert
|
|
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
|
return nil, err
|
|
}
|
|
return &r, nil
|
|
}
|
|
|
|
func verifyGenesisCert(n networks.Network, cert *aggregator.Certificate, asJSON bool) int {
|
|
vk, err := verify.DecodeGenesisVerifyKey(n.GenesisVerifyKey)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "decode genesis key:", err)
|
|
return exitGeneric
|
|
}
|
|
err = verify.GenesisFromJSON(vk, cert.SignedMessage, cert.GenesisSignature, cert.ProtocolMessage)
|
|
if asJSON {
|
|
result := map[string]any{
|
|
"cert_hash": cert.Hash,
|
|
"kind": "genesis",
|
|
"signed_message": cert.SignedMessage,
|
|
"epoch": cert.Epoch,
|
|
"verified": err == nil,
|
|
}
|
|
if err != nil {
|
|
result["error"] = err.Error()
|
|
}
|
|
code := emitJSON(result)
|
|
if err != nil {
|
|
return exitBadSig
|
|
}
|
|
return code
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "verify genesis:", err)
|
|
return exitBadSig
|
|
}
|
|
fmt.Printf("genesis cert %s epoch=%d Ed25519 ✓\n", cert.Hash, cert.Epoch)
|
|
return exitOK
|
|
}
|
|
|
|
func cmdCert(ctx context.Context, args []string) int {
|
|
fs := flag.NewFlagSet("cert", flag.ExitOnError)
|
|
chain := fs.Bool("chain", false, "walk previous_hash back to the genesis certificate")
|
|
maxDepth := fs.Int("max-depth", 1024, "chain walk safety cap")
|
|
asJSON := fs.Bool("json", false, "emit structured JSON")
|
|
n, rest, err := resolveNetwork(fs, args)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return exitUsage
|
|
}
|
|
if len(rest) == 0 {
|
|
fmt.Fprintln(os.Stderr, "cert: hash required (or 'head' to use the latest snapshot's cert_hash)")
|
|
return exitUsage
|
|
}
|
|
head := rest[0]
|
|
c := aggregator.New(n.AggregatorURL)
|
|
if head == "head" {
|
|
snap, err := resolveSnapshot(ctx, c, "latest")
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "resolve head:", err)
|
|
return exitNetwork
|
|
}
|
|
head = snap.CertificateHash
|
|
}
|
|
if *chain {
|
|
certs, err := c.CertChain(ctx, head, *maxDepth)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "chain:", err)
|
|
return exitNetwork
|
|
}
|
|
if *asJSON {
|
|
return emitJSON(map[string]any{"chain_length": len(certs), "certs": certs})
|
|
}
|
|
fmt.Printf("chain length: %d (head → genesis)\n\n", len(certs))
|
|
for i, ct := range certs {
|
|
role := ""
|
|
if ct.GenesisSignature != "" {
|
|
role = " [GENESIS]"
|
|
}
|
|
fmt.Printf("[%3d] %s epoch=%d prev=%.16s…%s\n",
|
|
i, ct.Hash, ct.Epoch, ct.PreviousHash, role)
|
|
}
|
|
return exitOK
|
|
}
|
|
cert, err := c.GetCertificate(ctx, head)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "cert:", err)
|
|
return exitNetwork
|
|
}
|
|
if *asJSON {
|
|
return emitJSON(cert)
|
|
}
|
|
fmt.Printf("hash: %s\n", cert.Hash)
|
|
fmt.Printf("previous_hash: %s\n", cert.PreviousHash)
|
|
fmt.Printf("epoch: %d\n", cert.Epoch)
|
|
fmt.Printf("signed_message: %s\n", cert.SignedMessage)
|
|
fmt.Printf("genesis sig: %s\n", yesNo(cert.GenesisSignature != ""))
|
|
fmt.Printf("multi_signature: %d bytes (raw)\n", len(cert.Multisignature))
|
|
fmt.Printf("protocol_message: %d bytes (raw)\n", len(cert.ProtocolMessage))
|
|
return exitOK
|
|
}
|
|
|
|
func yesNo(b bool) string {
|
|
if b {
|
|
return "yes"
|
|
}
|
|
return "no"
|
|
}
|
|
|
|
func cmdInfo(args []string) int {
|
|
fs := flag.NewFlagSet("info", flag.ExitOnError)
|
|
asJSON := fs.Bool("json", false, "emit structured JSON")
|
|
n, _, err := resolveNetwork(fs, args)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return exitUsage
|
|
}
|
|
if *asJSON {
|
|
return emitJSON(map[string]any{
|
|
"network": n.Name,
|
|
"aggregator": n.AggregatorURL,
|
|
"genesis_verify_key": n.GenesisVerifyKey,
|
|
})
|
|
}
|
|
fmt.Printf("network: %s\n", n.Name)
|
|
fmt.Printf("aggregator: %s\n", n.AggregatorURL)
|
|
fmt.Printf("genesis verify key: %s…\n", n.GenesisVerifyKey[:16])
|
|
return exitOK
|
|
}
|
|
|
|
func cmdMCP(ctx context.Context, args []string) int {
|
|
s := mcp.New(mcp.ServerInfo{
|
|
Name: "mithril-go",
|
|
Version: version,
|
|
})
|
|
registerMCPTools(s)
|
|
fmt.Fprintf(os.Stderr, "[mcp] mithril-go %s MCP server ready on stdio (%d tools)\n", version, 8)
|
|
if err := s.Run(ctx); err != nil {
|
|
if err == context.Canceled || err == context.DeadlineExceeded {
|
|
return exitCanceled
|
|
}
|
|
fmt.Fprintln(os.Stderr, "mcp:", err)
|
|
return exitGeneric
|
|
}
|
|
return exitOK
|
|
}
|
|
|
|
func resolveSnapshot(ctx context.Context, c *aggregator.Client, hashOrLatest string) (*aggregator.CardanoDBSnapshot, error) {
|
|
if hashOrLatest == "latest" {
|
|
snaps, err := c.ListCardanoDBSnapshots(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(snaps) == 0 {
|
|
return nil, fmt.Errorf("aggregator returned no snapshots")
|
|
}
|
|
hashOrLatest = snaps[0].Hash
|
|
}
|
|
return c.GetCardanoDBSnapshot(ctx, hashOrLatest)
|
|
}
|
|
|
|
func cloudURIs(locs []aggregator.Location) []string {
|
|
var out []string
|
|
for _, l := range locs {
|
|
if l.Type == "cloud_storage" && l.URI.Plain != "" {
|
|
out = append(out, l.URI.Plain)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func downloadWithBar(ctx context.Context, uri, dest string) error {
|
|
fmt.Printf(" %s\n", uri)
|
|
start := time.Now()
|
|
var last int64
|
|
cb := func(read, total int64) {
|
|
elapsed := time.Since(start).Seconds()
|
|
rate := float64(read) / elapsed
|
|
pct := ""
|
|
if total > 0 {
|
|
pct = fmt.Sprintf("%5.1f%% ", float64(read)/float64(total)*100)
|
|
}
|
|
fmt.Printf("\r %s%s @ %s/s ", pct, humanSize(uint64(read)), humanSize(uint64(rate)))
|
|
last = read
|
|
}
|
|
err := artifact.Download(ctx, uri, dest, "", cb)
|
|
fmt.Printf("\r %s in %s \n", humanSize(uint64(last)), time.Since(start).Round(time.Second))
|
|
return err
|
|
}
|
|
|
|
func humanSize(b uint64) string {
|
|
const k = 1024.0
|
|
if b < 1024 {
|
|
return fmt.Sprintf("%dB", b)
|
|
}
|
|
v := float64(b)
|
|
for _, u := range []string{"K", "M", "G", "T", "P"} {
|
|
v /= k
|
|
if v < k {
|
|
return fmt.Sprintf("%.1f%s", v, u)
|
|
}
|
|
}
|
|
return fmt.Sprintf("%.1fE", v/k)
|
|
}
|