commit f87b7fc3c486439d038fb78342bcfbce184d6e05 Author: Kayos Date: Thu Apr 23 15:12:39 2026 -0700 initial scaffold - module layout: cmd/mithril-go, internal/{aggregator,artifact,verify,networks} - aggregator REST client, list command working against mainnet - download/extract/verify stubbed - no deps yet, pure stdlib diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e78842 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/bin/ +/mithril-go +*.test +*.out +.coverage* diff --git a/README.md b/README.md new file mode 100644 index 0000000..6eb3cc5 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# mithril-go + +Pure-Go client for the Cardano [Mithril](https://mithril.network) protocol. + +Mithril is Cardano's stake-based certified-snapshot system — it lets a new +node bootstrap the chain from a cryptographically-verified snapshot +instead of replaying every block from genesis. + +The official [`mithril-client`](https://github.com/input-output-hk/mithril) +is Rust. This project is a pure-Go reimplementation that produces a single +static binary with no runtime dependencies — useful for: + +- Embedding a Mithril bootstrap into Go-based Cardano tooling + (alongside `gouroboros`, `dingo`, and friends) +- Running on constrained ARM/embedded targets where shipping the Rust + binary + its deps is overkill +- Operators who prefer a single `go install`-able helper + +## Status + +**Early development — not usable yet.** Current state: + +- [x] Module scaffold, network configs, aggregator REST client +- [x] `list` command hits the aggregator and enumerates cardano-database snapshots +- [ ] `download` — range-chunk parallel HTTP, SHA-256 integrity, resume +- [ ] `extract` — streamed zstd + tar decompression +- [ ] Genesis Ed25519 verification (per-network bootstrap key) +- [ ] STM BLS12-381 aggregate-signature verification (the hard part) +- [ ] Incremental / ancillary artifact support + +## Usage (eventual) + +``` +mithril-go info -network mainnet +mithril-go list -network mainnet +mithril-go download -network mainnet -out ./db latest +mithril-go verify -network mainnet ./db +``` + +## License + +TBD diff --git a/cmd/mithril-go/main.go b/cmd/mithril-go/main.go new file mode 100644 index 0000000..924fbf3 --- /dev/null +++ b/cmd/mithril-go/main.go @@ -0,0 +1,147 @@ +// 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 +// download — fetch a snapshot (verify + extract optional) +// verify — verify an already-downloaded snapshot +// info — show aggregator + network details +package main + +import ( + "context" + "flag" + "fmt" + "os" + "text/tabwriter" + + "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/aggregator" + "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/networks" +) + +const version = "0.0.1-dev" + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + cmd := os.Args[1] + args := os.Args[2:] + switch cmd { + case "list": + os.Exit(cmdList(args)) + case "download": + os.Exit(cmdDownload(args)) + case "verify": + os.Exit(cmdVerify(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 [flags] + +Commands: + list List available cardano-database snapshots + download Download + verify + extract a snapshot + verify Verify an already-downloaded snapshot + info Show network + aggregator info + version Print version + help Show this help + +Common flags: + -network mainnet | preprod | preview (default: preprod)`) +} + +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(args []string) int { + fs := flag.NewFlagSet("list", flag.ExitOnError) + n, _, err := resolveNetwork(fs, args) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 2 + } + client := aggregator.New(n.AggregatorURL) + snaps, err := client.ListCardanoDBSnapshots(context.Background()) + if err != nil { + fmt.Fprintln(os.Stderr, "list:", err) + return 1 + } + 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 { + fmt.Fprintln(os.Stderr, "flush:", err) + return 1 + } + return 0 +} + +func cmdDownload(args []string) int { + fmt.Fprintln(os.Stderr, "download: not yet implemented") + return 1 +} + +func cmdVerify(args []string) int { + fmt.Fprintln(os.Stderr, "verify: not yet implemented") + return 1 +} + +func cmdInfo(args []string) int { + fs := flag.NewFlagSet("info", flag.ExitOnError) + n, _, err := resolveNetwork(fs, args) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 2 + } + 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 0 +} + +func humanSize(b uint64) string { + const k = 1024 + if b < k { + return fmt.Sprintf("%dB", b) + } + units := []string{"K", "M", "G", "T"} + v := float64(b) + u := 0 + for v >= k && u < len(units)-1 { + v /= k + u++ + } + return fmt.Sprintf("%.1f%s", v, units[u]) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f102270 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.sulkta.coop/Sulkta-Coop/mithril-go + +go 1.26 diff --git a/internal/aggregator/client.go b/internal/aggregator/client.go new file mode 100644 index 0000000..0f3e9ea --- /dev/null +++ b/internal/aggregator/client.go @@ -0,0 +1,125 @@ +// Package aggregator is a thin HTTP client for the Mithril aggregator REST API. +// +// Only the handful of endpoints needed for client-side snapshot workflows are +// exposed. Authentication is not required for the read paths used here. +package aggregator + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type Client struct { + baseURL string + http *http.Client +} + +func New(baseURL string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + http: &http.Client{Timeout: 60 * time.Second}, + } +} + +// CardanoDBSnapshot is the server-reported shape for /artifact/cardano-database/{hash}. +// Field set is trimmed to what the client actually consumes — full schema documented +// at https://mithril.network/doc/aggregator-api/. +type CardanoDBSnapshot struct { + Hash string `json:"hash"` + MerkleRoot string `json:"merkle_root"` + Network string `json:"network"` + Beacon Beacon `json:"beacon"` + CertificateHash string `json:"certificate_hash"` + TotalDBSizeUncompressed uint64 `json:"total_db_size_uncompressed"` + Digests LocationList `json:"digests"` + ImmutablesAncillary LocationList `json:"immutables"` + ImmutablesIncremental *IncrementalImmutables `json:"immutables_incremental,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type Beacon struct { + Epoch uint64 `json:"epoch"` + ImmutableFileNumber uint64 `json:"immutable_file_number"` +} + +type LocationList struct { + Size uint64 `json:"size"` + Locations []LocationAlt `json:"locations"` +} + +// LocationAlt is a best-of alternative; Mithril returns a typed-discriminated object. +type LocationAlt struct { + Type string `json:"type"` // e.g. "cloud_storage", "ipfs" + URI string `json:"uri"` +} + +type IncrementalImmutables struct { + AverageSize uint64 `json:"average_size"` + Locations []LocationAlt `json:"locations"` +} + +// Certificate is the server-reported shape for /certificate/{hash}. +// Kept minimal; STM verification reads what it needs from the raw JSON later. +type Certificate struct { + Hash string `json:"hash"` + PreviousHash string `json:"previous_hash"` + Epoch uint64 `json:"epoch"` + SignedMessage string `json:"signed_message"` + ProtocolMessage json.RawMessage `json:"protocol_message"` + Multisignature json.RawMessage `json:"multi_signature"` + GenesisSignature string `json:"genesis_signature,omitempty"` +} + +func (c *Client) get(ctx context.Context, path string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("aggregator GET %s: %w", path, err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("aggregator GET %s: status %d: %s", path, resp.StatusCode, string(body)) + } + if out == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// ListCardanoDBSnapshots returns the sorted-newest-first list of cardano-database snapshots. +func (c *Client) ListCardanoDBSnapshots(ctx context.Context) ([]CardanoDBSnapshot, error) { + var out []CardanoDBSnapshot + if err := c.get(ctx, "/artifact/cardano-database", &out); err != nil { + return nil, err + } + return out, nil +} + +// GetCardanoDBSnapshot fetches details for a single snapshot by hash (or "latest"). +func (c *Client) GetCardanoDBSnapshot(ctx context.Context, hash string) (*CardanoDBSnapshot, error) { + var out CardanoDBSnapshot + if err := c.get(ctx, "/artifact/cardano-database/"+url.PathEscape(hash), &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetCertificate fetches a certificate by hash for signature verification. +func (c *Client) GetCertificate(ctx context.Context, hash string) (*Certificate, error) { + var out Certificate + if err := c.get(ctx, "/certificate/"+url.PathEscape(hash), &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/internal/artifact/download.go b/internal/artifact/download.go new file mode 100644 index 0000000..46f3a0d --- /dev/null +++ b/internal/artifact/download.go @@ -0,0 +1,27 @@ +// Package artifact handles downloading and extracting Mithril snapshot artifacts. +// Currently stubs — HTTP range requests, resumable downloads, zstd+tar extraction +// will be implemented in the next pass. +package artifact + +import ( + "context" + "errors" +) + +var ErrNotImplemented = errors.New("not yet implemented") + +// Download fetches an artifact from one of the supplied locations, choosing +// the first reachable one and storing it at destPath. +// Implementation will do: +// - parallel range-chunks over HTTP +// - resume on partial .part file +// - SHA-256 verification against the snapshot manifest +func Download(ctx context.Context, locations []string, destPath string) error { + return ErrNotImplemented +} + +// Extract decompresses a zstd+tar archive into targetDir. +// Will stream through zstd -> tar reader without buffering the full archive. +func Extract(ctx context.Context, archivePath, targetDir string) error { + return ErrNotImplemented +} diff --git a/internal/networks/networks.go b/internal/networks/networks.go new file mode 100644 index 0000000..6bd6010 --- /dev/null +++ b/internal/networks/networks.go @@ -0,0 +1,40 @@ +// Package networks holds Mithril aggregator endpoints and genesis keys +// per Cardano network. +package networks + +type Network struct { + Name string + AggregatorURL string + GenesisVerifyKey string // hex-encoded Ed25519 public key used to verify the Mithril genesis cert chain + CardanoConfigURL string // upstream cardano-node config bundle (config.json, genesis files) +} + +var ( + Mainnet = Network{ + Name: "mainnet", + AggregatorURL: "https://aggregator.release-mainnet.api.mithril.network/aggregator", + GenesisVerifyKey: "5b3139312c36362c3134302c3138352c3133382c31312c3233372c3230372c3235302c3134342c32372c322c3138382c33302c31322c38312c3135352c3230342c31302c3137392c37352c32332c3133382c3139362c3231372c352c31342c32302c35372c37392c33392c3137365d", + } + Preprod = Network{ + Name: "preprod", + AggregatorURL: "https://aggregator.release-preprod.api.mithril.network/aggregator", + GenesisVerifyKey: "5b3132372c37332c3132342c3136312c31362c38372c3133332c3136372c3135352c3138362c3138372c36372c3231322c37382c3131372c3230352c3234362c35322c35312c31372c3138302c38372c3130342c3139362c3131332c3130332c3239355d", // placeholder — replace with known-good key at implementation time + } + Preview = Network{ + Name: "preview", + AggregatorURL: "https://aggregator.pre-release-preview.api.mithril.network/aggregator", + GenesisVerifyKey: "5b3132372c37332c3132342c3136312c31362c38372c3133332c3136372c3135352c3138362c3138372c36372c3231322c37382c3131372c3230352c3234362c35322c35312c31372c3138302c38372c3130342c3139362c3131332c3130332c3239355d", // placeholder + } +) + +func ByName(name string) (Network, bool) { + switch name { + case "mainnet": + return Mainnet, true + case "preprod": + return Preprod, true + case "preview": + return Preview, true + } + return Network{}, false +} diff --git a/internal/verify/verify.go b/internal/verify/verify.go new file mode 100644 index 0000000..b392e2c --- /dev/null +++ b/internal/verify/verify.go @@ -0,0 +1,61 @@ +// Package verify implements signature verification for Mithril certificates. +// +// Two layers of verification exist in Mithril: +// +// 1. The genesis certificate is signed by a static Ed25519 key baked into +// the client (per-network). This bootstraps trust into the STM protocol. +// 2. Subsequent certificates carry an STM (Stake-based Threshold Multi- +// signature) aggregate signature over BLS12-381. Verification requires +// the stake distribution snapshot plus the signers' verification keys +// and their individual signature shares. +// +// v1 scope: genesis Ed25519 verification only. STM/BLS verification is a +// separate follow-on milestone — it is the bulk of the cryptographic work +// in this project. +package verify + +import ( + "crypto/ed25519" + "encoding/hex" + "errors" + "fmt" +) + +var ( + ErrNotGenesis = errors.New("certificate is not a genesis certificate") + ErrBadSignature = errors.New("genesis signature verification failed") + ErrSTMNotImplemented = errors.New("STM signature verification not implemented yet") +) + +// Genesis verifies that the certificate was signed by the network's genesis +// verification key. signedPayload is the exact bytes the aggregator stated +// were signed (derived from the certificate's protocol_message, not this +// function's job to construct). +func Genesis(verifyKeyHex, genesisSignatureHex string, signedPayload []byte) error { + pkHex, err := hex.DecodeString(verifyKeyHex) + if err != nil { + return fmt.Errorf("decode verify key: %w", err) + } + // Mithril genesis keys are serialized as hex(ascii-of-byte-array-literal), + // e.g. "[191,66,140,...]" → outer hex → inner ASCII → parse. The real decoder + // will unpack this; for now accept a raw 32-byte hex as well. + pk := ed25519.PublicKey(pkHex) + if len(pk) != ed25519.PublicKeySize { + return fmt.Errorf("verify key wrong size: got %d, want %d", len(pk), ed25519.PublicKeySize) + } + sig, err := hex.DecodeString(genesisSignatureHex) + if err != nil { + return fmt.Errorf("decode signature: %w", err) + } + if !ed25519.Verify(pk, signedPayload, sig) { + return ErrBadSignature + } + return nil +} + +// STM verifies a non-genesis certificate's aggregate BLS signature against +// the stake distribution. Stub — implementation target: Mithril STM paper +// §5 (signing protocol) + §6 (aggregation) using a BLS12-381 library. +func STM(protocolMessage, multiSignature []byte, stakeDistribution any) error { + return ErrSTMNotImplemented +}