Skip to content

feat: bootstrap client for mithril#1396

Open
wolf31o2 wants to merge 1 commit intomainfrom
feat/mithril-bootstrap
Open

feat: bootstrap client for mithril#1396
wolf31o2 wants to merge 1 commit intomainfrom
feat/mithril-bootstrap

Conversation

@wolf31o2
Copy link
Member

@wolf31o2 wolf31o2 commented Feb 13, 2026


Summary by cubic

Adds a Mithril bootstrap client and CLI for one‑command fast sync from certified snapshots. Speeds up first run with bulk ledger import, header‑only block load, resumable downloads, and a startup guard that blocks serving on incomplete syncs.

  • New Features

    • CLI: “dingo mithril {list,show,sync}” and “dingo sync --mithril”; auto aggregator by “--network”; progress + resumable downloads; zstd‑tar extract; optional digest and certificate‑chain verification.
    • Ledger restore: UTxO (legacy and UTxO‑HD via MemPack), accounts with rewards, pools, DReps; treasury/reserves via NetworkState; resumable phases via ImportCheckpoint; bulk import for MySQL/PostgreSQL/SQLite; header‑only RawBlock and LoadWithDB; immutable chunk lookup bounds fix.
    • Startup guard: refuse “serve” when sync_state shows an incomplete bootstrap.
  • Migration

    • Enable Mithril (default true) and run “dingo mithril sync” or “dingo sync --mithril”.
    • Optional dingo.yaml: aggregatorUrl (auto), downloadDir, cleanupAfterLoad, verifyCertificates.
    • Auto‑migrations add ImportCheckpoint, NetworkState, and SyncState.

Written for commit 55fc5d4. Summary will update on new commits.

Summary by CodeRabbit

Release Notes

New Features

  • Mithril Snapshot Bootstrap: Added native dingo sync --mithril command to download and import ledger snapshots directly from Mithril aggregator with automatic ledger state and gap-block handling.
  • Network Selection: Added --network flag to specify Cardano network (preview, preprod, mainnet) across all commands.
  • Mithril Command Suite: Added mithril subcommand group with list, show, and sync operations for snapshot management.
  • Resume-capable Imports: Snapshot imports now support resumption from last completed phase via checkpoint tracking.

Improvements

  • Enhanced block insertion with batch RawBlock API for optimized loading.
  • Sync state validation prevents startup during incomplete synchronization.
  • Support for multiple ledger snapshot formats and era-specific parsing.

Documentation

  • Updated README with dual-path Mithril bootstrapping guide (built-in vs. external client).
  • Added disk space requirements table per network.

@wolf31o2 wolf31o2 requested a review from a team as a code owner February 13, 2026 00:31
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 13, 2026

📝 Walkthrough

Walkthrough

This pull request implements Mithril snapshot bootstrap and ledger state import functionality. It introduces a complete Mithril client with snapshot discovery, download, and extraction; adds ledger state parsing for UTxOs, accounts, pools, DReps, governance, and snapshots; implements a resumable, phased import pipeline with checkpointing; expands the database layer with ImportCheckpoint, NetworkState, and SyncState models across SQLite, PostgreSQL, and MySQL; adds new CLI commands for Mithril bootstrap and sync; introduces RawBlock and bulk block insertion for efficient ledger loading; and updates node loading with database-integrated workflows. The changes enable rapid bootstrapping from Mithril snapshots and restoration of historical ledger state into the database.

Possibly related PRs

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: bootstrap client for mithril' clearly describes the main feature addition—a new Mithril bootstrap client—and aligns with the extensive changes across CLI commands, ledger import, and database models.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/mithril-bootstrap

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

20 issues found across 43 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="ledgerstate/import_test.go">

<violation number="1" location="ledgerstate/import_test.go:64">
P2: state.Tip is dereferenced in the log output before the nil check, so a nil Tip would panic and skip the intended assertion. Check for nil before logging fields from state.Tip.</violation>
</file>

<file name="ledgerstate/utxo.go">

<violation number="1" location="ledgerstate/utxo.go:513">
P1: Potential index-out-of-bounds panic: `data[1+pos]` is not bounds-checked. If the CBOR data is truncated (no `0xff` terminator), `1+pos` could equal or exceed `len(data)`, causing a runtime panic. Add a bounds check before indexing.</violation>
</file>

<file name="ledgerstate/mempack.go">

<violation number="1" location="ledgerstate/mempack.go:152">
P2: The `readVarLen` method converts `uint64` to `int` which can silently produce a negative value for large VarLen-encoded numbers from malformed input. This negative value propagates to `make()` calls (e.g., `make([]ParsedAsset, numAssets)` or `make([]byte, n)`) causing a runtime panic. Consider adding a bounds check after the conversion.</violation>

<violation number="2" location="ledgerstate/mempack.go:607">
P1: The last asset's name will include trailing padding bytes from the flat buffer. The format pads Region E to word-size alignment, but the code takes `flat[nameOff:]` which includes those padding bytes. This corrupts the last asset name, which could cause incorrect asset identification downstream. Consider using the total name-region size (e.g., tracking the end of names from the last distinct offset boundary, or stripping trailing zero padding).</violation>
</file>

<file name="cmd/dingo/mithril.go">

<violation number="1" location="cmd/dingo/mithril.go:76">
P2: Use `cmd.Context()` instead of `context.Background()` so that the API calls respect signal-based cancellation (e.g., Ctrl+C).</violation>

<violation number="2" location="cmd/dingo/mithril.go:100">
P2: Unguarded string slicing `s.Digest[:16]` and `s.CreatedAt[:19]` will panic if the API returns values shorter than expected. Add bounds checks or use a helper that truncates safely.</violation>

<violation number="3" location="cmd/dingo/mithril.go:287">
P2: `errgroup.WithContext(context.Background())` makes the parallel operations non-cancelable from outside. Consider accepting a `context.Context` parameter in `runMithrilSync` and passing it to the errgroup.</violation>
</file>

<file name="ledgerstate/verify_test.go">

<violation number="1" location="ledgerstate/verify_test.go:48">
P2: Avoid calling require.NoError (FailNow) inside the httptest handler goroutine. Use a non-fatal error report or propagate the error back to the test goroutine so failures are recorded reliably.</violation>
</file>

<file name="mithril/client.go">

<violation number="1" location="mithril/client.go:132">
P2: Escape the snapshot digest before inserting it into the URL path; unescaped path characters can produce malformed requests or hit unintended endpoints.</violation>
</file>

<file name="database/plugin/metadata/sqlite/import.go">

<violation number="1" location="database/plugin/metadata/sqlite/import.go:108">
P2: When the pool already exists, `OnConflict` updates won’t populate `pool.ID`, so this assignment can set `PoolID` to 0 and create orphaned registration/owner rows. Fetch the ID before linking the registration when `pool.ID` is still zero.</violation>
</file>

<file name="ledgerstate/cbor_decode.go">

<violation number="1" location="ledgerstate/cbor_decode.go:94">
P2: Unterminated indefinite-length map silently returns partial results. If the `0xff` break code is never found (truncated data), the loop exits and the function returns `nil` error. This is inconsistent with `cborItemSize` which correctly returns an `"unterminated indef map"` error. Add a check after the loop.</violation>

<violation number="2" location="ledgerstate/cbor_decode.go:226">
P1: Missing bounds validation: `cborItemSize` for byte/text strings returns `hLen + int(arg)` without verifying the data is actually that long. With truncated CBOR, this causes callers (e.g., `decodeMapEntries`) to panic on out-of-bounds slicing when they do `data[pos : pos+keySize]`.</violation>
</file>

<file name="internal/node/load.go">

<violation number="1" location="internal/node/load.go:219">
P2: `LoadBlobsWithDB` is designed for bulk-loading Mithril snapshots but does not enable bulk-load pragmas (`SetBulkLoadPragmas`/`RestoreNormalPragmas`) like `LoadWithDB` does. Since `AddRawBlocks` writes to the metadata store, omitting these optimizations may significantly degrade import performance. Consider adding the same bulk-load pragma setup that `LoadWithDB` uses.</violation>
</file>

<file name="ledgerstate/snapshot.go">

<violation number="1" location="ledgerstate/snapshot.go:74">
P1: Bug: Lexicographic string sort of slot-number directory names gives wrong results when slot numbers have different digit counts (e.g., `"9" > "200"` in string order). This will select the wrong (not most recent) ledger state. Use a numeric sort instead, or use `sort.Slice` with `strconv.Atoi` comparison. The same bug is present in the `legacyFiles` sort below and in `FindUTxOTableFile`.</violation>
</file>

<file name="ledgerstate/telescope.go">

<violation number="1" location="ledgerstate/telescope.go:112">
P2: Add a length check before indexing items[1] to avoid panics on malformed telescope data.</violation>

<violation number="2" location="ledgerstate/telescope.go:150">
P2: Reject empty past-era summaries instead of appending a zero-value bound; this avoids silently producing incorrect epoch history when the CBOR is malformed.</violation>
</file>

<file name="ledgerstate/snapshots.go">

<violation number="1" location="ledgerstate/snapshots.go:35">
P2: The fee is described as optional, but the length check requires 4 elements and ss[3] is accessed unconditionally. Snapshots with only mark/set/go will error or panic. Allow len==3 and only decode fee when present.</violation>
</file>

<file name="ledgerstate/certstate.go">

<violation number="1" location="ledgerstate/certstate.go:615">
P2: `big.Int.Uint64()` is called without checking `IsUint64()` first. Per Go documentation, the result of `Uint64()` is undefined if the value cannot be represented as a `uint64` (negative or overflow). Malformed CBOR input could silently produce incorrect margin values. Guard both calls with `IsUint64()`.</violation>
</file>

<file name="ledgerstate/import.go">

<violation number="1" location="ledgerstate/import.go:228">
P1: Bug: `setCheckpoint` never commits the transaction, so checkpoints are silently discarded and resume will never work. Add `return txn.Commit()` after the success log, matching the pattern used by every other write function in this file.</violation>

<violation number="2" location="ledgerstate/import.go:1021">
P2: Potential uint64 underflow in fallback path: when called with zero `EraBound{}` for the current era, `endBound.Epoch - startBound.Epoch` underflows if `startBound.Epoch > 0`, producing a garbage epoch length. Guard against this by checking that `endBound.Epoch > startBound.Epoch`.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

if _, err := cbor.Decode(data, &r); err == nil {
rat := r.ToBigRat()
if rat != nil {
num := rat.Num().Uint64()
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: big.Int.Uint64() is called without checking IsUint64() first. Per Go documentation, the result of Uint64() is undefined if the value cannot be represented as a uint64 (negative or overflow). Malformed CBOR input could silently produce incorrect margin values. Guard both calls with IsUint64().

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/certstate.go, line 615:

<comment>`big.Int.Uint64()` is called without checking `IsUint64()` first. Per Go documentation, the result of `Uint64()` is undefined if the value cannot be represented as a `uint64` (negative or overflow). Malformed CBOR input could silently produce incorrect margin values. Guard both calls with `IsUint64()`.</comment>

<file context>
@@ -0,0 +1,630 @@
+	if _, err := cbor.Decode(data, &r); err == nil {
+		rat := r.ToBigRat()
+		if rat != nil {
+			num := rat.Num().Uint64()
+			den := rat.Denom().Uint64()
+			if den > 0 {
</file context>
Fix with Cubic

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🤖 Fix all issues with AI agents
In `@cmd/dingo/mithril.go`:
- Line 287: The errgroup is currently created with
errgroup.WithContext(context.Background()), so CLI cancellation (SIGINT) won't
propagate; change runMithrilSync to accept a ctx context.Context (call site
should pass cmd.Context() or a signal-aware parent), replace
context.Background() with that ctx when calling errgroup.WithContext(ctx), and
thread the ctx into any spawned goroutines (e.g., the ledger-state import and
blob load) so they respect cancellation; update callers of runMithrilSync
accordingly.

In `@dingo.yaml.example`:
- Around line 219-221: The config default for the Mithril certificate
verification is insecure: change the `verifyCertificates` default from false to
true in the configuration example and any code that provides defaults (ensure
the symbol `verifyCertificates` now defaults to true), update associated
documentation/comments to state that verification is enabled by default and
users may opt out for faster bootstraps, and adjust any config-schema/validation
logic or tests that assume the previous false default so they reflect the new
secure default.

In `@internal/config/config.go`:
- Around line 332-336: Change the Mithril default to enable certificate
verification by setting the VerifyCertificates field to true in the
MithrilConfig default block (update the Mithril: MithrilConfig{ ... }
initializer to include VerifyCertificates: true). Also ensure any code that
relies on zero values or unmarshalling of MithrilConfig (e.g., constructors or
config loaders that treat absent fields specially) treats true as the
default/opt-out behavior and update relevant tests/docs referencing
VerifyCertificates or MithrilConfig.

In `@ledgerstate/cbor_decode.go`:
- Around line 188-226: cborItemSize can return a size larger than the available
buffer for definite-length strings and for composite types, so update
cborItemSize (and where it relies on cborArgument) to validate bounds: for major
types 2 and 3 after computing arg and hLen ensure hLen+int(arg) <= len(data) and
return an explicit truncated-data error if not; when handling indefinite strings
ensure each chunkSize is validated against remaining length before recursing and
that pos+chunkSize cannot exceed len(data); and in the array/map recursion paths
ensure each child chunkSize is checked against the remaining bytes before adding
to pos (returning an error on truncation) so callers like decodeMapEntries never
receive a too-large size.
- Around line 57-95: The indefinite-length map branch in DecodeMap stops parsing
when pos >= len(data) and currently returns entries even if the 0xff break code
was never seen; change the logic in that branch (the loop using pos,
cborItemSize, MapEntry, keyRaw, valRaw) to track whether the break code was
encountered (e.g., encounteredBreak boolean) and after the loop return a clear
error (including context like "truncated indefinite-length map: missing break
code 0xff") if the break code was not found instead of returning partial
entries; ensure the check happens before returning entries so truncated data is
treated as an error.

In `@ledgerstate/certstate.go`:
- Around line 85-151: parseCertStateConway currently only extracts PState and
DState and thus never populates result.DReps; detect and parse the VState
element (the small/empty map element, e.g. CBOR major type 5 or byte 0xa0)
similar to parseCertState3, call the existing parseVState (or the same VState
parsing helper used by parseCertState3) on that element and assign its output to
result.DReps; implement the detection by scanning certState for a map element
with small length (prefer the empty/small map) and handle errors the same way
you do for PState/DState (wrap and return).

In `@ledgerstate/import_test.go`:
- Around line 119-129: The test logs state.Tip.Slot before verifying state.Tip
is non-nil, risking a panic; move the nil check earlier by calling
require.NotNil(t, state.Tip) (or t.Fatalf/assert) before the t.Logf that
references state.Tip.Slot so that state.Tip is validated prior to access; update
the t.Logf invocation to use state.Tip.Slot only after require.NotNil(t,
state.Tip) succeeds (referencing the t.Logf and require.NotNil calls and the
state.Tip symbol).

In `@ledgerstate/import.go`:
- Line 70: The code reads cfg.State.Tip.Slot into slot without checking
cfg.State.Tip for nil; add a nil-check for cfg.State.Tip before accessing Slot
(e.g., if cfg.State.Tip == nil { /* handle origin snapshot: set slot to 0 or
return/err */ }) and decide the proper behavior for origin snapshots (use zero
value, skip import, or return an error). Update the assignment of slot (the
variable named slot) and any downstream logic that assumes a non-nil Tip
(references to cfg.State.Tip or slot) so they correctly handle the nil-Tip case.
- Around line 1001-1028: The fallback in resolveEraParams can underflow because
it subtracts endBound.Epoch - startBound.Epoch when endBound may be zero; change
the guard to explicitly verify endBound.Epoch >= startBound.Epoch (and ideally >
startBound.Epoch) before doing the subtraction and only compute slotSpan if
endBound.Slot >= startBound.Slot (or compute with safe signed arithmetic),
otherwise return (0,0). Update resolveEraParams to check endBound.Epoch and
endBound.Slot against startBound before computing epochSpan/slotSpan so no
unsigned underflow occurs.

In `@ledgerstate/snapshot.go`:
- Around line 72-83: The code currently uses lexicographic sorting
(sort.StringSlice) on slot-name slices (utxoHDDirs and legacyFiles and similarly
in FindUTxOTableFile), which yields incorrect "most recent" selection; replace
those sort.Sort(sort.Reverse(sort.StringSlice(...))) calls with a
numeric-descending sort helper (e.g., sortSlotNamesDesc) that parses slot names
to integers and sorts by numeric value, ensuring non-numeric entries are handled
deterministically (e.g., treat parse errors as -inf or 0) and then return the
first element; update places referencing utxoHDDirs, legacyFiles, and
FindUTxOTableFile to use this helper.

In `@ledgerstate/snapshots.go`:
- Around line 118-228: The three parsers (parseStakeMap, parseDelegationMap,
parsePoolParamsMap) currently drop malformed entries with bare continues, losing
data silently; add a skipped counter in each function (e.g., skipped := 0),
increment it at every place that currently does "continue" on parse errors
(inside the entry loop for entry.KeyRaw and entry.ValueRaw/parsing calls), and
after the loop return the partial result plus a non-nil error when skipped > 0
(e.g., fmt.Errorf("parsed map with %d malformed entries", skipped)) so callers
are informed of data loss; update callers if necessary to handle the returned
error.

In `@ledgerstate/utxo.go`:
- Around line 510-515: The loop reads data[1+pos] without checking bounds which
can panic if the decoder position reaches or exceeds the end of data (truncated
stream or missing 0xff); before accessing data[1+pos] in the loop that uses
decoder.Position(), guard with a bounds check (e.g., ensure pos+1 < len(data) or
use decoder.Remaining()/len check) and handle the truncated input by returning
an error instead of indexing out of range, so the code in the loop that checks
for the 0xff terminator safely exits or reports a malformed input.

In `@mithril/bootstrap_test.go`:
- Around line 99-116: The test handlers registered with mux.HandleFunc (e.g.,
the "/artifact/snapshots" handler that calls json.NewEncoder.Encode(snapshots)
and the "/download/snapshot.tar.zst" handler that writes archiveData) use
require.NoError inside the handler goroutine, which can panic/hang; change these
to a non-fatal handling strategy: either replace require.NoError(t, err) with
assert.NoError(t, err) so the assertion is non-fatal from the goroutine, or
write errors into the response via http.Error(w, err.Error(),
http.StatusInternalServerError) and assert the response in the main test
goroutine. Apply the same fix pattern to other occurrences noted
(TestBootstrapNoSnapshots, TestBootstrapNoLocations and similar handlers across
this file and certverify_test.go), ensuring no call to t.FailNow()/require.*
runs inside handler goroutines.

In `@mithril/download_test.go`:
- Around line 32-38: The httptest.NewServer handlers in mithril/download_test.go
call require.NoError(t, err) from handler goroutines which panics; update each
handler (the ones that create server :=
httptest.NewServer(http.HandlerFunc(...))) to not call t.FailNow from a non-test
goroutine: replace require.NoError(t, err) with assert.NoError(t, err)
(non-fatal) or simply ignore the write error (e.g., _, _ = w.Write(content));
apply this change to all httptest handler functions in the file.
🟡 Minor comments (10)
mithril/extract.go-138-150 (1)

138-150: ⚠️ Potential issue | 🟡 Minor

Unchecked outFile.Close() error on the write path.

On line 150, outFile.Close() is called without checking the returned error. For files opened with O_WRONLY, Close can surface delayed write errors (e.g., filesystem full, NFS flush failures). Ignoring this could silently produce a corrupt extraction.

Proposed fix
-			outFile.Close()
+			if err := outFile.Close(); err != nil {
+				return "", fmt.Errorf(
+					"closing file %s: %w",
+					target,
+					err,
+				)
+			}
ledgerstate/mempack_debug_test.go-10-146 (1)

10-146: ⚠️ Potential issue | 🟡 Minor

Debug test with hardcoded path should not be committed.

This test depends on a hardcoded absolute path (/tmp/dingo-mithril-download/...), produces no assertions (only t.Log), and will always be skipped in CI. It appears to be a development/debugging artifact. Consider removing it before merge, or converting it into a proper unit test with embedded test fixtures.

Additional issues:

  • Test name says "FirstTwoEntries" but iterates up to 5.
  • cbor.Decode errors silently ignored on lines 67 and 82.
cmd/dingo/mithril.go-97-106 (1)

97-106: ⚠️ Potential issue | 🟡 Minor

Potential panic on short Digest or CreatedAt strings.

s.Digest[:16] (line 100) and s.CreatedAt[:19] (line 104) will panic if the strings are shorter than expected. While the Mithril API should always return full-length values, a malformed response would crash the CLI.

🛡️ Defensive slicing
 		for _, s := range snapshots {
+			digest := s.Digest
+			if len(digest) > 16 {
+				digest = digest[:16]
+			}
+			createdAt := s.CreatedAt
+			if len(createdAt) > 19 {
+				createdAt = createdAt[:19]
+			}
 			fmt.Printf(
 				"%-16s  %-8d  %-8d  %12s  %s\n",
-				s.Digest[:16],
+				digest,
 				s.Beacon.Epoch,
 				s.Beacon.ImmutableFileNumber,
 				humanBytes(s.Size),
-				s.CreatedAt[:19],
+				createdAt,
 			)
 		}
mithril/bootstrap.go-297-319 (1)

297-319: ⚠️ Potential issue | 🟡 Minor

Cleanup removes the entire parent directory of the archive, which may contain unrelated files.

If the user passes a pre-existing DownloadDir (e.g., a data directory containing other files), os.RemoveAll(filepath.Dir(r.ArchivePath)) will delete everything in it. Consider tracking only the files/dirs created by bootstrap (archive, extractDir, ancillaryDir) and removing those individually, or document that DownloadDir must be dedicated to the bootstrap process.

mithril/bootstrap.go-168-173 (1)

168-173: ⚠️ Potential issue | 🟡 Minor

snapshot.Digest[:16] panics if digest is shorter than 16 characters.

While Mithril digests are typically 64-char hex strings, a malformed API response could produce a shorter value. Add a length guard or use a safe truncation helper.

Proposed fix
+func safePrefix(s string, n int) string {
+	if len(s) < n {
+		return s
+	}
+	return s[:n]
+}
+
 	archiveFilename := fmt.Sprintf(
 		"%s-%s.tar.zst",
 		snapshot.Network,
-		snapshot.Digest[:16],
+		safePrefix(snapshot.Digest, 16),
 	)

Also applies to: 257-261

ledgerstate/mempack.go-606-615 (1)

606-615: ⚠️ Potential issue | 🟡 Minor

Last asset name may include trailing padding bytes.

For the last asset in the flat buffer (lines 606-614), the name extends to the end of flat, which per the comment "may include padding." If downstream code compares asset names by exact byte match, trailing padding could cause mismatches. Consider trimming trailing zero bytes, or document this behavior for callers.

ledgerstate/snapshot.go-226-231 (1)

226-231: ⚠️ Potential issue | 🟡 Minor

Era-bounds extraction error is silently swallowed.

boundsErr is captured but never logged. If extractAllEraBounds fails, eraBounds is silently nil, which degrades the import (no full epoch history) without any diagnostic output. Consider logging it:

Proposed fix
 	eraBounds, boundsErr := extractAllEraBounds(telescopeData)
 	if boundsErr != nil {
 		// Non-fatal: log but continue without bounds
+		// (requires passing a logger or returning a warning)
 		eraBounds = nil
 	}

Since there's no logger in parseSnapshotData, consider returning the warning alongside the result, or accept a logger parameter.

mithril/download.go-71-97 (1)

71-97: ⚠️ Potential issue | 🟡 Minor

Progress speed is inflated for resumed downloads.

pw.written includes existingSize (set at line 223), so the speed calculation at line 85 (float64(pw.written) / elapsed) includes bytes that were already on disk before this session started. For a resume of a 5 GB file needing 100 MB more, the speed would show ~5.1 GB/s after 1 second.

Use only the bytes written in the current session:

Proposed fix
 		var speed float64
 		if elapsed > 0 {
-			speed = float64(pw.written) / elapsed
+			speed = float64(pw.written-pw.startOffset) / elapsed
 		}

This requires adding a startOffset int64 field to progressWriter (set to existingSize at creation).

ledgerstate/verify_test.go-34-61 (1)

34-61: ⚠️ Potential issue | 🟡 Minor

require in HTTP handler goroutine can cause test hangs or panics.

require.NoError(t, err) at line 48 (and similarly at lines 93-94, 134-135) calls t.FailNow() from a goroutine spawned by httptest.Server, not the test goroutine. Go's testing package documents that FailNow must be called from the goroutine running the test function. In a non-test goroutine it can cause hangs or panics.

Use t.Error + return (or just http.Error(w, ...)) instead of require inside the handler.

Example fix for the genesis test handler
 			err := json.NewEncoder(w).Encode(cert)
-			require.NoError(t, err)
+			if err != nil {
+				t.Errorf("encoding cert: %v", err)
+				http.Error(w, err.Error(), 500)
+				return
+			}
mithril/download.go-158-209 (1)

158-209: ⚠️ Potential issue | 🟡 Minor

resp.ContentLength may be -1, producing incorrect totalSize for resumed downloads.

When the server doesn't send a Content-Length header (chunked transfer), resp.ContentLength is -1. At line 174, totalSize = existingSize + (-1) would be incorrect, leading to negative or misleading progress percentages.

Consider guarding against this:

Proposed fix
 	case http.StatusPartialContent:
 		// Resume supported
-		totalSize = existingSize + resp.ContentLength
+		if resp.ContentLength > 0 {
+			totalSize = existingSize + resp.ContentLength
+		}
 		file, err = os.OpenFile(
🧹 Nitpick comments (22)
internal/node/load.go (4)

74-89: DB initialization logic is duplicated between LoadWithDB and LoadBlobsWithDB.

Consider extracting the "open DB if nil" pattern into a small helper (e.g., ensureDB) to reduce duplication and keep both paths consistent. Currently LoadWithDB applies bulk-load pragmas while LoadBlobsWithDB does not—if that's intentional for the blob-only path, a comment would help clarify.

Also applies to: 201-217


276-288: Unwrapped errors returned from the inner loop.

Per coding guidelines, errors should be wrapped with fmt.Errorf and %w to provide context. The bare return at lines 279 and 287 loses information about where the failure occurred during block iteration.

Proposed fix
 			next, err := iter.Next()
 			if err != nil {
-				return blocksCopied, immutableTip.Slot, err
+				return blocksCopied, immutableTip.Slot, fmt.Errorf(
+					"reading next block from immutable DB: %w", err,
+				)
 			}
 			// No more blocks
 			if next == nil {
 				break
 			}
 			tmpBlock, err := gledger.NewBlockFromCbor(next.Type, next.Cbor)
 			if err != nil {
-				return blocksCopied, immutableTip.Slot, err
+				return blocksCopied, immutableTip.Slot, fmt.Errorf(
+					"decoding block from CBOR: %w", err,
+				)
 			}

As per coding guidelines: "Wrap errors with context using fmt.Errorf with %w verb".


359-385: Same unwrapped-error pattern in copyBlocksRaw.

Lines 362, 377, and 384 return errors without context wrapping.

Proposed fix
 			next, err := iter.Next()
 			if err != nil {
-				return blocksCopied, immutableTip.Slot, err
+				return blocksCopied, immutableTip.Slot, fmt.Errorf(
+					"reading next block from immutable DB: %w", err,
+				)
 			}
 			if next == nil {
 				break
 			}
 			// ...
 			headerCbor, err := extractHeaderCbor(next.Cbor)
 			if err != nil {
-				return blocksCopied, immutableTip.Slot, err
+				return blocksCopied, immutableTip.Slot, fmt.Errorf(
+					"extracting header CBOR: %w", err,
+				)
 			}
 			header, err := gledger.NewBlockHeaderFromCbor(
 				next.Type,
 				headerCbor,
 			)
 			if err != nil {
-				return blocksCopied, immutableTip.Slot, err
+				return blocksCopied, immutableTip.Slot, fmt.Errorf(
+					"decoding block header from CBOR: %w", err,
+				)
 			}

As per coding guidelines: "Wrap errors with context using fmt.Errorf with %w verb".


308-310: slices.Delete to clear a batch is less idiomatic than re-slicing.

slices.Delete(blockBatch, 0, len(blockBatch)) works but blockBatch = blockBatch[:0] is the standard Go idiom for reusing the underlying array, is clearer in intent, and avoids the overhead of slices.Delete's internal copy/zero logic.

Proposed change (both sites)
-		blockBatch = slices.Delete(blockBatch, 0, len(blockBatch))
+		blockBatch = blockBatch[:0]

Also applies to: 406-408

ledgerstate/certstate.go (3)

103-130: Heuristic component detection is fragile.

Identifying PState as "largest array element" and DState as "largest map element" by raw byte length works for current encodings but could silently misidentify components if the encoding layout changes or if multiple elements share the same CBOR major type. Consider adding a comment documenting which node version / encoding this heuristic targets, or validating the identified components (e.g., checking expected sub-structure after selection).


609-630: big.Int.Uint64() silently truncates values exceeding math.MaxUint64.

At lines 615-616, rat.Num().Uint64() and rat.Denom().Uint64() will silently return the low 64 bits if the value overflows. For pool margin rationals (fractions < 1) this is safe in practice, but IsUint64() could be checked first for correctness.


190-218: Silently skipping unparseable pool entries may hide data issues.

Lines 201-205 and 210-212 continue on decode failures without logging. During bootstrap, silently dropping pools could lead to incomplete state. Consider at minimum logging at debug level when entries are skipped.

ledger/forging/builder_test.go (1)

566-567: Remove unused input variable assignment.

NewConstructorEncoder exists in gouroboros v0.155.0, but the input variable is never used. The test manually builds the transaction body via bodyMap instead. The comment already acknowledges this, but the dead code assignment can be safely removed for clarity.

Proposed cleanup
-	input := cbor.NewConstructorEncoder(0, cbor.IndefLengthList{})
-	_ = input // not used directly; we encode manually below
-
chain/chain.go (1)

244-333: AddRawBlock closely mirrors AddBlock — consider extracting shared logic.

AddRawBlock duplicates ~70 lines of AddBlock (reconcile, header match, tip fit, persist, tip update, iterator notification, event emission). The only difference is the input source (field access vs. interface calls). This is a reasonable duplication for now given the different input types, but if more block-insertion variants appear, extracting a shared addBlockCommon(point, hash, prevHash, blockNumber, blockType, cbor, txn) helper would reduce maintenance burden.

database/models/import_checkpoint.go (1)

40-46: Consider making ImportPhaseOrder immutable.

ImportPhaseOrder is a var slice, so callers could accidentally mutate it at runtime (e.g., append or index assignment). If this is intended to be a fixed constant list, wrapping it as a function or using a compile-time check would be safer.

♻️ Optional: return a copy from a function
-// ImportPhaseOrder defines the sequential order of import phases.
-var ImportPhaseOrder = []string{
-	ImportPhaseUTxO,
-	ImportPhaseCertState,
-	ImportPhaseSnapshots,
-	ImportPhasePParams,
-	ImportPhaseTip,
-}
+// importPhaseOrder defines the sequential order of import phases.
+var importPhaseOrder = []string{
+	ImportPhaseUTxO,
+	ImportPhaseCertState,
+	ImportPhaseSnapshots,
+	ImportPhasePParams,
+	ImportPhaseTip,
+}
+
+// ImportPhaseOrder returns the sequential order of import phases.
+func ImportPhaseOrder() []string {
+	return append([]string(nil), importPhaseOrder...)
+}
ledgerstate/mempack_test.go (1)

203-233: Hardcoded filesystem path is fragile and machine-specific.

/tmp/dingo-mithril-download/ancillary/ledger/104173430/tables/tvar is specific to a particular snapshot and developer machine. While the Skip guard prevents CI failures, this is effectively dead code for most developers.

Consider reading the path from an environment variable (e.g., DINGO_TVAR_PATH) or removing this test in favor of the integration test in import_test.go which fetches real data.

mithril/client.go (2)

57-61: WithHTTPClient overrides security defaults (timeout, redirect protection).

When a caller provides a custom *http.Client, the 30s timeout and httpsOnlyRedirect check are silently lost. Consider documenting this clearly, or applying the redirect policy defensively after applying options:

💡 Suggested approach
 func NewClient(
 	aggregatorURL string,
 	opts ...ClientOption,
 ) *Client {
 	c := &Client{
 		aggregatorURL: aggregatorURL,
 		httpClient: &http.Client{
 			Timeout:       30 * time.Second,
 			CheckRedirect: httpsOnlyRedirect,
 		},
 	}
 	for _, opt := range opts {
 		opt(c)
 	}
+	// Ensure redirect protection is always applied
+	if c.httpClient.CheckRedirect == nil {
+		c.httpClient.CheckRedirect = httpsOnlyRedirect
+	}
 	return c
 }

128-152: Consider sanitizing digest / hash path parameters.

GetSnapshot and GetCertificate concatenate user-supplied strings directly into the URL path. While these are expected to be hex digests, an adversarial input containing ../ or query characters could produce an unexpected request. A simple hex-validation or url.PathEscape would make this defensive.

ledgerstate/utxo.go (2)

62-66: Try 4 in decodeTxIn appears to be dead code.

After Try 1 either returns early (success with len(keyBytes) >= 34) or falls through (with len(keyBytes) < 34 or keyBytes == nil), the condition len(keyBytes) >= 34 at line 64 can never be true. This block is unreachable.


439-448: os.ReadFile loads entire tvar file into memory.

UTxO-HD tvar files on mainnet can be very large. While the streaming decoder avoids duplicating the data, the initial os.ReadFile still requires holding the entire file in memory. Consider using memory-mapped I/O or a buffered reader for very large files. This may be acceptable for now if mainnet tvar files are within reasonable bounds.

ledgerstate/verify_test.go (1)

80-107: Fragile URL path extraction can panic on unexpected request paths.

Line 85 (hash := path[len("/certificate/"):]) and line 126 will panic with an index-out-of-range if any request path doesn't start with /certificate/ (e.g., health-check probes). While this is test code, using strings.TrimPrefix or checking the prefix first is safer and avoids confusing failures.

Safer approach
-			hash := path[len("/certificate/"):]
+			hash, ok := strings.CutPrefix(path, "/certificate/")
+			if !ok {
+				http.NotFound(w, r)
+				return
+			}
ledgerstate/mempack.go (1)

186-194: Typo: babbageTxOutCompactRefScrip is missing the trailing 't'.

The constant name is babbageTxOutCompactRefScrip but the corresponding function is decodeTxOutCompactRefScript. Not a functional bug (the constant is used consistently), but it's a naming inconsistency that could confuse readers.

Proposed fix
-	babbageTxOutCompactRefScrip = 5 // CompactAddr + Value + Datum + Script
+	babbageTxOutCompactRefScript = 5 // CompactAddr + Value + Datum + Script

And update the switch case at line 227 accordingly.

ledgerstate/snapshot.go (1)

169-179: os.ReadFile loads the entire snapshot into memory.

Cardano ledger snapshots can be several GB. Loading the entire file at once (line 173) creates a large memory spike. While deferred CBOR decoding (via cbor.RawMessage) avoids re-parsing, the full byte slice remains in memory until GC. Consider whether a streaming approach (e.g., mmap or cbor.Decoder over a reader) would be feasible for the outer structure navigation.

ledgerstate/import.go (1)

452-494: Accounts, pools, and DReps are imported one-at-a-time in a loop.

importAccounts, importPools, and importDReps each iterate over the entire collection calling individual store.Import* methods in a loop within a single transaction. In contrast, importUTxOs uses a batch approach (store.ImportUtxos with a slice). If the store supports batch imports for accounts/pools/DReps, using the same pattern would be significantly faster for large datasets.

As per coding guidelines, prefer batch operations over N+1 patterns: "Use batch fetch queries (WHERE id IN ?) instead of N+1 queries in loops."

Also applies to: 497-571, 573-624

mithril/bootstrap.go (1)

376-443: verifyCertChain duplicates logic from ledgerstate.VerifyCertificateChain.

Both functions walk the certificate chain, detect cycles, and terminate at genesis. The only addition in verifyCertChain is the leaf digest binding check (lines 407-420). Maintaining two copies of the chain-walking logic risks them diverging. Consider extending the ledgerstate.VerifyCertificateChain with an optional digest-check parameter and calling it from here.

mithril/types.go (2)

31-65: Consider extracting shared fields to reduce duplication.

SnapshotListItem and Snapshot have identical field sets. If the Mithril API is expected to diverge them over time, keeping them separate is fine, but embedding a shared base could reduce maintenance burden.

♻️ Example using embedding
+// snapshotBase contains fields common to both list and detail
+// snapshot responses.
+type snapshotBase struct {
+	Digest               string   `json:"digest"`
+	Network              string   `json:"network"`
+	Beacon               Beacon   `json:"beacon"`
+	CertificateHash      string   `json:"certificate_hash"`
+	Size                 int64    `json:"size"`
+	AncillarySize        int64    `json:"ancillary_size"`
+	CreatedAt            string   `json:"created_at"`
+	Locations            []string `json:"locations"`
+	AncillaryLocations   []string `json:"ancillary_locations"`
+	CompressionAlgorithm string   `json:"compression_algorithm"`
+	CardanoNodeVersion   string   `json:"cardano_node_version"`
+}
+
 type SnapshotListItem struct {
-	Digest               string   `json:"digest"`
-	...
+	snapshotBase
 }
+
 type Snapshot struct {
-	Digest               string   `json:"digest"`
-	...
+	snapshotBase
 }

46-48: Wrap errors returned by CreatedAtTime with context.

Both CreatedAtTime methods return the bare error from time.Parse. As per coding guidelines, errors should be wrapped with fmt.Errorf and the %w verb to provide call-site context.

♻️ Proposed fix
+import "fmt"
+
 func (s *SnapshotListItem) CreatedAtTime() (time.Time, error) {
-	return time.Parse(time.RFC3339Nano, s.CreatedAt)
+	t, err := time.Parse(time.RFC3339Nano, s.CreatedAt)
+	if err != nil {
+		return time.Time{}, fmt.Errorf(
+			"parsing CreatedAt: %w",
+			err,
+		)
+	}
+	return t, nil
 }

 func (s *Snapshot) CreatedAtTime() (time.Time, error) {
-	return time.Parse(time.RFC3339Nano, s.CreatedAt)
+	t, err := time.Parse(time.RFC3339Nano, s.CreatedAt)
+	if err != nil {
+		return time.Time{}, fmt.Errorf(
+			"parsing CreatedAt: %w",
+			err,
+		)
+	}
+	return t, nil
 }

As per coding guidelines: "Wrap errors with context using fmt.Errorf with %w verb".

Also applies to: 67-70

@wolf31o2 wolf31o2 force-pushed the feat/mithril-bootstrap branch from bba42f8 to 2142bfa Compare February 13, 2026 20:20
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

🤖 Fix all issues with AI agents
In `@cmd/dingo/mithril.go`:
- Around line 510-518: The code building importKey uses
result.Snapshot.Digest[:16] which can panic if the digest is shorter than 16
chars; update the importKey construction in mithril.go to safely slice the
digest (e.g., take the first min(16, len(result.Snapshot.Digest))) or fall back
to the full digest when shorter, and then format it with state.Tip.Slot so
importKey is always built without risking an out-of-range slice on
result.Snapshot.Digest.
- Around line 495-518: The code dereferences state.Tip (state.Tip.Slot) without
a nil-check which can panic; add a nil guard: compute a safeSlot variable (e.g.,
safeSlot := uint64(0) or -1 sentinel) by checking if state.Tip != nil then
safeSlot = state.Tip.Slot, and use safeSlot in the logger.Info call (replace
state.Tip.Slot) and in the importKey fmt.Sprintf call (replace state.Tip.Slot)
so both uses are protected when state.Tip is nil.
- Around line 98-106: The code slices s.Digest[:16] and s.CreatedAt[:19] inside
the snapshots loop which can panic if those strings are shorter; change the
printing to use safe truncation (e.g., compute minLen := func(n int, s string)
int { if len(s) < n { return len(s) } return n } and use s.Digest[:minLen(16,
s.Digest)] and s.CreatedAt[:minLen(19, s.CreatedAt)] or a small helper
truncate(s, n) that returns s or s[:n]) so the fmt.Printf call in the snapshots
loop uses the truncated values instead of raw slices to avoid panics.
- Around line 355-361: The call to result.Cleanup(logger) is unconditional but
Bootstrap's contract says cleanup should be performed by the caller only when
cfg.Mithril.CleanupAfterLoad is true; change the unconditional call in the
Bootstrap caller to check cfg.Mithril.CleanupAfterLoad and only invoke
result.Cleanup(logger) when that flag is set, referencing the existing
cfg.Mithril.CleanupAfterLoad and result.Cleanup symbols in the surrounding
Bootstrap-related code.

In `@database/plugin/metadata/postgres/import.go`:
- Around line 83-124: ImportPool unconditionally calls db.Create(reg) which can
insert duplicate PoolRegistration rows on retries; change this to an idempotent
upsert or existence-check update. After setting reg.PoolID (and owners/relays),
replace the db.Create(reg) call in ImportPool with an upsert (e.g.,
db.Clauses(clause.OnConflict{Columns: /* use the unique registration constraint
columns */, DoNothing or DoUpdates: clause.AssignmentColumns(/* fields to update
*/)}).Create(reg)) or use FirstOrCreate/FirstOrInit+Save keyed on the
registration unique constraint so repeated ImportPool calls will not create
duplicates; ensure you reference the reg variable and the ImportPool method and
import/use gorm.io/gorm/clause as needed.

In `@database/plugin/metadata/sqlite/import.go`:
- Around line 84-140: ImportPool currently always calls db.Create(reg) which
creates duplicate PoolRegistration entries on retries; change the creation to an
upsert or guarded create: instead of unconditional db.Create(reg) use
db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "pool_id"}, {Name:
"tx_hash"}, {Name: "cert_index"}}, DoNothing: true/DoUpdates: ...}).Create(reg)
or alternatively perform a pre-check (db.Where("pool_id = ? AND tx_hash = ? AND
cert_index = ?", reg.PoolID, reg.TxHash, reg.CertIndex).First(&existing) and
only create if not found) so ImportPool (and the reg variable) will not insert
duplicate PoolRegistration records on retry.

In `@ledgerstate/cbor_decode.go`:
- Around line 57-97: The indefinite-length map handling in cbor_decode.go
collects entries into entries but always returns an error after the loop; change
the control flow so finding the 0xff break code returns the parsed entries with
nil error instead of falling through. Specifically, in the loop inside the block
that checks if first == 0xbf, either replace the single-line break when
data[pos] == 0xff with "return entries, nil" or after the loop add an explicit
check "if pos < len(data) && data[pos] == 0xff { return entries, nil }" and only
return the existing "indefinite-length map: break code not found" error when the
break code truly isn't present; refer to variables/function names first, pos,
entries, cborItemSize, and MapEntry to locate the logic to change.

In `@ledgerstate/govstate.go`:
- Around line 417-426: The current block silently leaves prop.ActionType as 0 on
decode failures (decodeRawArray and cbor.Decode of govAction[0]), which
misclassifies actions as ParameterChange; change the logic in the function that
parses procedure so that if decodeRawArray(procedure[2]) fails or
cbor.Decode(govAction[0], &actionType) fails you return an error (propagate a
descriptive error) instead of silently leaving prop.ActionType as 0 —
alternatively, if you prefer not to fail, set prop.ActionType to a clear
sentinel (e.g., 0xFF) and document it; update callers to handle the new error or
sentinel accordingly.

In `@ledgerstate/mempack_debug_test.go`:
- Around line 65-68: The cbor.Decode calls that populate keyBytes and valBytes
are ignoring their error return; update the two calls to check the returned
error (from cbor.Decode(keyRaw, &keyBytes) and cbor.Decode(valRaw, &valBytes))
and handle failures (e.g., t.Fatalf or t.Errorf and return) so a decode failure
doesn't leave keyBytes/valBytes nil and produce misleading logs; ensure you
reference the variables keyBytes and valBytes in the error handling and include
the decode error in the test log message.
- Around line 51-56: The test reads mapData[1+pos] without bounds checking which
can panic if innerDecoder.Position() returns a value that makes 1+pos >=
len(mapData); update the loop in mempack_debug_test.go to compute idx := 1 +
innerDecoder.Position() and guard with if idx >= len(mapData) { t.Log("no break
byte available") ; break } (or t.Fatalf if you want the test to fail) before
accessing mapData[idx], so the check of the break byte is only performed when
the index is in-bounds.

In `@ledgerstate/snapshot.go`:
- Around line 520-524: The Origin tip decoding currently returns a zero-valued
&SnapshotTip{} (Slot:0, BlockHash:nil) which bypasses the nil guard in
ImportLedgerState; change the behavior in the decoder that handles the empty tip
array (the len(tipArr)==0 branch in snapshot.go) to return nil instead of
&SnapshotTip{} so ImportLedgerState's cfg.State.Tip == nil check correctly
detects Origin snapshots, and ensure any callers of SnapshotTip decoding expect
and handle a nil return for Origin.

In `@mithril/bootstrap.go`:
- Around line 169-173: The code slices snapshot.Digest with snapshot.Digest[:16]
which will panic if the digest is shorter than 16 bytes; replace those direct
slices with a safe truncation: compute a local variable like d :=
snapshot.Digest; if len(d) > 16 { d = d[:16] } and use d when building
archiveFilename (and the other filename occurrence that also uses
snapshot.Digest), ensuring both places (the archiveFilename construction and the
later use around the 298/295-299 area) use this safe truncation to avoid
out-of-range panics.
- Around line 336-357: Cleanup currently removes the parent directory of
ArchivePath which can delete user directories; change Cleanup (method on
BootstrapResult) to remove only the specific artifacts the Bootstrap created:
delete the archive file at r.ArchivePath (if set), and call os.RemoveAll on
r.ExtractDir and r.AncillaryDir (if those fields exist and are set) instead of
filepath.Dir(r.ArchivePath); if the struct tracks whether a temporary download
dir was created (e.g., a boolean like CreatedTempDir or DownloadDirCreated),
only call RemoveAll on the parent dir when that flag is true — otherwise avoid
removing the parent directory to prevent deleting user-provided folders.

In `@mithril/download.go`:
- Around line 71-97: The periodic BytesPerSecond calculation in
progressWriter.Write wrongly uses pw.written (which includes previous session
bytes) causing inflated speed during resumed downloads; add a startOffset int64
field to the progressWriter struct (set to existingSize when the writer is
created), and change the speed computation in progressWriter.Write (and any
periodic reporting) to use float64(pw.written - pw.startOffset) / elapsed
(matching the final report logic that uses pw.written - existingSize) while
guarding against negative values and zero elapsed.
- Around line 172-175: The calculation of totalSize in the
http.StatusPartialContent branch can be wrong when resp.ContentLength == -1;
update the logic in the switch case handling StatusPartialContent (where
totalSize, existingSize and resp.ContentLength are used) to guard for a missing
Content-Length by only adding resp.ContentLength when it is >= 0 (e.g., set
totalSize = existingSize + resp.ContentLength if resp.ContentLength >= 0,
otherwise set totalSize = -1 or leave unknown) and ensure progress reporting and
any subsequent uses of totalSize handle the unknown/-1 case correctly; adjust
the code around the file opening and progress handling in the same block to
account for this change.
🧹 Nitpick comments (19)
chain/chain.go (1)

258-333: Significant duplication with AddBlock — consider extracting shared logic.

AddRawBlock reproduces ~95% of AddBlock's body (locking, reconcile, header check, tip-fit check, block construction, persistence, header removal, tip update, iterator notification, event publish). If the chain-integrity or event-emission logic changes, both methods must be updated in lockstep, which is error-prone.

Consider extracting the common post-validation logic (persist, update tip, notify, emit) into a private helper that both methods call after constructing the models.Block.

Sketch of a shared helper
// addBlockInternal performs the common post-validation steps for both
// AddBlock and AddRawBlock. Caller must hold c.mutex and c.manager.mutex.
func (c *Chain) addBlockInternal(
	tmpBlock models.Block,
	tmpPoint ocommon.Point,
	txn *database.Txn,
) error {
	newBlockIndex := c.tipBlockIndex + 1
	tmpBlock.ID = newBlockIndex
	if err := c.manager.addBlock(tmpBlock, txn, c.persistent); err != nil {
		return err
	}
	if !c.persistent {
		c.blocks = append(c.blocks, tmpPoint)
	}
	if len(c.headers) > 0 {
		c.headers = slices.Delete(c.headers, 0, 1)
	}
	c.currentTip = ochainsync.Tip{
		Point:       tmpPoint,
		BlockNumber: tmpBlock.Number,
	}
	c.tipBlockIndex = newBlockIndex
	c.waitingChanMutex.Lock()
	if c.waitingChan != nil {
		close(c.waitingChan)
		c.waitingChan = nil
	}
	c.waitingChanMutex.Unlock()
	if c.eventBus != nil {
		c.eventBus.Publish(
			ChainUpdateEventType,
			event.NewEvent(
				ChainUpdateEventType,
				ChainBlockEvent{
					Point: tmpPoint,
					Block: tmpBlock,
				},
			),
		)
	}
	return nil
}
chainselection/selector.go (2)

224-239: BlockDifference is an absolute block number, not a delta.

When the best peer disconnects, the previous tip is unavailable (already deleted from peerTips at line 205). BlockDifference is computed as safeUint64ToInt64(newPeerTip.Tip.BlockNumber), which is the new peer's absolute block number, not a meaningful difference. The same pattern appears in cleanupStalePeers (line 568-570).

This could surprise event consumers expecting a true delta. Consider either:

  1. Saving the previous peer's tip before the delete at line 205, so a real diff can be computed, or
  2. Documenting that BlockDifference may represent an absolute value when no previous tip is available.
Option 1: preserve previous tip before delete
 func (cs *ChainSelector) RemovePeer(connId ouroboros.ConnectionId) {
 	var switchEvent *event.Event
 
 	func() {
 		cs.mutex.Lock()
 		defer cs.mutex.Unlock()
 
+		// Save tip before deletion for event payload
+		var previousTip ochainsync.Tip
+		if pt, ok := cs.peerTips[connId]; ok {
+			previousTip = pt.Tip
+		}
 		delete(cs.peerTips, connId)

Then use previousTip in the event and compute safeBlockDiff(newPeerTip.Tip.BlockNumber, previousTip.BlockNumber).


559-574: Same BlockDifference / missing PreviousTip pattern as RemovePeer.

In cleanupStalePeers, the stale best peer is deleted from peerTips (line 534) before the event is constructed, so PreviousTip is omitted and BlockDifference is an absolute value rather than a delta. If you address the suggestion in RemovePeer, apply the same fix here by saving the tip before deletion.

ledger/forging/builder_test.go (1)

566-567: Dead code: input is constructed but immediately discarded.

cbor.NewConstructorEncoder(0, cbor.IndefLengthList{}) is called on line 566, but the result is never used — line 567 immediately blanks it with _ = input. If this is meant to verify that the API compiles, consolidate to a single line. Otherwise, remove both lines and construct the tagged input directly in the bodyMap as already done.

Suggested cleanup
-	input := cbor.NewConstructorEncoder(0, cbor.IndefLengthList{})
-	_ = input // not used directly; we encode manually below
+	// Inputs are encoded as a tagged set (tag 258)
ledgerstate/certstate.go (1)

219-239: Silent continue on parse errors loses diagnostic information.

In parseCertPoolParamsMap (and similarly in parseCredentialMap, parsePoolDeposits, and parseVState), malformed entries are silently skipped. During Mithril bootstrap debugging, it would be very helpful to know how many entries were skipped and why.

Consider adding a counter or debug-level logging for skipped entries. This applies to the same pattern throughout the file (lines 304–307, 389–390, 547–549, 676–679).

internal/node/load.go (1)

195-239: LoadBlobsWithDB skips bulk-load optimizations that LoadWithDB enables.

LoadWithDB (lines 90–109) checks for BulkLoadOptimizer and sets bulk-load pragmas (e.g., WAL mode, synchronous=OFF for SQLite) before importing blocks. LoadBlobsWithDB skips this entirely. Since blob-only loading can still involve writing thousands of blocks, it would benefit from the same optimization.

♻️ Proposed addition after line 226
 	c := cm.PrimaryChain()
 	if c == nil {
 		return nil, errors.New("primary chain not available")
 	}
+	// Enable bulk-load optimizations if available
+	if optimizer, ok := db.Metadata().(metadata.BulkLoadOptimizer); ok {
+		if err := optimizer.SetBulkLoadPragmas(); err != nil {
+			logger.Warn(
+				"failed to set bulk-load optimizations",
+				"error", err,
+			)
+		} else {
+			defer func() {
+				if err := optimizer.RestoreNormalPragmas(); err != nil {
+					logger.Error(
+						"failed to restore normal settings",
+						"error", err,
+					)
+				}
+			}()
+		}
+	}
 
 	blocksCopied, immutableTipSlot, err := copyBlocksRaw(
ledgerstate/mempack.go (1)

146-153: readVarLen truncates uint64 to int without overflow check.

The #nosec G115 suppresses the gosec warning, but if a VarLen-encoded value exceeds math.MaxInt (possible on 32-bit targets), the result silently wraps. This is used for byte-string lengths in readLengthPrefixedBytes, so a malformed input could cause incorrect length values and subsequent out-of-bounds reads rather than a clean error.

Consider adding an explicit overflow guard:

Proposed fix
 func (r *mempackReader) readVarLen() (int, error) {
 	result, err := r.readVarLenUint64()
 	if err != nil {
 		return 0, err
 	}
-	// `#nosec` G115
-	return int(result), nil
+	if result > uint64(^uint(0)>>1) {
+		return 0, fmt.Errorf(
+			"mempack: VarLen value %d overflows int", result,
+		)
+	}
+	return int(result), nil
 }
ledgerstate/utxo.go (3)

62-66: Try 4 is unreachable dead code.

If Try 1's cbor.Decode succeeds with len(keyBytes) >= 34, we return at line 46. If it fails, keyBytes remains nil. If it succeeds with len(keyBytes) < 34, the check at line 64 is false. In all cases, the condition len(keyBytes) >= 34 at line 64 can never be true at this point.

Proposed removal
-	// Try 4: If we got a short byte string, it might still be
-	// a valid TxIn with known length
-	if len(keyBytes) >= 34 {
-		return decodeTxInFromBytes(keyBytes)
-	}
-
 	// Nothing worked — return diagnostic error

436-448: os.ReadFile loads the entire tvar file into memory.

UTxO-HD tvar files can be very large (multi-GB on mainnet). Loading the entire file with os.ReadFile defeats the purpose of the streaming decode that follows. Consider using memory-mapped I/O (mmap) or a buffered reader to avoid the memory spike.


325-347: extractAddressKeys slices into the address without copying.

PaymentKey and StakingKey are set as sub-slices of addr. If the caller later modifies addr, these keys will be corrupted. Since addr comes from decoded data that may be reused across batches (e.g., the batch slice is reset with batch[:0]), this could lead to subtle data corruption during streaming.

However, looking at the flow: ParsedUTxO values are appended to the batch and passed to the callback before the batch is reset. The addr slice comes from freshly allocated data in the mempack decoder (make([]byte, ...)). So the risk is low in the current code path but is fragile.

mithril/certverify_test.go (1)

268-363: require calls inside httptest handlers run in a separate goroutine.

require.NoError / require. functions call t.FailNow(), which must only be called from the goroutine running the test. HTTP handlers in httptest servers run in their own goroutine. If the assertion fails, this can cause a panic or test suite crash instead of a clean failure.

Consider using assert (which doesn't call FailNow) or manually writing the error response and returning. This applies to all handler functions in this file.

ledgerstate/mempack_test.go (1)

203-206: Hardcoded tvar path duplicated across test files.

The path /tmp/dingo-mithril-download/ancillary/ledger/104173430/tables/tvar is repeated in mempack_debug_test.go (line 11) and here (line 204). Consider extracting it to a shared test constant or environment variable to avoid drift.

cmd/dingo/mithril.go (1)

77-83: Use cmd.Context() instead of context.Background() for CLI cancellation support.

Both mithrilListCommand (line 79) and mithrilShowCommand (line 144) use context.Background() for API calls. If a user cancels with Ctrl+C during a slow aggregator request, the call won't be interrupted. Using cmd.Context() propagates cancellation.

♻️ Proposed fix (line 79)
 			client := mithril.NewClient(aggregatorURL)
 			snapshots, err := client.ListSnapshots(
-				context.Background(),
+				cmd.Context(),
 			)
ledgerstate/import.go (2)

505-537: N+1 insert pattern for accounts will be slow at scale.

importAccounts calls store.ImportAccount once per account inside a loop. With hundreds of thousands of accounts this is significantly slower than a bulk insert. The UTxO path already uses a batched store.ImportUtxos (plural). Consider adding a similar ImportAccounts bulk method to the metadata store to align with the batching approach used for UTxOs.

The same N+1 pattern applies to importPools (lines 555–630) and importDReps (lines 651–683).

As per coding guidelines, "Use batch fetch queries (WHERE id IN ?) instead of N+1 queries in loops."


1162-1166: Swallowed non-nil error when govState is also non-nil — logic looks intentional but is subtle.

Line 1162: if govState == nil && err != nil means that if both govState != nil and err != nil, the function falls through to line 1170 where the error is logged as a warning. This is the intended "best-effort" behavior, but the condition govState == nil && err != nil at line 1162 can be easily misread as "if there's an error, return it." A short inline comment would help future readers.

ledgerstate/snapshot.go (1)

200-210: os.ReadFile loads the entire snapshot into memory.

Cardano ledger state snapshots can be multiple GB. On memory-constrained nodes this single allocation could cause OOM. The subsequent CBOR decoding also needs random access, so streaming is non-trivial, but worth noting as a known limitation. A memory-mapped file or streaming CBOR decoder could be considered in a future iteration.

ledgerstate/telescope.go (1)

156-172: Redundant length check after empty guard.

Line 163 if len(summary) >= 1 is always true since line 156 already returned an error when len(summary) == 0. Not a bug, just unnecessary.

Minor cleanup
 		if len(summary) == 0 {
 			return nil, fmt.Errorf(
 				"past era %s summary is empty",
 				EraName(eraIndex),
 			)
 		}
 		var bound EraBound
-		if len(summary) >= 1 {
-			slot, epoch, bErr := parseBound(summary[0])
-			if bErr != nil {
-				return nil, fmt.Errorf(
-					"parsing past era %s bound: %w",
-					EraName(eraIndex), bErr,
-				)
-			}
-			bound = EraBound{Slot: slot, Epoch: epoch}
+		slot, epoch, bErr := parseBound(summary[0])
+		if bErr != nil {
+			return nil, fmt.Errorf(
+				"parsing past era %s bound: %w",
+				EraName(eraIndex), bErr,
+			)
 		}
+		bound = EraBound{Slot: slot, Epoch: epoch}
mithril/bootstrap.go (1)

414-481: Duplicated certificate chain walking logic between verifyCertChain and VerifyCertificateChain.

The chain traversal (cycle detection, genesis/self-chain termination, depth limit) here closely mirrors ledgerstate/verify.go:VerifyCertificateChain. The additional value here is the leaf digest binding check (lines 445–458). Consider extracting the shared chain-walk into a common helper and composing the digest check on top, to avoid divergence over time.

mithril/types.go (1)

31-70: SnapshotListItem and Snapshot are structurally identical.

These two types share every field and both have an identical CreatedAtTime() helper. If the Mithril API guarantees the same shape for list vs. detail endpoints, consider embedding a shared base type to reduce duplication. If they're kept separate for forward-compatibility, a brief comment explaining why would help.

@wolf31o2 wolf31o2 force-pushed the feat/mithril-bootstrap branch from 2142bfa to 1ce3e2f Compare February 13, 2026 22:04
@wolf31o2 wolf31o2 requested review from a team as code owners February 13, 2026 22:04
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
README.md (1)

35-36: ⚠️ Potential issue | 🟡 Minor

Missing space in documentation text.

Line 35: dingo.yaml.example.You can copy` — there's a missing space between the backtick-terminated filename and "You".

-A sample configuration file is provided at `dingo.yaml.example`.You can copy and edit this file to configure Dingo for your local or production environment:
+A sample configuration file is provided at `dingo.yaml.example`. You can copy and edit this file to configure Dingo for your local or production environment:
🤖 Fix all issues with AI agents
In `@cmd/dingo/mithril.go`:
- Around line 77-83: The list/show commands use context.Background() when
calling the Mithril client, so in mithrilListCommand and mithrilShowCommand
replace context.Background() with the command's context (cmd.Context()) when
invoking client.ListSnapshots and the corresponding show call; update any calls
like client.ListSnapshots(context.Background(), ...) and the show snapshot
request to use cmd.Context() so SIGINT/cancel will propagate to the HTTP
request.

In `@database/plugin/metadata/mysql/import.go`:
- Around line 123-154: ImportDrep currently upserts models.Drep by credential
but does not ensure drep.ID is populated (MySQL Create with OnConflict won't set
the existing row's PK), so subsequent creation of models.RegistrationDrep can
use a zero/incorrect DrepID and duplicate registrations on re-import; after the
upsert in ImportDrep, fetch the DRep row by credential (e.g.,
db.Where("credential = ?", drep.Credential).First(&drep)) to populate drep.ID,
set reg.DrepID = drep.ID, and then create the registration using conflict-safe
semantics (use clause.OnConflict{DoNothing: true} or an upsert on unique keys)
when calling db.Create/reg insertion so repeated imports are idempotent; keep
all operations on the resolved txn/db (resolveDB, ImportDrep, models.Drep,
models.RegistrationDrep).
- Around line 90-121: The MySQL upsert can leave pool.ID unset, so after the
upsert via db.Clauses(clause.OnConflict{...}).Create(pool) check if pool.ID == 0
and, if so, refetch the record by pool_key_hash to populate pool.ID before
assigning reg.PoolID and linking owners/relays; additionally, avoid duplicate
PoolRegistration rows by using conflict handling on the registration insert
(e.g., db.Clauses(clause.OnConflict{...}).Create(reg) or equivalent upsert for
the PoolRegistration model) so repeated imports won’t create duplicates — update
the import logic around the variables and functions pool, reg, PoolID,
pool_key_hash, and the db.Create calls to implement these fixes.

In `@database/plugin/metadata/postgres/import.go`:
- Around line 91-110: The upsert may leave pool.ID set to 0 on conflict; after
the db.Clauses(...).Create(pool) call (the result variable) check if pool.ID ==
0 and, if so, re-query the DB for the existing pool row (e.g., SELECT ... WHERE
pool_key_hash = pool.PoolKeyHash) to load and set pool.ID before assigning
reg.PoolID and iterating reg.Owners / reg.Relays; update the block around the
OnConflict upsert (variables: db, pool, result, clause.OnConflict) to perform
this re-fetch so owners/relays get the correct PoolID.

In `@ledgerstate/cbor_decode.go`:
- Around line 126-134: The definite-length map branch uses the CBOR-declared
count to allocate and iterate without bounds, risking OOM/panic; before calling
make([]MapEntry...) and starting the for-loop in the code around cborArgument,
cap the parsed count using the existing maxMapEntries constant (e.g., if count >
maxMapEntries then set count = maxMapEntries or return an error) and ensure the
loop iterates only up to that capped value so MapEntry allocation and iteration
cannot be driven by a maliciously large CBOR count; update references to count,
headerLen, and the loop that constructs entries accordingly.
- Around line 338-352: The switch handling major type 7 (simple values/floats)
returns fixed sizes for info values 24–27 without checking input length, which
can cause panics in callers like decodeMapEntries; update the case branches for
info 24,25,26,27 inside the major type 7 handler to validate that the remaining
buffer (len(data) - pos or equivalent) is at least the required size (2,3,5,9)
and return an explicit truncation/error instead of the size when it's too short;
ensure the function returns a clear error value (not a size) for truncated data
so callers like decodeMapEntries can handle it safely.

In `@ledgerstate/mempack.go`:
- Around line 615-624: The last-asset path populates assetName from
flat[nameOff:] which may include word-alignment padding bytes; update the code
that sets assetName (the branch using nameOff and flat) to trim trailing
null/padding bytes (e.g., remove trailing 0x00) or compute the true name length
from the structure instead of taking the entire suffix, so assetName contains
only the real name bytes (use bytes.TrimRight or locate the first zero
terminator before assigning to assetName).
- Around line 443-446: The script decoding currently calls
r.readLengthPrefixedBytes() directly and ignores the AlonzoScript tag
discriminator; change this to first read the discriminator byte/token (e.g.,
using r.readByte or r.readVarUint as appropriate) and switch on its value (0 =>
NativeScript, 1 => PlutusScript) before reading the actual packed script payload
(then call r.readLengthPrefixedBytes() or the appropriate reader for the inner
script); ensure you return an error for any unknown tag and update the variable
currently named script to hold the correctly-dispatched payload for the
AlonzoScript union (reference the existing symbols r.readLengthPrefixedBytes(),
the local variable script, and the AlonzoScript/NativeScript/PlutusScript union
types).

In `@ledgerstate/telescope.go`:
- Around line 314-317: In navigateTelescopeNested, when handling case tag == 0
the code accesses items[1] without ensuring len(items) >= 2; add a bounds check
at the start of the case (or before accessing items[1]) to return a clear error
if len(items) < 2, and ensure the same check is applied on recursive entry since
callers pass decoded rest from items[2]; update any error messages returned by
navigateTelescopeNested to reflect a malformed CBOR/insufficient items scenario
so malformed recursive inputs are rejected safely.

In `@ledgerstate/verify.go`:
- Around line 34-42: The VerifyCertificateChain wrapper currently drops snapshot
verification by passing an empty snapshotDigest; update the exported function
signature VerifyCertificateChain(ctx context.Context, client *mithril.Client,
certificateHash string, snapshotDigest string) and forward that snapshotDigest
to mithril.VerifyCertificateChain(ctx, client, certificateHash, snapshotDigest)
so the leaf certificate's snapshot_digest binding is validated; keep existing
context, client and certificateHash parameters and only add and propagate
snapshotDigest in the call to mithril.VerifyCertificateChain.
🧹 Nitpick comments (22)
ledger/forging/builder_test.go (1)

566-567: Remove dead code: input variable is unused.

Lines 566–567 construct an input variable that is never referenced. The comment acknowledges it is not used directly. Removing both lines reduces noise.

Suggested cleanup
-	input := cbor.NewConstructorEncoder(0, cbor.IndefLengthList{})
-	_ = input // not used directly; we encode manually below
-
 	bodyMap := map[uint]any{
ledgerstate/certstate.go (3)

86-172: Heuristic-based Conway CertState detection is fragile — consider adding structural validation.

The approach of identifying PState, DState, and VState by relative byte-size of CBOR elements works for the known layouts, but a future encoding change could easily cause misidentification (e.g., a large VState DRep map exceeding the DState credential map). Consider adding a secondary validation step — for instance, after tentatively assigning PState/DState, attempt to decode each and swap assignments if the initial guess fails.

This isn't blocking since the code handles current known encodings, but it's worth hardening for forward compatibility.


220-239: Silently skipping malformed pool entries hides data loss.

Lines 222-226 and 231-233 continue on decode errors without logging, which means missing pools in the import could go unnoticed. Consider emitting a debug/warn log or at least counting skipped entries so operators can detect unexpected data loss during import.

Proposed approach
 	pools := make([]ParsedPool, 0, len(entries))
+	var skipped int
 	for _, entry := range entries {
 		var poolKeyHash []byte
 		if _, pErr := cbor.Decode(
 			entry.KeyRaw, &poolKeyHash,
 		); pErr != nil {
+			skipped++
 			continue
 		}
 
 		pool, err := parsePoolParams(
 			poolKeyHash, entry.ValueRaw,
 		)
 		if err != nil {
+			skipped++
 			continue
 		}
 
 		pools = append(pools, *pool)
 	}
+	if skipped > 0 {
+		// Consider logging: skipped N malformed pool entries
+	}
 
 	return pools, nil

729-752: Credential type (key vs script) is discarded.

parseCredential only extracts the hash from cred[1] and discards cred[0] (the credential type: 0 = key-hash, 1 = script-hash). If downstream code ever needs to distinguish key-based from script-based staking credentials (e.g., for filtering or display), this information is lost.

If this is intentional for the current use case, a brief comment clarifying that the type is not needed would help future readers.

chain/chain.go (1)

258-333: Significant duplication between AddRawBlock and AddBlock.

AddRawBlock is nearly identical to AddBlock (~70 lines of shared logic: reconcile, header check, tip fit check, block construction, persistence, header removal, tip update, iterator notification, event emission). The only difference is how fields are extracted (struct fields vs interface methods).

Consider extracting the common post-validation logic into a private helper to reduce the maintenance burden. For example:

♻️ Sketch of shared helper
+// addBlockCommon contains the shared logic for AddBlock and
+// AddRawBlock. Caller must hold c.mutex and c.manager.mutex.
+func (c *Chain) addBlockCommon(
+	hash, prevHash, cborData []byte,
+	slot, blockNumber uint64,
+	blockType uint,
+	txn *database.Txn,
+) error {
+	// Header match check, tip fit check, block construction,
+	// persistence, header removal, tip update, notification,
+	// and event emission — extracted from AddBlock.
+	...
+}
chainselection/selector.go (1)

226-238: PreviousTip field omitted in RemovePeer and cleanupStalePeers events.

Both RemovePeer (here) and cleanupStalePeers (line 561) emit ChainSwitchEvent without a PreviousTip, while EvaluateAndSwitch (line 430) populates it. This is understandable since the departing peer's tip data is deleted before event construction, but subscribers that rely on PreviousTip for logging or metrics will silently get a zero-valued tip. Consider capturing the previous tip before delete(cs.peerTips, connId) if consistency matters to consumers.

internal/node/load.go (3)

74-89: Duplicated database-initialization block.

The db == nil bootstrap (lines 75–89) is copy-pasted in LoadBlobsWithDB (lines 203–217). Extract a helper like openOrReuseDB(cfg, logger, db) (*database.Database, bool, error) returning whether the caller should close it.

♻️ Sketch
func openOrReuseDB(
	cfg *config.Config,
	logger *slog.Logger,
	db *database.Database,
) (*database.Database, bool, error) {
	if db != nil {
		return db, false, nil
	}
	dbConfig := &database.Config{
		DataDir:        cfg.DatabasePath,
		Logger:         logger,
		BlobPlugin:     cfg.BlobPlugin,
		MetadataPlugin: cfg.MetadataPlugin,
		MaxConnections: cfg.DatabaseWorkers,
	}
	newDB, err := database.New(dbConfig)
	if err != nil {
		return nil, false, err
	}
	return newDB, true, nil // caller must Close
}

244-323: copyBlocks and copyBlocksRaw share ~70% structural duplication.

The immutable DB open, tip read, iterator setup, batch loop, progress logging, and return pattern are near-identical. Consider extracting the common scaffolding into a generic helper that accepts a block-processing callback, differentiating only on how each block is decoded and added to the chain.

Also applies to: 330-421


159-181: 30-minute timeout may be insufficient for very large chains.

For Cardano mainnet snapshots containing millions of blocks, the ledger processing pipeline could take longer than 30 minutes to catch up. Consider making this timeout configurable, or at a minimum logging periodic progress within the wait loop.

ledgerstate/telescope.go (1)

156-172: Redundant len(summary) >= 1 check.

Line 156 already returns an error when len(summary) == 0, making the len(summary) >= 1 guard at line 163 always true.

ledgerstate/snapshot.go (1)

200-210: ParseSnapshot reads the entire file into memory.

os.ReadFile loads the complete snapshot into a single []byte. Cardano mainnet ledger snapshots can be several GB, which could cause OOM on memory-constrained systems. For the initial implementation this may be acceptable, but consider noting this as a known limitation or using memory-mapped I/O for production use.

ledgerstate/mempack_debug_test.go (1)

10-15: Debug test with hardcoded filesystem path — consider gating or documenting.

This test reads from a hardcoded /tmp/dingo-mithril-download/... path. While it correctly skips when absent, this path is machine-specific. Consider adding a build tag (e.g., //go:build debug) or an env-var gate (similar to DINGO_INTEGRATION_TEST in import_test.go) to prevent it from appearing in normal go test output.

mithril/extract.go (1)

130-155: Consider defer outFile.Close() for safer resource handling.

outFile.Close() is called inline at Line 148, which means a panic during io.Copy (however unlikely) would leak the file descriptor. Using defer with an error check is a more idiomatic Go pattern:

Suggested approach
 			outFile, err := os.OpenFile(
 				target,
 				os.O_CREATE|os.O_WRONLY|os.O_TRUNC,
 				0o640,
 			)
 			if err != nil {
 				return "", fmt.Errorf(
 					"creating file %s: %w",
 					target,
 					err,
 				)
 			}
 
 			// Limit copy to prevent decompression bombs
-			_, copyErr := io.Copy(
+			_, err = io.Copy(
 				outFile,
 				io.LimitReader(tr, header.Size),
 			)
-			outFile.Close()
-			if copyErr != nil {
+			closeErr := outFile.Close()
+			if err != nil {
 				return "", fmt.Errorf(
 					"extracting file %s: %w",
 					target,
-					copyErr,
+					err,
+				)
+			}
+			if closeErr != nil {
+				return "", fmt.Errorf(
+					"closing file %s: %w",
+					target,
+					closeErr,
 				)
 			}

This also checks the Close() error, which can indicate a failed flush to disk. As per coding guidelines, errors should be wrapped with %w.

README.md (1)

170-188: Consider linking to Mithril endpoint documentation for future reference.

The aggregator URLs in the README are current and match official Mithril documentation; however, linking to https://mithril.network/doc/manual/develop/references/ would help readers find the latest endpoints if they change in the future and reduce maintenance burden.

ledgerstate/mempack_test.go (1)

203-234: Hardcoded absolute path makes this test non-portable.

The path /tmp/dingo-mithril-download/ancillary/ledger/104173430/tables/tvar is machine-specific and snapshot-version-specific. Consider sourcing the path from an environment variable (e.g., DINGO_TVAR_PATH) so CI and other developers can run the test without guessing the expected location.

♻️ Suggested improvement
 func TestParseTvarFileIfAvailable(t *testing.T) {
-	tvarPath := "/tmp/dingo-mithril-download/ancillary/" +
-		"ledger/104173430/tables/tvar"
+	tvarPath := os.Getenv("DINGO_TVAR_PATH")
+	if tvarPath == "" {
+		tvarPath = "/tmp/dingo-mithril-download/ancillary/" +
+			"ledger/104173430/tables/tvar"
+	}
 	if _, err := os.Stat(tvarPath); os.IsNotExist(err) {
 		t.Skip("tvar file not available, skipping")
 	}
mithril/client.go (1)

198-239: Response body leak if caller ignores the returned ReadCloser.

doGet returns an io.ReadCloser the caller must close. All current call sites use defer body.Close() — good. However, consider documenting this contract more explicitly or returning the decoded result directly for simpler APIs.

Also, io.LimitReader silently truncates at maxResponseBytes without error — if a response exceeds 10 MiB, the JSON decoder will see an unexpected EOF rather than a descriptive error. This is an acceptable trade-off for safety, but worth noting.

ledgerstate/verify_test.go (1)

34-62: Consider using t.Cleanup for server teardown (consistency with other test files).

The mithril/client_test.go uses t.Cleanup(server.Close) via the newTestServer helper, while these tests use defer server.Close(). Both work, but t.Cleanup is preferred as it runs even if a t.Fatal/require fails before the defer is registered.

ledgerstate/import.go (1)

502-534: All accounts imported in a single transaction — consider batching for large imports.

importAccounts opens one transaction and inserts all accounts sequentially. For mainnet snapshots with ~1.3M+ stake accounts, this creates a very large transaction that may cause memory pressure and lock contention, especially on SQLite. The UTxO import path already uses per-batch transactions — consider a similar pattern here.

The same concern applies to importPools (lines 549–631) and importDReps (lines 646–685), though their cardinalities are typically smaller.

mithril/bootstrap.go (1)

183-201: Only the first download location is tried.

snapshot.Locations[0] is used without fallback to other locations if the download fails. For robustness, consider iterating through snapshot.Locations on transient failures — though this can be deferred.

ledgerstate/utxo.go (2)

62-66: Try 4 appears to be dead code.

If Try 1 (line 44) succeeds with len(keyBytes) >= 34, we return at line 46. If it succeeds with len(keyBytes) < 34, then Try 4's len(keyBytes) >= 34 is false. If Try 1's cbor.Decode fails, keyBytes remains nil/empty. In all cases, the condition at line 64 cannot be true.


444-447: os.ReadFile loads the entire tvar file into memory.

UTxO-HD tvar files on mainnet can be several GB. While downstream processing is streamed in batches, the full file content is held in memory. This is pragmatic for now but consider memory-mapped I/O (mmap) or incremental reads for mainnet-scale files if memory becomes a constraint.

mithril/types.go (1)

31-65: SnapshotListItem and Snapshot have identical fields.

These two types share all 11 fields verbatim. Consider embedding a common base type to reduce duplication, unless you expect the Mithril API to diverge them in the future.

Comment on lines +443 to +446
// Script is MemPack (Script era) - length-prefixed for now
script, err := r.readLengthPrefixedBytes()
if err != nil {
return nil, fmt.Errorf("reading Script: %w", err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

cardano-ledger mempack Script era encoding babbage conway plutus

💡 Result:

  • In the Haskell ledger, Script era (for Alonzo-family eras) is represented as AlonzoScript era = NativeScript | PlutusScript, and there are MemPack instances for AlonzoScript whenever PlutusScript era and NativeScript era have MemPack instances. (cardano-ledger.cardano.intersectmbo.org)
  • PlutusScript has era-specific MemPack instances (notably for BabbageEra and ConwayEra). The docs also note that the MemPack (PlutusScript AlonzoEra) instance uses a “zero tag” for forward compatibility with future Plutus script variants, but that it “currently … is not used at all”, because reference scripts were introduced in Babbage, and (for now) MemPack is only used for TxOuts. (cardano-ledger.cardano.intersectmbo.org)
  • Conway extends the set of Plutus script purposes beyond the earlier spending/minting/certifying/rewarding purposes by adding voting and proposing purposes (reflected in ConwayPlutusPurpose and the VotingPurpose / ProposingPurpose patterns). (cardano-ledger.cardano.intersectmbo.org)
  • Plutus “ledger language versions” are tied to eras and available transaction features: Plutus V1 (introduced in Alonzo) can’t be used with Babbage-only features like inline datums; Plutus V2 (introduced in Babbage) can’t be used with Conway-introduced features like DRep registration. (plutus.cardano.intersectmbo.org)
  • At the encoding level, Conway also introduced tagged encoding for sets (a CBOR representation change vs legacy array encoding), and libraries expose “use tag” toggles for Conway-era compatibility (example shown for Plutus script sets). (cardano-c.readthedocs.io)

Citations:


🌐 Web query:

cardano-ledger AlonzoScript MemPack encoding discriminator tag NativeScript PlutusScript

💡 Result:

In cardano-ledger’s Alonzo-era MemPack encoding, the “discriminator” is a Tag written before the payload:

  • AlonzoScript era:

    • NativeScript scriptpackTagM Tag 0 then packM script
    • PlutusScript scriptpackTagM Tag 1 then packM script
      (and decoding dispatches on Tag 0 vs Tag 1 accordingly). [1]
  • PlutusScript AlonzoEra (which, in Alonzo, only has AlonzoPlutusV1):

    • AlonzoPlutusV1 scriptpackTagM Tag 0 then packM script
      This explicit Tag 0 is kept for forward compatibility with future Plutus script variants. [2]

Sources
[1] Cardano.Ledger.Alonzo.Scripts (source view), MemPack (AlonzoScript era) packM/unpackM implementation (tags 0/1). (cardano-ledger.cardano.intersectmbo.org)
[2] Cardano.Ledger.Alonzo.Scripts (source view), MemPack (PlutusScript AlonzoEra) packM (tag 0) + comment on forward compatibility. (cardano-ledger.cardano.intersectmbo.org)

Citations:


🏁 Script executed:

cd /tmp && git ls-files 'ledgerstate/mempack.go' | head -5

Repository: blinklabs-io/dingo

Length of output: 130


🏁 Script executed:

cat -n ledgerstate/mempack.go | sed -n '430,460p'

Repository: blinklabs-io/dingo

Length of output: 1128


🏁 Script executed:

# Check for any tag reading around the Script decoding area
rg -A 10 -B 10 "readLengthPrefixedBytes" ledgerstate/mempack.go | head -50

Repository: blinklabs-io/dingo

Length of output: 1223


Script decoding must read tag discriminator for AlonzoScript union type.

Cardano's MemPack encoding for AlonzoScript uses tag discriminators:

  • NativeScript → Tag 0 + packed script
  • PlutusScript → Tag 1 + packed script

The current code reads only length-prefixed bytes without consuming the tag, which will silently misinterpret the script type and misalign the stream. The "length-prefixed for now" comment indicates this is incomplete. Add tag-based dispatch before reading the script payload.

🤖 Prompt for AI Agents
In `@ledgerstate/mempack.go` around lines 443 - 446, The script decoding currently
calls r.readLengthPrefixedBytes() directly and ignores the AlonzoScript tag
discriminator; change this to first read the discriminator byte/token (e.g.,
using r.readByte or r.readVarUint as appropriate) and switch on its value (0 =>
NativeScript, 1 => PlutusScript) before reading the actual packed script payload
(then call r.readLengthPrefixedBytes() or the appropriate reader for the inner
script); ensure you return an error for any unknown tag and update the variable
currently named script to hold the correctly-dispatched payload for the
AlonzoScript union (reference the existing symbols r.readLengthPrefixedBytes(),
the local variable script, and the AlonzoScript/NativeScript/PlutusScript union
types).

@wolf31o2 wolf31o2 force-pushed the feat/mithril-bootstrap branch from 1ce3e2f to 49c10bb Compare February 14, 2026 03:04
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/node/load.go (1)

293-305: ⚠️ Potential issue | 🟡 Minor

Several raw errors returned without wrapping in copyBlocks.

Lines 297 and 305 return errors from iter.Next() and gledger.NewBlockFromCbor() without any context wrapping. This makes it harder to trace where the failure occurred.

Proposed fix
 			next, err := iter.Next()
 			if err != nil {
-				return blocksCopied, immutableTip.Slot, err
+				return blocksCopied, immutableTip.Slot, fmt.Errorf(
+					"reading next block: %w", err,
+				)
 			}
 			tmpBlock, err := gledger.NewBlockFromCbor(next.Type, next.Cbor)
 			if err != nil {
-				return blocksCopied, immutableTip.Slot, err
+				return blocksCopied, immutableTip.Slot, fmt.Errorf(
+					"decoding block CBOR: %w", err,
+				)
 			}

As per coding guidelines, "Wrap errors with context using fmt.Errorf with %w verb".

🤖 Fix all issues with AI agents
In `@database/plugin/metadata/sqlite/import.go`:
- Around line 141-174: ImportDrep can leave drep.ID as 0 on SQLite upsert
conflicts; add the same post-upsert ID re-fetch used in Postgres/MySQL. After
the db.Create(drep) upsert in ImportDrep, if drep.ID == 0 (or RowsAffected ==
0), query the DB for the existing DRep by its unique credential and populate
drep.ID (e.g., use db.First(&drep, "credential = ?", drep.Credential) or
equivalent) before creating the registration; reference the ImportDrep function
and the drep.ID field to locate where to insert this re-fetch.

In `@internal/node/load.go`:
- Around line 377-403: In copyBlocksRaw, the calls iter.Next(),
extractHeaderCbor(next.Cbor), and gledger.NewBlockHeaderFromCbor(...) return raw
errors that should be wrapped with context; update the error returns in the loop
so each returns fmt.Errorf(... %w, err) with a clear message (e.g., "iter.Next
failed", "extractHeaderCbor failed for slot X", "NewBlockHeaderFromCbor failed
for type Y") while preserving the existing return values (blocksCopied,
immutableTip.Slot, wrappedErr) and reference the symbols iter.Next,
extractHeaderCbor, and gledger.NewBlockHeaderFromCbor to locate the sites to
change.
- Around line 74-89: The code returns raw errors from database.New in the Load
function (and similarly in LoadBlobsWithDB), so wrap those errors with
contextual messages using fmt.Errorf and the %w verb; locate the calls to
database.New (e.g., the db, err = database.New(...) in Load and the analogous
call in LoadBlobsWithDB) and change the error handling to return
fmt.Errorf("creating database: %w", err) (or a similarly descriptive message)
instead of returning err directly, preserving the existing defer db.Close()
behavior.

In `@ledgerstate/certstate.go`:
- Around line 219-239: The code currently silently skips unparseable entries in
parseCertPoolParamsMap and parseCredentialMap; change these handlers to record
failures instead of dropping them silently by (1) logging each decode/parse
error with contextual details (e.g., include entry.KeyRaw or poolKeyHash and the
error) using the module's logger (referencing functions parseCertPoolParamsMap
and parsePoolParams for pool entries and parseCredentialMap for credentials) and
(2) optionally surface a skipped-count to callers by updating the function
signatures to return an additional skippedCount (or an error) so callers can
detect data loss; implement both logging at the continue points and add/return a
skipped counter where these continues occur.

In `@ledgerstate/import.go`:
- Around line 1264-1271: isEpochDuplicate only matches SQLite's error string and
misses MySQL/Postgres duplicates; update it to detect unique-constraint errors
portably by: check errors.Is(err, gorm.ErrDuplicatedKey) if your GORM version
exposes that, and additionally use errors.As to detect driver error types (e.g.,
*mysql.MySQLError with Number 1062, *pq.Error with Code "23505") and/or fallback
to checking MySQL/Postgres duplicate-key substrings ("Duplicate entry",
"duplicate key value violates unique constraint") so both isEpochDuplicate and
SetEpoch (where raw db.Create is used) correctly treat duplicates across
SQLite/MySQL/Postgres. Reference symbols: isEpochDuplicate and SetEpoch.

In `@ledgerstate/snapshot.go`:
- Around line 91-103: The sort comparator for legacyFiles incorrectly passes
filenames with suffixes into strconv.ParseUint, causing parse failures; add a
helper function (e.g., stripLedgerSuffix) that trims known suffixes like
".lstate" and "_snapshot" and use it inside the slices.SortFunc comparator
before calling strconv.ParseUint so na/nb parse the numeric slot correctly, then
return filepath.Join(ledgerDir, legacyFiles[0]) as before; remember to import
strings if not already present.

In `@mithril/bootstrap.go`:
- Around line 468-482: The current leaf certificate check in the block guarding
isLeaf and snapshotDigest uses
cert.ProtocolMessage.MessageParts["snapshot_digest"] (certDigest) but silently
accepts a missing/empty certDigest; change the logic in the isLeaf &&
snapshotDigest != "" branch to treat an empty certDigest as a verification
failure by returning an error that the leaf certificate is missing
snapshot_digest, and otherwise still compare certDigest to snapshotDigest and
return the existing mismatch error if they differ (refer to
cert.ProtocolMessage.MessageParts["snapshot_digest"], certDigest, isLeaf, and
snapshotDigest to locate and modify the check).
- Around line 156-167: Bootstrap currently creates a temp dir when
cfg.DownloadDir is empty using os.MkdirTemp (downloadDir) but can leak it if
later steps fail; update Bootstrap so the created temp dir is cleaned up on
error by implementing a named return with a success boolean (or similar guard)
and a deferred cleanup that removes downloadDir unless success is set true, or
alternatively return a partial BootstrapResult that carries Cleanup() so caller
can remove it; reference the Bootstrap function, the downloadDir variable, and
BootstrapResult/Cleanup() to locate and modify the allocation path around
os.MkdirTemp.

In `@mithril/client.go`:
- Around line 67-82: NewClient currently stores aggregatorURL verbatim which
allows a trailing slash to produce double slashes when building endpoints (see
uses in request URL concatenation). In NewClient, sanitize aggregatorURL by
removing any trailing slash (e.g., use strings.TrimSuffix or strings.TrimRight
with "/") before saving to the Client struct so subsequent concatenations like
aggregatorURL + "/artifact/..." do not yield double slashes; preserve a lone
scheme/root correctly (i.e., only strip a trailing "/" not nullify the URL).

In `@mithril/download.go`:
- Around line 199-206: The 416 (http.StatusRequestedRangeNotSatisfiable) branch
currently returns immediately, bypassing the ExpectedSize verification and
accepting potentially corrupt or wrong-size files; update the case in the
download function to perform the same size check used later (the
ExpectedSize/file-size verification block) before returning: stat destPath,
compare its size to ExpectedSize (or reuse the existing verification logic
already present later in this function), and if sizes mismatch log an error via
cfg.Logger and return an error instead of returning destPath,nil; only return
destPath,nil when the on-disk file size matches ExpectedSize.

In `@mithril/extract.go`:
- Around line 86-108: The HasPrefix check incorrectly rejects a tar entry like
"." because target equals destDir (no trailing separator); update the validation
around target (the variable built from filepath.Join(destDir,
filepath.FromSlash(name))) to allow either an exact match to
filepath.Clean(destDir) OR a HasPrefix starting with
filepath.Clean(destDir)+string(filepath.Separator). Keep the existing use of
path.Clean(header.Name) and validRelPath(name) but replace the single
strings.HasPrefix(...) conditional with a check that accepts target ==
filepath.Clean(destDir) || strings.HasPrefix(target,
filepath.Clean(destDir)+string(filepath.Separator)) to avoid false "path escapes
destination" errors.

In `@mithril/types.go`:
- Around line 162-172: The IsChainingToItself method currently returns true when
Certificate.Hash == Certificate.PreviousHash even if both are empty; update
IsChainingToItself (on type Certificate) to first guard against empty Hash or
PreviousHash and return false if either is empty, then compare equality—this
prevents malformed certificates with empty fields from being treated as
root/genesis in VerifyCertificateChain.

In `@README.md`:
- Around line 170-172: The README sets CARDANO_NETWORK but the mithril-client
commands shown do not use it; either remove the "export CARDANO_NETWORK=preview"
line or add a clarifying comment that CARDANO_NETWORK is required only for the
subsequent dingo command (not mithril-client), and explicitly mention which
variables are consumed by mithril-client (AGGREGATOR_ENDPOINT and
GENESIS_VERIFICATION_KEY) so readers know which env vars to set; update the
README section near the existing AGGREGATOR_ENDPOINT line to reflect this change
and reference the CARDANO_NETWORK and dingo usage if you keep the export.
🧹 Nitpick comments (21)
chain/chain.go (1)

258-333: Consider extracting shared logic with AddBlock to reduce duplication.

AddRawBlock duplicates nearly all of AddBlock's body (lock ordering, reconcile, header/tip checks, block persistence, tip update, waiter notification, event emission). A shared private helper (e.g., addBlockInternal) accepting the already-constructed models.Block and pre-computed point would let both public methods delegate to it, keeping the validation and state-management logic in one place.

ledger/forging/builder_test.go (1)

566-567: Remove unused constructor call on lines 566–567.

The input variable is created with NewConstructorEncoder but never used—it's immediately assigned to _ on the next line. The transaction body is built manually via bodyMap below. This dead code can be safely removed.

Remove unused code
-	input := cbor.NewConstructorEncoder(0, cbor.IndefLengthList{})
-	_ = input // not used directly; we encode manually below
-
 	bodyMap := map[uint]any{
internal/config/config.go (2)

58-60: Minor grammar nit in comment.

There's a missing period/comma between "plugins" and "This".

Proposed fix
-// ErrPluginListRequested is returned when the user requests to list available
-// plugins This is not an error condition but a successful operation that
-// displays plugin information
+// ErrPluginListRequested is returned when the user requests to list
+// available plugins. This is not an error condition but a successful
+// operation that displays plugin information.

402-472: Duplicated blob/metadata config extraction logic.

The blob config processing block (lines 403-436) and the metadata config processing block (lines 438-472) are nearly identical in structure — both extract a plugin key, build a config map with map[any]anymap[string]any conversion, and merge. Consider extracting a helper like extractPluginConfig(raw map[string]any) (string, map[string]map[string]any, error) to reduce duplication.

mithril/client.go (1)

200-203: Parameter url shadows the imported net/url package.

The parameter name url on line 203 shadows the net/url import used on line 133 (url.PathEscape). While it doesn't cause a bug here (the url import is only used in caller methods, not in doGet), it reduces clarity and could cause issues if doGet ever needs url.Parse or similar.

Proposed fix
 func (c *Client) doGet(
 	ctx context.Context,
-	url string,
+	reqURL string,
 ) (io.ReadCloser, error) {
 	req, err := http.NewRequestWithContext(
 		ctx,
 		http.MethodGet,
-		url,
+		reqURL,
 		nil,
 	)
internal/node/load.go (1)

195-257: Significant duplication between LoadWithDB and LoadBlobsWithDB for DB init and bulk-load setup.

The database initialization (lines 201-217) and bulk-load optimizer setup (lines 219-235) in LoadBlobsWithDB are nearly identical to LoadWithDB (lines 74-109). Consider extracting a shared helper like openAndOptimizeDB(cfg, logger, db) (*database.Database, func(), error) that returns the db and a cleanup function.

ledgerstate/certstate.go (1)

86-172: Heuristic-based Conway CertState parsing — document assumptions and add safeguard.

The Conway parser identifies PState/DState/VState by CBOR type and size heuristics (largest array → PState, largest map → DState, remaining smaller map → VState). While pragmatic for the observed node 10.x encoding, this is fragile against future era changes.

Consider adding a log warning (or returning a diagnostic) when the element count is neither 3 nor 6, since the heuristic was designed around those two layouts. This would help surface parsing issues early rather than silently producing incomplete data.

mithril/download_test.go (1)

47-50: require in OnProgress callback is safe here but worth a note.

The OnProgress callback at line 49 uses require.GreaterOrEqual. This is safe because DownloadSnapshot calls the callback synchronously from io.Copy on the test goroutine. If the implementation ever invokes callbacks from a different goroutine, this would need to switch to assert.

ledgerstate/verify.go (1)

83-130: Consider validating the checksum file contains valid hex.

If the .checksum file contains non-hex garbage, strings.EqualFold will always report a mismatch without indicating the checksum file itself is malformed. Decoding the expected hex first would give a more actionable error message.

Suggested improvement
 	expectedHex := strings.TrimSpace(string(checksumData))
 	if expectedHex == "" {
 		return nil // Empty checksum file, skip verification
 	}
+	if _, err := hex.DecodeString(expectedHex); err != nil {
+		return fmt.Errorf(
+			"invalid hex in checksum file %s: %w",
+			checksumPath, err,
+		)
+	}

(Would require adding "encoding/hex" to imports.)

ledgerstate/mempack_debug_test.go (1)

10-15: Debug test depends on hardcoded /tmp path — consider a build tag.

This test always runs in CI (where it will be skipped), adding noise to test output. A //go:build manual or similar build tag would keep it out of go test ./... runs while remaining easily invocable during development.

mithril/certverify_test.go (1)

280-376: Consider adding a snapshot_digest to cert_leaf to exercise the full digest verification path.

The cert_leaf in this test has no ProtocolMessage.MessageParts["snapshot_digest"], so VerifyCertificateChain silently skips the digest binding check (per bootstrap.go line ~475: if certDigest != "" guard). Adding the digest would validate that the end-to-end flow correctly binds the snapshot to its certificate.

 	certs := map[string]Certificate{
 		"cert_leaf": {
 			Hash:           "cert_leaf",
 			PreviousHash:   "cert_genesis",
 			MultiSignature: "sig",
+			ProtocolMessage: ProtocolMessage{
+				MessageParts: map[string]string{
+					"snapshot_digest": "abc123def456789012345678",
+				},
+			},
 		},
ledgerstate/mempack_test.go (1)

203-234: Same /tmp hardcoded path concern as the debug test — consider a shared constant or build tag.

This test and TestDebugFirstTwoEntries in mempack_debug_test.go both reference the same hardcoded path. Extracting it to a package-level constant would ensure they stay in sync and make it easier to update.

cmd/dingo/mithril.go (1)

346-366: loadResult dereference on Line 364 is safe only if LoadBlobsWithDB never returns (nil, nil).

After g.Wait() succeeds, loadResult is expected to be non-nil, but there's no guard. If LoadBlobsWithDB ever returned a nil result without an error, loadResult.BlocksCopied would panic.

Defensive guard
 	if len(recentBlocks) > 0 {
 		chainTip := recentBlocks[0]
 		// ...
+		blocksCopied := int64(0)
+		if loadResult != nil {
+			blocksCopied = loadResult.BlocksCopied
+		}
 		logger.Info(
 			"metadata tip updated to chain tip",
 			"component", "mithril",
 			"slot", chainTip.Slot,
-			"blocks_loaded", loadResult.BlocksCopied,
+			"blocks_loaded", blocksCopied,
 		)
ledgerstate/snapshots.go (1)

339-343: Silent skip on hex.DecodeString failure for a self-encoded key.

Since poolHex was encoded via hex.EncodeToString at line 319, hex.DecodeString at line 340 should never fail here. However, silently continuing discards a pool's aggregated stake with no warning, which is inconsistent with the warning pattern used in the parse functions above. Consider at least logging or counting skips for parity.

ledgerstate/import.go (2)

1100-1111: Redundant epochSpan > 0 check; consider guarding slotSpan subtraction.

After the endBound.Epoch <= startBound.Epoch guard on line 1101, epochSpan at line 1104 is guaranteed > 0, making the check on line 1105 redundant. More importantly, endBound.Slot - startBound.Slot on line 1106 could underflow if the slot bounds are inconsistent (unlikely but unguarded), whereas the epoch bounds are guarded.

Proposed simplification
 	// Fallback: compute from bounds if we have both start and end.
 	if endBound.Epoch <= startBound.Epoch {
 		return 0, 0
 	}
+	if endBound.Slot < startBound.Slot {
+		return 0, 0
+	}
 	epochSpan := endBound.Epoch - startBound.Epoch
-	if epochSpan > 0 {
-		slotSpan := endBound.Slot - startBound.Slot
-		return 0, uint(slotSpan / epochSpan)
-	}
-
-	return 0, 0
+	slotSpan := endBound.Slot - startBound.Slot
+	return 0, uint(slotSpan / epochSpan)

490-534: Single transaction for all accounts may be long-lived on mainnet.

On mainnet there can be hundreds of thousands of staking accounts. importAccounts wraps all inserts in a single transaction (line 503), which could hold a write lock for a long time depending on the backend. The same applies to importPools and importDReps, though those have fewer entries.

This is acceptable for a one-time bootstrap import, but worth noting if the import path is ever used more generally.

ledgerstate/snapshot.go (1)

200-210: ParseSnapshot reads entire file into memory.

os.ReadFile loads the full ledger state file at once. For mainnet snapshots this can be several GB. This is acceptable for a bootstrap operation, but worth noting for memory-constrained environments. A streaming approach could be considered in a future iteration if needed.

ledgerstate/utxo.go (1)

319-341: extractAddressKeys slices reference the original addr backing array.

The slices addr[1:29] and addr[29:57] share memory with the input buffer. If the caller later overwrites or reuses that buffer (e.g., in a streaming decode loop), the PaymentKey/StakingKey values would be silently corrupted. This is safe if the decoded addr is freshly allocated by the CBOR decoder per entry (which appears to be the case for both MemPack and CBOR paths), but worth noting.

mithril/bootstrap.go (1)

183-201: Only the first download location is tried; no fallback to alternate mirrors.

snapshot.Locations[0] is always used. If that CDN endpoint is down, the bootstrap fails even though other locations may be available. Same pattern at line 309 for ancillary data.

This is acceptable for an initial implementation, but consider iterating over Locations on transient failures for improved resilience.

ledgerstate/govstate.go (1)

332-347: Defensive check on fields length is good, but consider validating govId field sizes.

The function validates array lengths throughout. One observation: prop.TxHash (line 365) is expected to be 32 bytes per the struct comment, but the decoded value is not length-checked. A malformed CBOR payload could produce a hash of unexpected length that downstream consumers may not handle.

This is low risk since CBOR data comes from a verified Mithril snapshot, but worth noting if this parser is reused for untrusted inputs.

mithril/types.go (1)

31-70: SnapshotListItem and Snapshot are structurally identical.

Both structs have exactly the same fields and JSON tags. If they're not expected to diverge, consider embedding a shared base type or using a single type with a type alias. If they will diverge (e.g., the detail endpoint adds fields), this is fine as-is.

@wolf31o2 wolf31o2 force-pushed the feat/mithril-bootstrap branch from 49c10bb to d1e3f64 Compare February 14, 2026 11:52
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/node/load.go (1)

66-68: ⚠️ Potential issue | 🟡 Minor

Unwrapped error from LoadCardanoNodeConfigWithFallback.

Line 67 returns the raw error without context wrapping.

Proposed fix
 	if err != nil {
-		return err
+		return fmt.Errorf("loading cardano node config: %w", err)
 	}

As per coding guidelines, "Wrap errors with context using fmt.Errorf with %w verb".

🤖 Fix all issues with AI agents
In `@database/plugin/metadata/mysql/import.go`:
- Around line 127-135: The OnConflict clause around the Create(reg) call is
ineffective because PoolRegistration and RegistrationDrep lack a composite
unique key; fix by either (A) make the conflict meaningful: add a composite
unique constraint to the models (e.g., on PoolRegistration add a unique index
combining PoolID + AddedSlot + CertificateID/CertIndex and on RegistrationDrep
add a unique index combining DrepID + AddedSlot + CertificateID/CertIndex), add
a DB migration to create those unique indexes, and keep the
db.Clauses(clause.OnConflict{DoNothing: true}).Create(reg) usage so duplicate
inserts are prevented at the DB level; or (B) remove the misleading OnConflict
clause from the Create(reg) call and update comments/docs to state idempotency
is only provided by ImportCheckpoint. Locate the Create(reg) call and the
PoolRegistration/RegistrationDrep model definitions to apply the model
tags/migration or to remove the clause and documentation accordingly.

In `@ledgerstate/utxo.go`:
- Around line 433-442: ParseUTxOsFromFile currently uses os.ReadFile (in
ParseUTxOsFromFile) which loads the whole tvar into memory; change it to a
streaming approach (or memory-map) similar to ParseUTxOsStreaming: open the file
with os.Open, create an io.Reader (or mmap.ReaderAt) and feed that into the CBOR
streaming/decoder logic used by ParseUTxOsStreaming so the top-level map and
batched entries are decoded incrementally; ensure you replace the os.ReadFile
usage, close the file when done, preserve the existing UTxOCallback batching
semantics, and propagate errors from open/decode operations.
🧹 Nitpick comments (22)
chain/chain.go (3)

258-333: Consider extracting shared logic with AddBlock to reduce duplication.

AddRawBlock duplicates nearly the entire body of AddBlock (lock acquisition, reconcile, header check, tip check, block construction, persist, tip update, notify waiters, emit event). The only difference is how fields are extracted (from RawBlock fields vs. ledger.Block methods). A shared internal helper (e.g., addBlockInternal) accepting the already-extracted values would eliminate this duplication and ensure future changes (e.g., new validations) are applied consistently.


320-331: Per-block event emission may bottleneck bulk Mithril imports.

During a Mithril bootstrap, AddRawBlocks will call AddRawBlock for potentially millions of blocks, each publishing a ChainUpdateEvent. If any subscriber does non-trivial work per event, this becomes a hot path. Consider either batching events (one event per batch of 50) or providing an option to suppress events during bulk load and emit a single "bulk load complete" event afterward.


266-268: Errors returned without wrapping context.

Per coding guidelines, errors should be wrapped with fmt.Errorf and %w. For example:

if err := c.reconcile(); err != nil {
    return fmt.Errorf("reconcile: %w", err)
}

This applies to the reconcile() call (Line 266) and addBlock() call (Line 300). The existing AddBlock has the same gap, so this is not a regression — but worth addressing here since this is new code.

As per coding guidelines: "Wrap errors with context using fmt.Errorf with %w verb".

Also applies to: 300-301

mithril/extract_test.go (1)

175-197: Good edge-case coverage for validRelPath.

Consider adding a test case for "." which path.Clean produces from entries like "./". This path currently passes validRelPath but then causes a false "path escapes destination" error in ExtractArchive (as noted in the existing review on extract.go lines 86-110).

{"current dir dot", ".", false},
mithril/extract.go (1)

190-203: validRelPath should also reject ".".

While the "." path doesn't cause a runtime error in ExtractArchive (it safely creates the already-existing destDir), it represents a root-directory tar entry that should arguably be skipped rather than processed. Rejecting it in validRelPath or adding a name == "." skip before the switch would be cleaner.

♻️ Suggested approach in ExtractArchive
 		name := path.Clean(header.Name)
+		if name == "." {
+			continue // skip root directory entry
+		}
 		if !validRelPath(name) {
mithril/types.go (1)

31-65: SnapshotListItem and Snapshot have identical fields.

These two structs share the exact same field set. If they're not expected to diverge, consider using a single type or embedding a shared base to reduce duplication. If they're kept separate intentionally (e.g., API may evolve them independently), a brief comment explaining why would help future readers.

ledgerstate/cbor_decode.go (1)

28-38: Consider wrapping the error in decodeRawArray for context.

Per coding guidelines, errors should be wrapped with context using fmt.Errorf with %w. The raw cbor.Decode error on line 31 loses the context that this was specifically an array decode attempt.

♻️ Suggested fix
 	var items []cbor.RawMessage
 	if _, err := cbor.Decode(data, &items); err != nil {
-		return nil, err
+		return nil, fmt.Errorf("decoding CBOR array: %w", err)
 	}

As per coding guidelines, **/*.go: Wrap errors with context using fmt.Errorf with %w verb.

ledgerstate/certstate.go (2)

97-194: Heuristic-based Conway element detection is fragile but pragmatically acceptable.

The "largest array = PState, largest map = DState" heuristic works for the known Conway 6-element structure, but could misidentify elements if future eras change relative sizes (e.g., a very large VState DRep map). The approach is well-documented in comments. Consider adding a log/warning when using this path so operators know the heuristic was applied.


460-487: parsePoolDeposits silently drops unparseable entries, unlike other parsers.

Other map parsers (parseCertPoolParamsMap, parseCredentialMap, parseStakeMap, etc.) track a skipped counter and return a warning. parsePoolDeposits uses bare continue at lines 473 and 480 with no visibility into data loss. Consider aligning with the warning pattern used elsewhere for consistency.

♻️ Suggested fix
 func parsePoolDeposits(data []byte) map[string]uint64 {
 	entries, err := decodeMapEntries(data)
 	if err != nil {
 		return nil
 	}
 
 	deposits := make(map[string]uint64, len(entries))
+	var skipped int
 	for _, entry := range entries {
 		var keyHash []byte
 		if _, err := cbor.Decode(
 			entry.KeyRaw, &keyHash,
 		); err != nil {
+			skipped++
 			continue
 		}
 
 		var amount uint64
 		if _, err := cbor.Decode(
 			entry.ValueRaw, &amount,
 		); err != nil {
+			skipped++
 			continue
 		}
 
 		deposits[hex.EncodeToString(keyHash)] = amount
 	}
 
 	return deposits
 }

Note: The function currently returns only a map with no error. To fully align, the signature would need to return an error as well, but that's a larger change. At minimum, tracking the count internally could be useful for debugging.

internal/node/load.go (1)

150-181: Consider wrapping the error from copyBlocks for additional context in the caller.

Line 155 returns err from copyBlocks without additional context from LoadWithDB. While copyBlocks does wrap its own errors, adding LoadWithDB context would help with traceability in log output.

Proposed fix
 	blocksCopied, immutableTipSlot, err := copyBlocks(
 		logger, immutableDir, c,
 	)
 	if err != nil {
-		return err
+		return fmt.Errorf("loading blocks: %w", err)
 	}

As per coding guidelines, "Wrap errors with context using fmt.Errorf with %w verb".

ledgerstate/telescope.go (1)

32-58: Consider extracting the shared format-detection logic to reduce duplication.

Both extractAllEraBounds and navigateTelescope repeat the same pattern: decode the raw array, check len >= 2, try to decode the first item as uint64 to distinguish flat vs. nested format. This could be factored into a small helper (e.g., detectTelescopeFormat) to keep the two entry points DRY.

Not blocking since both implementations are correct and well-bounded.

Also applies to: 247-276

ledgerstate/snapshot.go (1)

213-223: ParseSnapshot reads the entire file into memory — acceptable for bootstrap but worth noting.

os.ReadFile loads the full ledger state file at once. Ledger state files can range from tens of MB to a few hundred MB. For a one-time bootstrap operation this is acceptable, but be aware of memory pressure if this is ever called in a memory-constrained environment.

ledgerstate/govstate.go (1)

437-452: Anchor parsing is intentionally best-effort — consider adding a brief comment.

The anchor [url, hash] decoding silently swallows errors (lines 439, 441-445, 447-451), while other fields in parseGovActionState are strict. This is reasonable since anchor is non-critical metadata, but a short inline comment explaining why would help future readers.

ledgerstate/import_test.go (1)

180-190: Consider releasing the transaction before the final fmt.Fprintf.

The txn.Release() is deferred (line 182), but the fmt.Fprintf block at lines 197-208 accesses state.Tip fields outside of any transaction context. This is fine since state is a local variable, not database state — but it's worth noting that txn is held open longer than necessary across the final logging block.

mithril/certverify_test.go (1)

280-383: Good end-to-end bootstrap verification test.

This test thoroughly validates the full bootstrap flow with certificate verification enabled, including snapshot listing, certificate fetching, and archive download.

One minor inconsistency: this file uses defer server.Close() while mithril/client_test.go uses t.Cleanup(server.Close). Consider aligning on one pattern within the mithril package for consistency.

ledgerstate/mempack_test.go (1)

203-233: Hardcoded absolute path will rot — consider using an env var or t.TempDir() fixture.

The path /tmp/dingo-mithril-download/ancillary/ledger/104173430/tables/tvar contains a hardcoded epoch/slot number that will become stale. While the os.Stat + t.Skip makes this safe, it's effectively dead code for CI and other developers. Consider gating on an env var (e.g., DINGO_TVAR_PATH) or removing this test in favor of the integration test in import_test.go that already exercises tvar parsing with a live snapshot.

♻️ Suggested approach
 func TestParseTvarFileIfAvailable(t *testing.T) {
-	tvarPath := "/tmp/dingo-mithril-download/ancillary/" +
-		"ledger/104173430/tables/tvar"
-	if _, err := os.Stat(tvarPath); os.IsNotExist(err) {
-		t.Skip("tvar file not available, skipping")
+	tvarPath := os.Getenv("DINGO_TVAR_PATH")
+	if tvarPath == "" {
+		t.Skip("set DINGO_TVAR_PATH to run this test")
 	}
ledgerstate/import.go (1)

498-543: Single transaction for all accounts may cause issues with large datasets.

importAccounts opens one transaction (line 512) and iterates through every account inside it. On mainnet, there can be hundreds of thousands of stake accounts. A single long-running transaction can cause lock contention, high memory usage, and risk timeouts depending on the backend.

Consider batching (similar to how importUTxOs uses batchCallback) — e.g., commit every N accounts and open a new transaction.

The same pattern applies to importPools (lines 558-640) and importDReps (lines 655-694), though those datasets are smaller in practice.

mithril/client.go (1)

59-83: WithHTTPClient bypasses the HTTPS-only redirect policy.

When a caller uses WithHTTPClient, the custom client replaces the default one including its httpsOnlyRedirect check. This is the expected functional-option pattern, but callers should be aware that security-sensitive defaults are lost. A brief doc comment on WithHTTPClient noting this would help.

📝 Suggested doc addition
-// WithHTTPClient sets a custom *http.Client for the Mithril client.
+// WithHTTPClient sets a custom *http.Client for the Mithril client.
+// Note: this replaces the default client, including the HTTPS-only
+// redirect policy. The caller is responsible for configuring
+// appropriate security settings on the provided client.
 func WithHTTPClient(hc *http.Client) ClientOption {
cmd/dingo/mithril.go (1)

346-370: Tip is updated unconditionally even if the blob store loaded zero blocks.

If recentBlocks has entries but blocksCopied is 0 (load found nothing new), the tip is still overwritten. This is likely fine for bootstrap (the blocks are already there from a previous run), but the log message at line 364-369 could be misleading in that scenario.

mithril/download.go (1)

150-153: New http.Client created per download — no connection reuse.

Each DownloadSnapshot call creates a fresh http.Client, preventing TCP/TLS connection reuse across multiple downloads (main + ancillary). Consider accepting an optional *http.Client in DownloadConfig or reusing the Mithril client's HTTP client.

ledgerstate/utxo.go (1)

381-389: ledger.NewBlake2b224(nil) called twice per TxOut — consider a package-level zero value.

Lines 383 and 387 each allocate a new zero-hash for comparison. For millions of UTxOs, this creates unnecessary allocations.

♻️ Proposed optimization
+var zeroBlake2b224 = ledger.NewBlake2b224(nil)
+
 func parseCborTxOut(
 	...
 ) (*ParsedUTxO, error) {
 	...
 	pkh := addr.PaymentKeyHash()
-	if pkh != ledger.NewBlake2b224(nil) {
+	if pkh != zeroBlake2b224 {
 		result.PaymentKey = pkh.Bytes()
 	}
 	skh := addr.StakeKeyHash()
-	if skh != ledger.NewBlake2b224(nil) {
+	if skh != zeroBlake2b224 {
 		result.StakingKey = skh.Bytes()
 	}
mithril/bootstrap.go (1)

192-210: Only the first download location is used — no fallback on failure.

snapshot.Locations[0] is used at line 196, but Locations can contain multiple mirrors. If the first location is unavailable or slow, the download fails without trying alternatives.

Consider iterating through snapshot.Locations on download failure:

♻️ Proposed fallback approach
-	} else {
-		var dlErr error
-		archivePath, dlErr = DownloadSnapshot(
-			ctx, DownloadConfig{
-				URL:          snapshot.Locations[0],
-				DestDir:      downloadDir,
-				Filename:     archiveFilename,
-				ExpectedSize: snapshot.Size,
-				Logger:       cfg.Logger,
-				OnProgress:   cfg.OnProgress,
-			},
-		)
-		if dlErr != nil {
-			return nil, fmt.Errorf(
-				"downloading snapshot: %w",
-				dlErr,
-			)
-		}
+	} else {
+		var dlErr error
+		for i, loc := range snapshot.Locations {
+			archivePath, dlErr = DownloadSnapshot(
+				ctx, DownloadConfig{
+					URL:          loc,
+					DestDir:      downloadDir,
+					Filename:     archiveFilename,
+					ExpectedSize: snapshot.Size,
+					Logger:       cfg.Logger,
+					OnProgress:   cfg.OnProgress,
+				},
+			)
+			if dlErr == nil {
+				break
+			}
+			cfg.Logger.Warn(
+				"download location failed, trying next",
+				"component", "mithril",
+				"location", i,
+				"error", dlErr,
+			)
+		}
+		if dlErr != nil {
+			return nil, fmt.Errorf(
+				"downloading snapshot (all locations failed): %w",
+				dlErr,
+			)
+		}
 	}

The same applies to downloadAncillary at line 319.

Comment on lines +433 to +442
func ParseUTxOsFromFile(
path string,
era int,
callback UTxOCallback,
) (int, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, fmt.Errorf("reading tvar file: %w", err)
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

os.ReadFile loads entire tvar file into memory — mainnet files can be multi-GB.

ParseUTxOsFromFile reads the entire UTxO-HD tvar file into memory at line 438. On mainnet, these files can be several gigabytes. The streaming approach used for the map contents (batched callbacks) is negated by loading the entire file upfront.

Consider using memory-mapped I/O (mmap) or a buffered streaming approach that reads the file in chunks, similar to how ParseUTxOsStreaming processes the CBOR map incrementally.

🤖 Prompt for AI Agents
In `@ledgerstate/utxo.go` around lines 433 - 442, ParseUTxOsFromFile currently
uses os.ReadFile (in ParseUTxOsFromFile) which loads the whole tvar into memory;
change it to a streaming approach (or memory-map) similar to
ParseUTxOsStreaming: open the file with os.Open, create an io.Reader (or
mmap.ReaderAt) and feed that into the CBOR streaming/decoder logic used by
ParseUTxOsStreaming so the top-level map and batched entries are decoded
incrementally; ensure you replace the os.ReadFile usage, close the file when
done, preserve the existing UTxOCallback batching semantics, and propagate
errors from open/decode operations.

@wolf31o2 wolf31o2 force-pushed the feat/mithril-bootstrap branch 2 times, most recently from bb7a6a3 to e7f3e12 Compare February 14, 2026 13:52
@wolf31o2
Copy link
Member Author

@cubic-dev-ai review

@cubic-dev-ai
Copy link

cubic-dev-ai bot commented Feb 14, 2026

@cubic-dev-ai review

@wolf31o2 I have started the AI code review. It will take a few minutes to complete.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

13 issues found across 50 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="ledgerstate/snapshot.go">

<violation number="1" location="ledgerstate/snapshot.go:273">
P2: Comment says "log but continue without bounds" but the error `boundsErr` is silently discarded — no logging actually occurs. Either add actual logging (e.g., `slog.Warn`) or update the comment to reflect that the error is intentionally ignored.</violation>
</file>

<file name="ledgerstate/cbor_decode.go">

<violation number="1" location="ledgerstate/cbor_decode.go:253">
P1: Integer overflow: `int(arg)` can wrap negative for a crafted large `uint64`, causing the bounds check `size > len(data)` to silently pass. Validate that `arg` fits in a non-negative `int` before casting, or compare in `uint64` space.</violation>
</file>

<file name="ledgerstate/certstate.go">

<violation number="1" location="ledgerstate/certstate.go:157">
P2: Conway-era DReps may be silently dropped. `parseCertStateConway` passes raw DRep map bytes to `parseVState`, but `parseVState` expects a VState *array* `[drepMap, ccHotKeys, ...]` and accesses `vs[0]` as the inner DRep map. When a flat map is passed, `decodeRawElements` will decompose it differently, so `decodeMapEntries(vs[0])` will operate on a single map value rather than the whole DRep map. The `vErr == nil && len(dreps) > 0` guard silently absorbs the failure.

Consider parsing the DRep map directly in the Conway path instead of delegating to `parseVState`:
```go
if isMap && (dIdx < 0 || len(elem) < dSize) {
    // In Conway flat layout, elem IS the drep map directly
    drepEntries, dErr := decodeMapEntries(elem)
    if dErr == nil && len(drepEntries) > 0 {
        // parse drep entries directly...
    }
}
```</violation>

<violation number="2" location="ledgerstate/certstate.go:782">
P2: Credential type (key vs. script) is silently discarded. `parseCredential` parses `[type, hash]` but only returns `hash`, losing the distinction between key-based (type 0) and script-based (type 1) credentials. If downstream consumers need to reconstruct addresses or store credential types, this information is permanently lost at parse time. Consider returning a struct with both fields or encoding the type in the returned byte slice.</violation>
</file>

<file name="ledgerstate/import.go">

<violation number="1" location="ledgerstate/import.go:1006">
P2: When `EpochLengthFunc` is unavailable for the current (last) era, `resolveEraParams` silently returns `(0, 0)` because the zero-value `endBound` always triggers the early return. This causes every epoch in the last era to have `startSlot = lastBound.Slot` and `lengthInSlots = 0`, silently corrupting epoch data critical for the slot clock. Consider returning an error from `resolveEraParams` when it cannot determine valid parameters, or at minimum logging a warning before using the zero fallback.</violation>

<violation number="2" location="ledgerstate/import.go:1156">
P2: The docstring claims this imports "constitution, committee, proposals" but committee members (`govState.Committee`) are parsed by `ParseGovState` and then silently discarded — no import, no log. Consider either adding a log message that committee import is not yet supported, or updating the docstring to reflect what is actually imported.</violation>
</file>

<file name="internal/node/load.go">

<violation number="1" location="internal/node/load.go:254">
P2: Unwrapped error return from `copyBlocksRaw` — inconsistent with the sibling `LoadWithDB` which wraps the same pattern with `fmt.Errorf("loading blocks: %w", err)`. The project convention (per AGENTS.md) is to always wrap errors with context.</violation>
</file>

<file name="mithril/bootstrap.go">

<violation number="1" location="mithril/bootstrap.go:392">
P2: Ancillary archive file (`.tar.zst`) is never cleaned up. `downloadAncillary` downloads the archive but only returns the extraction directory path. The archive file path is not stored in `BootstrapResult`, so `Cleanup()` cannot remove it. Consider adding an `AncillaryArchivePath` field to `BootstrapResult` and including it in the cleanup paths.</violation>
</file>

<file name="ledgerstate/govstate.go">

<violation number="1" location="ledgerstate/govstate.go:163">
P2: Validate the constitution policy hash length and surface decode errors; silently ignoring non‑28‑byte values can hide malformed governance state.</violation>

<violation number="2" location="ledgerstate/govstate.go:217">
P2: Do not treat a malformed StrictMaybe committee (tag present but missing body) as SNothing; return an error for unexpected tags or missing body to avoid silently dropping committee data.</violation>
</file>

<file name="cmd/dingo/mithril.go">

<violation number="1" location="cmd/dingo/mithril.go:469">
P2: Errors from `FindLedgerStateFile` are silently swallowed. A permission-denied or I/O error would be indistinguishable from "file not found", potentially causing the ledger state import to be silently skipped. Consider logging the error at debug/warn level.</violation>
</file>

<file name="ledgerstate/mempack.go">

<violation number="1" location="ledgerstate/mempack.go:608">
P2: Out-of-bounds asset name offsets are silently ignored (resulting in a nil/empty asset name), while out-of-bounds policy ID offsets correctly return an error. This inconsistency could hide data corruption. Consider returning an error when name offsets are out of bounds, consistent with the policy ID bounds check.</violation>

<violation number="2" location="ledgerstate/mempack.go:628">
P2: Trimming trailing null bytes from the last asset name may corrupt names that legitimately end with `0x00`. Cardano asset names are arbitrary byte strings. Consider using the difference between the name region's start (derived from the last entry in Region D) and the total concatenated name length to determine exact boundaries, rather than stripping nulls heuristically.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

return 0, err
}
// #nosec G115
size := hLen + int(arg)
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Integer overflow: int(arg) can wrap negative for a crafted large uint64, causing the bounds check size > len(data) to silently pass. Validate that arg fits in a non-negative int before casting, or compare in uint64 space.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/cbor_decode.go, line 253:

<comment>Integer overflow: `int(arg)` can wrap negative for a crafted large `uint64`, causing the bounds check `size > len(data)` to silently pass. Validate that `arg` fits in a non-negative `int` before casting, or compare in `uint64` space.</comment>

<file context>
@@ -0,0 +1,421 @@
+			return 0, err
+		}
+		// #nosec G115
+		size := hLen + int(arg)
+		if size > len(data) {
+			return 0, fmt.Errorf(
</file context>
Fix with Cubic

c := &ParsedConstitution{}

var url string
if _, err := cbor.Decode(
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Validate the constitution policy hash length and surface decode errors; silently ignoring non‑28‑byte values can hide malformed governance state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/govstate.go, line 163:

<comment>Validate the constitution policy hash length and surface decode errors; silently ignoring non‑28‑byte values can hide malformed governance state.</comment>

<file context>
@@ -0,0 +1,474 @@
+	c := &ParsedConstitution{}
+
+	var url string
+	if _, err := cbor.Decode(
+		anchor[0], &url,
+	); err != nil {
</file context>
Fix with Cubic

if nameStart > nameEnd {
nameStart, nameEnd = nameEnd, nameStart
}
if nameStart >= 0 &&
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Out-of-bounds asset name offsets are silently ignored (resulting in a nil/empty asset name), while out-of-bounds policy ID offsets correctly return an error. This inconsistency could hide data corruption. Consider returning an error when name offsets are out of bounds, consistent with the policy ID bounds check.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/mempack.go, line 608:

<comment>Out-of-bounds asset name offsets are silently ignored (resulting in a nil/empty asset name), while out-of-bounds policy ID offsets correctly return an error. This inconsistency could hide data corruption. Consider returning an error when name offsets are out of bounds, consistent with the policy ID bounds check.</comment>

<file context>
@@ -0,0 +1,792 @@
+				if nameStart > nameEnd {
+					nameStart, nameEnd = nameEnd, nameStart
+				}
+				if nameStart >= 0 &&
+					nameStart <= len(flat) &&
+					nameEnd <= len(flat) {
</file context>
Fix with Cubic

@wolf31o2 wolf31o2 force-pushed the feat/mithril-bootstrap branch from e7f3e12 to 3da4464 Compare February 14, 2026 14:42
@wolf31o2
Copy link
Member Author

@cubic-dev-ai review

@cubic-dev-ai
Copy link

cubic-dev-ai bot commented Feb 14, 2026

@cubic-dev-ai review

@wolf31o2 I have started the AI code review. It will take a few minutes to complete.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 50 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="ledgerstate/mempack.go">

<violation number="1" location="ledgerstate/mempack.go:629">
P2: Trimming trailing null bytes from the last asset name could silently truncate legitimate asset names that end with `0x00`. Cardano asset names are arbitrary byte sequences and may contain null bytes.

Consider computing the actual end of Region E from the known region sizes (quantities + offsets + policy IDs) and the total flat buffer length, then determining the padding size from word-alignment math rather than stripping nulls.</violation>
</file>

<file name="ledgerstate/snapshot.go">

<violation number="1" location="ledgerstate/snapshot.go:76">
P2: Silent `strconv.ParseUint` error handling in sort comparisons: non-numeric names would parse as slot 0, potentially selecting the wrong file. Consider logging a warning or pre-filtering non-numeric names before sorting.</violation>
</file>

<file name="ledgerstate/cbor_decode.go">

<violation number="1" location="ledgerstate/cbor_decode.go:263">
P2: Integer overflow: `hLen + int(arg)` can wrap negative when `arg` is near `math.MaxInt`. Although `arg` is bounded by `math.MaxInt`, adding `hLen` (1–9) to `int(arg)` can still overflow, producing a negative `size` that passes the `size > len(data)` guard and causes panics in upstream slice operations.</violation>
</file>

<file name="ledgerstate/import.go">

<violation number="1" location="ledgerstate/import.go:1006">
P1: `resolveEraParams` silently returns `(0, 0)` for the last era when `CardanoNodeConfig` is nil or `EpochLengthFunc` fails, because `EraBound{}` is passed as `endBound`. This causes all last-era epochs to be stored with the same start slot and zero-length, corrupting the slot clock. Consider returning an error or at least logging a warning when the epoch length can't be resolved for a non-empty epoch range.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

// Prefer UTxO-HD format (newer)
if len(utxoHDDirs) > 0 {
slices.SortFunc(utxoHDDirs, func(a, b string) int {
na, _ := strconv.ParseUint(a, 10, 64)
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Silent strconv.ParseUint error handling in sort comparisons: non-numeric names would parse as slot 0, potentially selecting the wrong file. Consider logging a warning or pre-filtering non-numeric names before sorting.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/snapshot.go, line 76:

<comment>Silent `strconv.ParseUint` error handling in sort comparisons: non-numeric names would parse as slot 0, potentially selecting the wrong file. Consider logging a warning or pre-filtering non-numeric names before sorting.</comment>

<file context>
@@ -0,0 +1,753 @@
+	// Prefer UTxO-HD format (newer)
+	if len(utxoHDDirs) > 0 {
+		slices.SortFunc(utxoHDDirs, func(a, b string) int {
+			na, _ := strconv.ParseUint(a, 10, 64)
+			nb, _ := strconv.ParseUint(b, 10, 64)
+			if na > nb {
</file context>
Fix with Cubic

)
}
// #nosec G115
size := hLen + int(arg)
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Integer overflow: hLen + int(arg) can wrap negative when arg is near math.MaxInt. Although arg is bounded by math.MaxInt, adding hLen (1–9) to int(arg) can still overflow, producing a negative size that passes the size > len(data) guard and causes panics in upstream slice operations.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/cbor_decode.go, line 263:

<comment>Integer overflow: `hLen + int(arg)` can wrap negative when `arg` is near `math.MaxInt`. Although `arg` is bounded by `math.MaxInt`, adding `hLen` (1–9) to `int(arg)` can still overflow, producing a negative `size` that passes the `size > len(data)` guard and causes panics in upstream slice operations.</comment>

<file context>
@@ -0,0 +1,431 @@
+			)
+		}
+		// #nosec G115
+		size := hLen + int(arg)
+		if size > len(data) {
+			return 0, fmt.Errorf(
</file context>
Fix with Cubic

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@chain/chain.go`:
- Around line 336-359: AddRawBlocks currently returns raw errors from txn.Do
which loses batch context; update the error handling after calling txn.Do in
AddRawBlocks to wrap the returned error using fmt.Errorf and %w, including
identifying batch context (e.g., batchOffset and batchSize or the batch range)
in the message so callers can see which block batch failed; ensure the wrapped
error is returned in place of the raw err from txn.Do.
- Around line 259-289: Add guards in Chain.AddRawBlock to validate RawBlock
fields before doing header/tip comparisons: ensure rb.Hash is non-empty
(len(rb.Hash) > 0) before comparing to c.headers[0].Hash(), and when
c.tipBlockIndex >= initialBlockIndex ensure rb.PrevHash is non-empty before
comparing to c.currentTip.Point.Hash; return a clear error (e.g., a new
invalid-block error) if either hash is empty so we never proceed with malformed
blocks into the chain state.

In `@database/plugin/metadata/sqlite/import.go`:
- Around line 128-136: The OnConflict{DoNothing: true} used in the Create(reg)
call (db.Clauses(clause.OnConflict{DoNothing: true}).Create(reg)) is ineffective
because PoolRegistration lacks a unique constraint; either add a DB-level unique
constraint to the PoolRegistration model (e.g., a composite unique index on
PoolID + CertificateID or whatever fields uniquely identify a registration) and
run the migration so the OnConflict works, or remove the misleading OnConflict
clause and rely solely on ImportCheckpoint for idempotency; update the
PoolRegistration model definition and migration code (or update the
import-create code to drop the OnConflict) so behavior is correct and not
silently allowing duplicates.

In `@ledgerstate/cbor_decode.go`:
- Around line 272-342: The array and map handling in cborItemSize currently
casts arg (from cborArgument) to int without bounds checking, which can wrap for
extremely large CBOR lengths; before casting arg to int in both the array (case
4) and map (case 5) non-indefinite branches, validate that arg <= MaxInt (the
same guard used for strings) and return an error if it exceeds MaxInt to prevent
overflow and unsafe slicing when iterating with the loop that calls
cborItemSize; update the checks immediately after retrieving arg, hLen, err from
cborArgument.
🧹 Nitpick comments (7)
README.md (2)

35-36: Fix the awkward sentence transition.

Line 35 ends with a colon suggesting a continuation, but line 36 starts a new sentence about environment variables. The transition is unclear — what does "This behavior" refer to?

✍️ Proposed fix for clarity
-A sample configuration file is provided at `dingo.yaml.example`. You can copy and edit this file to configure Dingo for your local or production environment:
-This behavior can be changed via the following environment variables:
+A sample configuration file is provided at `dingo.yaml.example`. You can copy and edit this file to configure Dingo for your local or production environment.
+
+Dingo's behavior can be modified via the following environment variables:

119-126: Clarify that performance timings are approximate.

The performance table shows specific timing values that will vary based on hardware, network conditions, and snapshot age. Consider adding a note that these are example/approximate values to set expectations appropriately.

📊 Suggested clarification
-**Performance** (preview network, ~4M blocks):
+**Performance** (example timings for preview network, ~4M blocks):

Or add a note after the table:

 | **Total** | **~50 min** |
+
+*Note: Timings are approximate and will vary based on hardware, network speed, and snapshot size.*
internal/node/load.go (2)

160-183: Minor timer leak with time.After.

time.After creates a timer that isn't garbage collected until it fires. If the function returns successfully before the 30-minute timeout, the timer continues to exist. For a single long-running operation this is typically acceptable, but using time.NewTimer with a deferred stop is more resource-efficient.

♻️ Suggested improvement
-	timeout := time.After(30 * time.Minute)
+	timeout := time.NewTimer(30 * time.Minute)
+	defer timeout.Stop()
 	for {
 		select {
 		case <-ticker.C:
 			tip := ls.Tip()
 			if tip.Point.Slot >= immutableTipSlot {
 				logger.Info(
 					"finished processing blocks from immutable DB",
 					"blocks_copied", blocksCopied,
 				)
 				return nil
 			}
-		case <-timeout:
+		case <-timeout.C:
 			return fmt.Errorf(

203-239: Consider extracting common database setup logic.

The database initialization and bulk-load optimization setup (lines 203-239) duplicates the pattern in LoadWithDB (lines 76-111). While acceptable given the different return types, extracting this to a helper could reduce duplication.

ledgerstate/import_test.go (1)

17-49: Add a timeout to avoid hanging integration runs.

Using context.WithTimeout keeps CI from stalling on network issues.

💡 Suggested change
 import (
 	"context"
 	"fmt"
 	"log/slog"
 	"os"
 	"testing"
+	"time"
@@
-	ctx := context.Background()
+	ctx, cancel := context.WithTimeout(
+		context.Background(), 30*time.Minute,
+	)
+	t.Cleanup(cancel)
cmd/dingo/mithril.go (1)

387-404: Consider using a constant for humanBytes thresholds.

The implementation is correct. Minor suggestion: extracting the unit constants (KB, MB, GB) to package level would improve reusability if this utility is needed elsewhere.

mithril/download.go (1)

150-153: Consider adding a configurable timeout for connection establishment.

The http.Client has Timeout: 0 for large downloads (correct), but this also disables connection timeout. For very slow or unresponsive servers, the download could hang indefinitely waiting to connect.

💡 Optional enhancement

Consider using a custom Transport with DialContext timeout while keeping the overall request timeout at 0:

client := &http.Client{
    Timeout:       0, // No timeout for large downloads
    CheckRedirect: httpsOnlyRedirect,
    Transport: &http.Transport{
        DialContext: (&net.Dialer{
            Timeout: 30 * time.Second,
        }).DialContext,
    },
}

chain/chain.go Outdated
Comment on lines 259 to 289
func (c *Chain) AddRawBlock(rb RawBlock, txn *database.Txn) error {
if c == nil {
return errors.New("chain is nil")
}
c.mutex.Lock()
defer c.mutex.Unlock()
c.manager.mutex.Lock()
defer c.manager.mutex.Unlock()
if err := c.reconcile(); err != nil {
return fmt.Errorf("reconcile: %w", err)
}
// Check that the new block matches our first header, if any
if len(c.headers) > 0 {
firstHeader := c.headers[0]
if string(rb.Hash) != string(firstHeader.Hash().Bytes()) {
return NewBlockNotMatchHeaderError(
hex.EncodeToString(rb.Hash),
firstHeader.Hash().String(),
)
}
}
// Check that this block fits on the current chain tip
if c.tipBlockIndex >= initialBlockIndex {
if string(rb.PrevHash) != string(c.currentTip.Point.Hash) {
return NewBlockNotFitChainTipError(
hex.EncodeToString(rb.Hash),
hex.EncodeToString(rb.PrevHash),
hex.EncodeToString(c.currentTip.Point.Hash),
)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate RawBlock hashes before insertion to avoid corrupt chain state.

AddRawBlock accepts raw slices; with an empty Hash (or empty PrevHash when a prior tip/header exists), an invalid block can be persisted and later chain operations may misbehave. Add minimal guards before the header/tip checks.

🔧 Suggested guard rails
 if err := c.reconcile(); err != nil {
 	return fmt.Errorf("reconcile: %w", err)
 }
+if len(rb.Hash) == 0 {
+	return errors.New("raw block hash is empty")
+}
+if (c.tipBlockIndex >= initialBlockIndex || len(c.headers) > 0) && len(rb.PrevHash) == 0 {
+	return errors.New("raw block prev hash is empty")
+}
 // Check that the new block matches our first header, if any
 if len(c.headers) > 0 {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (c *Chain) AddRawBlock(rb RawBlock, txn *database.Txn) error {
if c == nil {
return errors.New("chain is nil")
}
c.mutex.Lock()
defer c.mutex.Unlock()
c.manager.mutex.Lock()
defer c.manager.mutex.Unlock()
if err := c.reconcile(); err != nil {
return fmt.Errorf("reconcile: %w", err)
}
// Check that the new block matches our first header, if any
if len(c.headers) > 0 {
firstHeader := c.headers[0]
if string(rb.Hash) != string(firstHeader.Hash().Bytes()) {
return NewBlockNotMatchHeaderError(
hex.EncodeToString(rb.Hash),
firstHeader.Hash().String(),
)
}
}
// Check that this block fits on the current chain tip
if c.tipBlockIndex >= initialBlockIndex {
if string(rb.PrevHash) != string(c.currentTip.Point.Hash) {
return NewBlockNotFitChainTipError(
hex.EncodeToString(rb.Hash),
hex.EncodeToString(rb.PrevHash),
hex.EncodeToString(c.currentTip.Point.Hash),
)
}
}
func (c *Chain) AddRawBlock(rb RawBlock, txn *database.Txn) error {
if c == nil {
return errors.New("chain is nil")
}
c.mutex.Lock()
defer c.mutex.Unlock()
c.manager.mutex.Lock()
defer c.manager.mutex.Unlock()
if err := c.reconcile(); err != nil {
return fmt.Errorf("reconcile: %w", err)
}
if len(rb.Hash) == 0 {
return errors.New("raw block hash is empty")
}
if (c.tipBlockIndex >= initialBlockIndex || len(c.headers) > 0) && len(rb.PrevHash) == 0 {
return errors.New("raw block prev hash is empty")
}
// Check that the new block matches our first header, if any
if len(c.headers) > 0 {
firstHeader := c.headers[0]
if string(rb.Hash) != string(firstHeader.Hash().Bytes()) {
return NewBlockNotMatchHeaderError(
hex.EncodeToString(rb.Hash),
firstHeader.Hash().String(),
)
}
}
// Check that this block fits on the current chain tip
if c.tipBlockIndex >= initialBlockIndex {
if string(rb.PrevHash) != string(c.currentTip.Point.Hash) {
return NewBlockNotFitChainTipError(
hex.EncodeToString(rb.Hash),
hex.EncodeToString(rb.PrevHash),
hex.EncodeToString(c.currentTip.Point.Hash),
)
}
}
🤖 Prompt for AI Agents
In `@chain/chain.go` around lines 259 - 289, Add guards in Chain.AddRawBlock to
validate RawBlock fields before doing header/tip comparisons: ensure rb.Hash is
non-empty (len(rb.Hash) > 0) before comparing to c.headers[0].Hash(), and when
c.tipBlockIndex >= initialBlockIndex ensure rb.PrevHash is non-empty before
comparing to c.currentTip.Point.Hash; return a clear error (e.g., a new
invalid-block error) if either hash is empty so we never proceed with malformed
blocks into the chain state.

Comment on lines 336 to 359
// AddRawBlocks adds a batch of pre-extracted blocks to the chain.
func (c *Chain) AddRawBlocks(blocks []RawBlock) error {
if c == nil {
return errors.New("chain is nil")
}
batchOffset := 0
for {
batchSize := min(50, len(blocks)-batchOffset)
if batchSize == 0 {
break
}
txn := c.manager.db.BlobTxn(true)
err := txn.Do(func(txn *database.Txn) error {
for _, rb := range blocks[batchOffset : batchOffset+batchSize] {
if err := c.AddRawBlock(rb, txn); err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
batchOffset += batchSize
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Wrap batch errors with context.

AddRawBlocks returns raw errors from txn.Do, which drops batch context. Wrap the error for easier triage.

🔧 Suggested fix
 if err != nil {
-	return err
+	return fmt.Errorf("add raw blocks batch starting at %d: %w", batchOffset, err)
 }

As per coding guidelines, Wrap errors with context using fmt.Errorf with %w verb.

🤖 Prompt for AI Agents
In `@chain/chain.go` around lines 336 - 359, AddRawBlocks currently returns raw
errors from txn.Do which loses batch context; update the error handling after
calling txn.Do in AddRawBlocks to wrap the returned error using fmt.Errorf and
%w, including identifying batch context (e.g., batchOffset and batchSize or the
batch range) in the message so callers can see which block batch failed; ensure
the wrapped error is returned in place of the raw err from txn.Do.

Comment on lines +128 to +136
// Create registration record (ignore duplicates on retry)
if result := db.Clauses(clause.OnConflict{
DoNothing: true,
}).Create(reg); result.Error != nil {
return fmt.Errorf(
"import pool registration: %w",
result.Error,
)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

rg -A 30 "type PoolRegistration struct" --type go

Repository: blinklabs-io/dingo

Length of output: 1853


🏁 Script executed:

# Search for pool_registration table creation/migration
fd . database/ -type f -name "*.go" | xargs rg "pool_registration" -l | head -20

Repository: blinklabs-io/dingo

Length of output: 703


🏁 Script executed:

# Check ImportCheckpoint usage
rg -B 5 -A 10 "ImportCheckpoint" database/plugin/metadata/sqlite/import.go

Repository: blinklabs-io/dingo

Length of output: 1088


🏁 Script executed:

# Check import.go around the PoolRegistration create to understand the flow
rg -B 15 -A 5 "Create pool registration record\|Create\(reg\)" database/plugin/metadata/sqlite/import.go

Repository: blinklabs-io/dingo

Length of output: 44


🏁 Script executed:

# Look for migrations or schema definitions for pool_registration
rg -l "pool_registration\|PoolRegistration" database/ | xargs rg "CREATE TABLE\|unique\|UniqueIndex" -B 2 -A 5

Repository: blinklabs-io/dingo

Length of output: 46


🏁 Script executed:

# View the import.go file around line 128
sed -n '100,150p' database/plugin/metadata/sqlite/import.go

Repository: blinklabs-io/dingo

Length of output: 1254


🏁 Script executed:

# Search for migration files
find database -type f -name "*migrat*" -o -name "*schema*" | head -20

Repository: blinklabs-io/dingo

Length of output: 44


🏁 Script executed:

# Search for AutoMigrate or table creation
rg "AutoMigrate|CreateTable" database/ -A 3

Repository: blinklabs-io/dingo

Length of output: 14241


🏁 Script executed:

# Check the Pool model for unique constraints
rg -A 40 "type Pool struct" database/models/pool.go

Repository: blinklabs-io/dingo

Length of output: 1695


🏁 Script executed:

# Get the complete PoolRegistration struct definition including all fields and tags
sed -n '27,55p' database/models/pool.go

Repository: blinklabs-io/dingo

Length of output: 1198


🏁 Script executed:

# Get the complete PoolRegistration struct - more lines
sed -n '47,75p' database/models/pool.go

Repository: blinklabs-io/dingo

Length of output: 1057


OnConflict{DoNothing: true} will not prevent duplicates without a unique constraint on PoolRegistration.

The PoolRegistration model has no unique constraints—only an auto-incrementing ID and several regular indexes. GORM's OnConflict{DoNothing: true} requires a unique constraint to detect conflicts. As currently written, the code silently fails to prevent duplicates on retry.

Idempotency is genuinely provided by ImportCheckpoint tracking import progress. If database-level duplicate prevention is needed, add a unique constraint (e.g., composite index on PoolID + CertificateID or other identifying fields) to PoolRegistration. Otherwise, remove the misleading OnConflict clause and rely solely on ImportCheckpoint.

🤖 Prompt for AI Agents
In `@database/plugin/metadata/sqlite/import.go` around lines 128 - 136, The
OnConflict{DoNothing: true} used in the Create(reg) call
(db.Clauses(clause.OnConflict{DoNothing: true}).Create(reg)) is ineffective
because PoolRegistration lacks a unique constraint; either add a DB-level unique
constraint to the PoolRegistration model (e.g., a composite unique index on
PoolID + CertificateID or whatever fields uniquely identify a registration) and
run the migration so the OnConflict works, or remove the misleading OnConflict
clause and rely solely on ImportCheckpoint for idempotency; update the
PoolRegistration model definition and migration code (or update the
import-create code to drop the OnConflict) so behavior is correct and not
silently allowing duplicates.

@wolf31o2 wolf31o2 force-pushed the feat/mithril-bootstrap branch 2 times, most recently from 92b9af7 to e5417dc Compare February 14, 2026 22:40
@wolf31o2
Copy link
Member Author

@cubic-dev-ai review

@cubic-dev-ai
Copy link

cubic-dev-ai bot commented Feb 14, 2026

@cubic-dev-ai review

@wolf31o2 I have started the AI code review. It will take a few minutes to complete.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Fix all issues with AI agents
In `@internal/node/load.go`:
- Around line 392-398: The skip logic in copyBlocks currently compares next.Slot
(ImmutableDB metadata) to chainTip.Point.Slot which can diverge from the decoded
block header slot used elsewhere (tmpBlock.SlotNumber()); change the check to
decode the first block's header (or reuse the already-decoded tmpBlock/header)
and compare header.SlotNumber() (or tmpBlock.SlotNumber()) against
chainTip.Point.Slot so both resume paths use the same source of truth; update
the code around the next == nil / blocksCopied == 0 branch in copyBlocks to
perform the header decode prior to the skip-check and use header.SlotNumber()
instead of next.Slot to decide whether to continue.

In `@ledgerstate/govstate.go`:
- Around line 300-340: parseProposals currently returns nil, nil when the
decoded container has fewer than 2 elements which can be ambiguous; change this
to return an empty []ParsedGovProposal (not nil) and nil error for the "no
proposals" case so callers get an empty slice instead of nil, and add a short
comment noting that a single-element container is treated as "no OMap -> no
proposals"; keep the existing error return path for actual decode failures and
leave errors.Join(propErrs...) as-is.

In `@ledgerstate/utxo.go`:
- Around line 126-132: The current fallback treats any byte slice with len(data)
>= 34 as a valid binary "hash + LE index", which can silently mis-parse
malformed CBOR; update the fallback in ledgerstate/utxo.go so it only accepts
the binary path when the remaining bytes after the initial 34 are empty (i.e.,
len(data) == 34) or pass an additional sanity check (for example verify the
32-byte txHash is not all-zero or contains expected entropy) and otherwise
return an error (or log a warning) instead of silently returning garbage; locate
the block using the variables/data sequence (data, txHash, idx) and change the
condition and add a warning/error path when extra/unexpected bytes or
implausible txHash are present.
- Around line 347-412: parseCborTxOut currently only extracts DatumHash via a
type assertion and omits inline Datum and ScriptRef; update parseCborTxOut to
also detect and extract inline datum and reference script similar to
parseMempackTxOut by using type assertions on txOut for Datum() and ScriptRef()
methods (in addition to the existing DatumHash() assertion), setting
ParsedUTxO.Datum with the decoded datum bytes when Datum() returns non-nil and
setting ParsedUTxO.ScriptRef when ScriptRef() returns a non-nil reference (use
the same byte extraction/limits logic used for DatumHash and other fields to
avoid huge allocations), ensuring you reference the txOut variable, the
ParsedUTxO struct fields (Datum, DatumHash, ScriptRef), and the
Datum()/ScriptRef()/DatumHash() method names when making the changes.

In `@mithril/bootstrap.go`:
- Around line 259-291: The code skips downloading when
hasLedgerFiles(candidateDir) is true but never sets ancillaryArchivePath, so
Cleanup() can't remove a leftover archive; update the branch for the
already-extracted path to locate and assign the existing ancillary archive path
(ancillaryArchivePath) so Cleanup can remove it — e.g., add logic in the
hasLedgerFiles true branch to call a new helper (or reuse logic from
downloadAncillary) to find the existing archive file and set
ancillaryArchivePath, or change hasLedgerFiles/downloadAncillary to return the
archive path alongside the extraction check; ensure you reference
ancillaryArchivePath, hasLedgerFiles, downloadAncillary and Cleanup in the fix.

In `@mithril/download.go`:
- Around line 166-176: When handling the http.StatusOK branch in the download
switch, reset existingSize to 0 before creating/truncating the destination file
so the progressWriter (constructed later with written: existingSize and
startOffset: existingSize) doesn't carry a stale partial-file size; update the
http.StatusOK case in the function that checks resp.StatusCode (references:
resp.StatusCode, http.StatusOK, existingSize, os.Create, destPath,
progressWriter, BytesDownloaded, startOffset) to set existingSize = 0 so
progress callbacks report correct bytes and percent.

In `@README.md`:
- Around line 35-36: The sentence "This behavior can be changed via the
following environment variables:" is misplaced immediately after the sentence
introducing `dingo.yaml.example` and breaks the text flow; fix by either moving
that sentence to follow the environment variables list or insert a paragraph
break so the config-file introduction and the env-var note are separate—update
the README so the sentence follows the relevant env var section or is separated
into its own paragraph for clear context.
🧹 Nitpick comments (17)
chainselection/selector.go (1)

224-238: PreviousTip omitted from ChainSwitchEvent in RemovePeer — intentional but worth noting.

When the best peer disconnects, it's already removed from peerTips (line 205) before the event is constructed, so the previous tip is unavailable. This means PreviousTip will be the zero value. If subscribers rely on PreviousTip for anything meaningful (e.g., logging the slot delta), they'll see misleading data. Consider capturing the previous peer's tip before the delete on line 205 if this matters to consumers.

internal/node/load.go (2)

160-183: 30-minute timeout for ledger catch-up may be insufficient for mainnet.

For a full mainnet bootstrap (millions of blocks), the ledger processing pipeline could take significantly longer than 30 minutes to consume all queued chain events. Consider making this timeout configurable or scaling it based on the number of blocks copied.


197-261: DB config construction is duplicated between LoadWithDB and LoadBlobsWithDB.

Lines 206–213 repeat the exact same database.Config setup as lines 78–85. Consider extracting a small helper to reduce drift risk.

ledgerstate/snapshots.go (1)

302-358: Consider sorting the output for deterministic results.

AggregatePoolStake iterates poolMap (a Go map) which has non-deterministic iteration order. While this likely doesn't affect correctness (downstream code presumably handles unordered slices), deterministic output simplifies debugging and testing.

ledgerstate/snapshot.go (3)

270-277: Silently discarding era-bounds extraction error.

boundsErr is explicitly suppressed with _ = boundsErr. If bounds extraction fails, EraBounds will be nil, and the comment notes a fallback to "single-epoch path." However, since incorrect epoch history can affect SlotToTime calculations and downstream functionality, consider returning the error as a warning (similar to the warnings pattern in snapshots.go) or adding eraBoundsErr to RawLedgerState so callers can assess the quality of the parsed result.


362-388: Version-peeling loop is well-designed but could benefit from a depth limit.

The loop correctly handles nested version wrappers ([v1, [v2, [tip, NES, ...]]]) and flat wrappers ([v, tip, NES, ...]). In practice, Cardano snapshots have at most 1-2 version layers. Consider adding a depth counter (e.g., max 5 iterations) as a defensive measure against pathological input causing excessive recursion.


696-709: Same version-peeling pattern for PraosState — consider extracting a shared helper.

This loop mirrors the version-unwrapping logic in parseCurrentEra (lines 362-388). A shared helper like peelVersionWrappers(data [][]byte) [][]byte could reduce duplication and apply a consistent depth limit.

ledgerstate/import_test.go (1)

156-175: Minor: lastProgress capture may have a data race if OnProgress is called concurrently.

lastProgress = p (Line 165) is a plain struct assignment in a closure. If ImportLedgerState ever calls OnProgress from a background goroutine, this is a race with the read at Line 175. Currently this is likely synchronous, but consider using atomic or a mutex for safety, or documenting the synchronous contract.

cmd/dingo/serve.go (2)

46-72: Swallowing all database.New errors may mask real problems.

Line 57-60: any database.New failure (permissions, corrupt WAL, disk full, etc.) is treated as "fresh start" and silently ignored. The node would then proceed to node.Run, which likely opens the same DB and fails with a less actionable error.

Consider distinguishing "database doesn't exist" from other errors, or at least logging a warning.

♻️ Suggested improvement
 	db, err := database.New(&database.Config{
 		DataDir:        cfg.DatabasePath,
 		Logger:         logger,
 		BlobPlugin:     cfg.BlobPlugin,
 		MetadataPlugin: cfg.MetadataPlugin,
 		MaxConnections: 1,
 	})
 	if err != nil {
-		// Database may not exist yet (fresh start) — that's fine.
+		// Database may not exist yet (fresh start).
+		// Log at debug level so real errors are not completely invisible.
+		logger.Debug(
+			"could not open database for sync check",
+			"error", err,
+		)
 		return nil
 	}

63-66: GetSyncState error is also silently swallowed.

Line 64 treats any error from GetSyncState the same as "no entry found." If the sync_state table exists but the query fails for a real reason (e.g., I/O error), the node starts without the safety check. The same debug-log suggestion applies here.

ledgerstate/import.go (1)

498-543: Single transaction for all accounts may strain the database on mainnet.

importAccounts opens one transaction and inserts every account serially. On mainnet there can be millions of stake accounts, which means one very long-lived write transaction. The UTxO path already demonstrates a good batching pattern with per-batch commits. Consider applying a similar batched-commit approach here (and in importPools/importDReps) to reduce memory pressure and lock duration.

mithril/types.go (1)

31-65: SnapshotListItem and Snapshot are structurally identical.

These two types share every field. If the Mithril API guarantees the same shape for both endpoints, consider embedding a shared struct or using a type alias to reduce duplication. That said, keeping them separate is defensible if you expect the schemas to diverge.

cmd/dingo/mithril.go (1)

638-758: fetchGapBlocks only tries the first bootstrap peer with no retry.

If the first (and only) bootstrap peer is unreachable or stalls, the gap-block fetch fails the entire sync. Consider iterating over netInfo.BootstrapPeers with a fallback, or at minimum documenting this limitation.

database/models/import_checkpoint.go (1)

41-48: Exported ImportPhaseOrder slice is mutable — consider making it unexported or a function.

Any caller can accidentally (or intentionally) mutate ImportPhaseOrder via append or index assignment, which would corrupt phase ordering globally. A safer pattern is to either unexport it or return a copy from a function.

♻️ Suggested change
-// ImportPhaseOrder defines the sequential order of import phases.
-var ImportPhaseOrder = []string{
+// importPhaseOrder defines the sequential order of import phases.
+var importPhaseOrder = []string{
 	ImportPhaseUTxO,
 	ImportPhaseCertState,
 	ImportPhaseSnapshots,
 	ImportPhasePParams,
 	ImportPhaseGovState,
 	ImportPhaseTip,
 }
+
+// ImportPhaseOrder returns the sequential order of import phases.
+func ImportPhaseOrder() []string {
+	out := make([]string, len(importPhaseOrder))
+	copy(out, importPhaseOrder)
+	return out
+}
ledgerstate/utxo.go (1)

174-262: Duplicated batch-processing logic between ParseUTxOsStreaming and parseIndefiniteUTxOMap.

The batch accumulation, callback invocation, and final flush pattern (lines 201–259 and 505–572) are nearly identical. Consider extracting a shared helper to reduce duplication.

♻️ Sketch of a shared helper
// batchProcessor handles batch accumulation and callback.
type batchProcessor struct {
	batch    []ParsedUTxO
	callback UTxOCallback
	total    int
}

func (bp *batchProcessor) add(u ParsedUTxO) error {
	bp.batch = append(bp.batch, u)
	bp.total++
	if len(bp.batch) >= utxoBatchSize {
		if err := bp.callback(bp.batch); err != nil {
			return fmt.Errorf(
				"UTxO callback error at entry %d: %w",
				bp.total, err,
			)
		}
		bp.batch = bp.batch[:0]
	}
	return nil
}

func (bp *batchProcessor) flush() error {
	if len(bp.batch) > 0 {
		if err := bp.callback(bp.batch); err != nil {
			return fmt.Errorf(
				"UTxO callback error at final batch: %w",
				err,
			)
		}
	}
	return nil
}

Also applies to: 488-575

database/plugin/metadata/store.go (1)

613-626: Consider naming the string parameters for readability.

SetSyncState(string, string, types.Txn) — it's unclear from the signature alone which string is the key vs. value. While other methods in this interface also use bare types, the two adjacent string params make this particularly easy to mix up.

♻️ Suggested change
-	GetSyncState(string, types.Txn) (string, error)
-	SetSyncState(string, string, types.Txn) error
-	DeleteSyncState(string, types.Txn) error
+	GetSyncState(key string, txn types.Txn) (string, error)
+	SetSyncState(key string, value string, txn types.Txn) error
+	DeleteSyncState(key string, txn types.Txn) error
mithril/bootstrap.go (1)

82-101: Missing validation for cfg.Network when AggregatorURL is also empty.

If both cfg.Network and cfg.AggregatorURL are empty, AggregatorURLForNetwork("") will fail with a somewhat opaque error. Consider validating the config upfront.

♻️ Suggested early validation
 func Bootstrap(
 	ctx context.Context,
 	cfg BootstrapConfig,
 ) (*BootstrapResult, error) {
 	if cfg.Logger == nil {
 		cfg.Logger = slog.Default()
 	}
+	if cfg.Network == "" && cfg.AggregatorURL == "" {
+		return nil, errors.New(
+			"either Network or AggregatorURL must be set",
+		)
+	}
 
 	// Resolve aggregator URL

Comment on lines 300 to 340
func parseProposals(data []byte) (
[]ParsedGovProposal, error,
) {
container, err := decodeRawArray(data)
if err != nil {
return nil, fmt.Errorf(
"decoding proposals container: %w", err,
)
}
if len(container) < 2 {
// No proposals or unexpected format
return nil, nil
}

// The OMap is the second element: [roots, omap].
// OMap encodes as a flat array of GovActionState values.
proposalSeq := container[1]

items, err := decodeRawArray(proposalSeq)
if err != nil {
return nil, fmt.Errorf(
"decoding proposals OMap: %w", err,
)
}

proposals := make([]ParsedGovProposal, 0, len(items))
var propErrs []error
for _, item := range items {
prop, err := parseGovActionState(item)
if err != nil {
propErrs = append(propErrs, err)
continue
}
if prop != nil {
proposals = append(proposals, *prop)
}
}

// Return parsed proposals even if some failed
return proposals, errors.Join(propErrs...)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

parseProposals returns nil, nil for containers with fewer than 2 elements.

A 1-element container (roots without OMap) would silently succeed with no proposals. If this is expected for empty proposal sets, it's fine. If a 1-element container indicates corruption, a warning might be useful.

🤖 Prompt for AI Agents
In `@ledgerstate/govstate.go` around lines 300 - 340, parseProposals currently
returns nil, nil when the decoded container has fewer than 2 elements which can
be ambiguous; change this to return an empty []ParsedGovProposal (not nil) and
nil error for the "no proposals" case so callers get an empty slice instead of
nil, and add a short comment noting that a single-element container is treated
as "no OMap -> no proposals"; keep the existing error return path for actual
decode failures and leave errors.Join(propErrs...) as-is.

Comment on lines 126 to 132
// Fall back to binary format for other lengths
if len(data) >= 34 {
txHash := make([]byte, 32)
copy(txHash, data[:32])
idx := uint32(data[32]) | uint32(data[33])<<8
return txHash, idx, nil
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Binary fallback for len(data) >= 34 may silently mis-parse non-binary data.

After the CBOR array decode attempt fails, any byte slice ≥ 34 bytes is interpreted as binary hash + LE index. If the data is actually malformed CBOR (not binary MemPack), this will silently produce a garbage TxHash and output index without any error.

Consider logging a warning or only accepting this path if there's additional validation (e.g., checking that remaining bytes after 34 are empty, or that the resulting hash looks plausible).

🤖 Prompt for AI Agents
In `@ledgerstate/utxo.go` around lines 126 - 132, The current fallback treats any
byte slice with len(data) >= 34 as a valid binary "hash + LE index", which can
silently mis-parse malformed CBOR; update the fallback in ledgerstate/utxo.go so
it only accepts the binary path when the remaining bytes after the initial 34
are empty (i.e., len(data) == 34) or pass an additional sanity check (for
example verify the 32-byte txHash is not all-zero or contains expected entropy)
and otherwise return an error (or log a warning) instead of silently returning
garbage; locate the block using the variables/data sequence (data, txHash, idx)
and change the condition and add a warning/error path when extra/unexpected
bytes or implausible txHash are present.

Comment on lines +35 to 36
A sample configuration file is provided at `dingo.yaml.example`. You can copy and edit this file to configure Dingo for your local or production environment:
This behavior can be changed via the following environment variables:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor text flow issue.

Line 35 introduces the sample config file, but line 36 starts with "This behavior can be changed..." which originally referred to the preceding paragraph about YAML config and env vars. The new sentence creates a disconnect. Consider moving it after the environment variables list, or adding a paragraph break.

🤖 Prompt for AI Agents
In `@README.md` around lines 35 - 36, The sentence "This behavior can be changed
via the following environment variables:" is misplaced immediately after the
sentence introducing `dingo.yaml.example` and breaks the text flow; fix by
either moving that sentence to follow the environment variables list or insert a
paragraph break so the config-file introduction and the env-var note are
separate—update the README so the sentence follows the relevant env var section
or is separated into its own paragraph for clear context.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8 issues found across 58 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="cmd/dingo/mithril.go">

<violation number="1" location="cmd/dingo/mithril.go:1000">
P1: Bug: `seenCbor` map is scoped per-transaction but `findNthOccurrence` searches the entire block CBOR. If two transactions in the same block produce outputs with identical CBOR, the counter resets and both will resolve to the same byte position (the first occurrence), producing an incorrect offset for the second transaction's output. Move `seenCbor` declaration before the transaction loop so it accumulates across all transactions in the block.</violation>
</file>

<file name="ledgerstate/import.go">

<violation number="1" location="ledgerstate/import.go:516">
P2: Unlike `importUTxOs` which streams records in batches, `importAccounts` processes all accounts (potentially ~1.4M on mainnet) in a single database transaction. Consider adding batching similar to the UTxO import to reduce memory pressure and transaction lock duration.</violation>

<violation number="2" location="ledgerstate/import.go:1324">
P2: Duplicate detection in `isEpochDuplicate` uses fragile string matching against DB-driver error messages. Consider using `errors.Is(err, gorm.ErrDuplicatedKey)` for a driver-independent check that won't break if error message formats change.</violation>
</file>

<file name="mithril/bootstrap.go">

<violation number="1" location="mithril/bootstrap.go:240">
P2: Goroutine leak / hang risk: if main archive extraction fails, `defer ancWg.Wait()` blocks the return until the ancillary download goroutine finishes, but nothing cancels that download. This can cause the function to hang for a long time (minutes to hours depending on archive size). Create a cancellable derived context and cancel it on error so the goroutine's download aborts promptly.</violation>
</file>

<file name="database/plugin/metadata/mysql/import.go">

<violation number="1" location="database/plugin/metadata/mysql/import.go:127">
P2: Inconsistent with `ImportPool` pattern: `reg.DrepCredential` is not linked to `drep.Credential` before creating the registration. In `ImportPool`, `reg.PoolID` and related fields are explicitly set after the upsert. The same pattern should apply here to ensure the registration is always linked to the DRep, regardless of what the caller passes.</violation>
</file>

<file name="ledgerstate/mempack.go">

<violation number="1" location="ledgerstate/mempack.go:610">
P2: Silent failure on malformed data: if the flat buffer is shorter than expected (`remainder < 0`) or no padding value matches, `lastNameLen` silently defaults to 0 instead of returning an error. This could produce incorrect asset name data without any indication of corruption. Consider returning an error when `remainder < 0` or when no padding candidate matches.</violation>

<violation number="2" location="ledgerstate/mempack.go:672">
P1: Missing bounds check: `flat[e.nameOff:namesContentEnd]` can panic if `namesContentEnd > len(flat)` due to malformed data. The non-last-asset branch correctly checks `nameEnd <= len(flat)` before slicing, but this branch does not. Add a bounds check like `e.nameOff < namesContentEnd && namesContentEnd <= len(flat)` to match the defensive style of the other branch.</violation>
</file>

<file name="cmd/dingo/serve.go">

<violation number="1" location="cmd/dingo/serve.go:57">
P2: Ignoring database.New errors can leak a partially opened DB and skip the sync-state guard even when initialization failed for non-“missing DB” reasons. Handle the error and close any opened DB instead of returning nil.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

var txHashArr [32]byte
copy(txHashArr[:], txHash.Bytes())
produced := tx.Produced()
seenCbor := make(map[string]int)
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Bug: seenCbor map is scoped per-transaction but findNthOccurrence searches the entire block CBOR. If two transactions in the same block produce outputs with identical CBOR, the counter resets and both will resolve to the same byte position (the first occurrence), producing an incorrect offset for the second transaction's output. Move seenCbor declaration before the transaction loop so it accumulates across all transactions in the block.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/dingo/mithril.go, line 1000:

<comment>Bug: `seenCbor` map is scoped per-transaction but `findNthOccurrence` searches the entire block CBOR. If two transactions in the same block produce outputs with identical CBOR, the counter resets and both will resolve to the same byte position (the first occurrence), producing an incorrect offset for the second transaction's output. Move `seenCbor` declaration before the transaction loop so it accumulates across all transactions in the block.</comment>

<file context>
@@ -0,0 +1,1123 @@
+				var txHashArr [32]byte
+				copy(txHashArr[:], txHash.Bytes())
+				produced := tx.Produced()
+				seenCbor := make(map[string]int)
+				for _, utxo := range produced {
+					outCbor := utxo.Output.Cbor()
</file context>
Fix with Cubic

} else {
// Last asset: name extends to the computed
// end of names content (excluding padding).
if e.nameOff < namesContentEnd {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Missing bounds check: flat[e.nameOff:namesContentEnd] can panic if namesContentEnd > len(flat) due to malformed data. The non-last-asset branch correctly checks nameEnd <= len(flat) before slicing, but this branch does not. Add a bounds check like e.nameOff < namesContentEnd && namesContentEnd <= len(flat) to match the defensive style of the other branch.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/mempack.go, line 672:

<comment>Missing bounds check: `flat[e.nameOff:namesContentEnd]` can panic if `namesContentEnd > len(flat)` due to malformed data. The non-last-asset branch correctly checks `nameEnd <= len(flat)` before slicing, but this branch does not. Add a bounds check like `e.nameOff < namesContentEnd && namesContentEnd <= len(flat)` to match the defensive style of the other branch.</comment>

<file context>
@@ -0,0 +1,838 @@
+		} else {
+			// Last asset: name extends to the computed
+			// end of names content (excluding padding).
+			if e.nameOff < namesContentEnd {
+				nameLen := namesContentEnd - e.nameOff
+				if nameLen > 0 {
</file context>
Fix with Cubic

txn := cfg.Database.MetadataTxn(true)
defer txn.Release()

for _, acct := range accounts {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Unlike importUTxOs which streams records in batches, importAccounts processes all accounts (potentially ~1.4M on mainnet) in a single database transaction. Consider adding batching similar to the UTxO import to reduce memory pressure and transaction lock duration.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/import.go, line 516:

<comment>Unlike `importUTxOs` which streams records in batches, `importAccounts` processes all accounts (potentially ~1.4M on mainnet) in a single database transaction. Consider adding batching similar to the UTxO import to reduce memory pressure and transaction lock duration.</comment>

<file context>
@@ -0,0 +1,1337 @@
+	txn := cfg.Database.MetadataTxn(true)
+	defer txn.Release()
+
+	for _, acct := range accounts {
+		select {
+		case <-ctx.Done():
</file context>
Fix with Cubic

// idempotent epoch generation when resuming a partially
// completed import. Handles SQLite, MySQL, and PostgreSQL
// duplicate key error formats.
func isEpochDuplicate(err error) bool {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Duplicate detection in isEpochDuplicate uses fragile string matching against DB-driver error messages. Consider using errors.Is(err, gorm.ErrDuplicatedKey) for a driver-independent check that won't break if error message formats change.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/import.go, line 1324:

<comment>Duplicate detection in `isEpochDuplicate` uses fragile string matching against DB-driver error messages. Consider using `errors.Is(err, gorm.ErrDuplicatedKey)` for a driver-independent check that won't break if error message formats change.</comment>

<file context>
@@ -0,0 +1,1337 @@
+// idempotent epoch generation when resuming a partially
+// completed import. Handles SQLite, MySQL, and PostgreSQL
+// duplicate key error formats.
+func isEpochDuplicate(err error) bool {
+	if err == nil {
+		return false
</file context>
Fix with Cubic

// Always wait for the goroutine before returning, even on error,
// to prevent goroutine leaks and races with temp dir cleanup.
var ancWg sync.WaitGroup
defer ancWg.Wait()
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Goroutine leak / hang risk: if main archive extraction fails, defer ancWg.Wait() blocks the return until the ancillary download goroutine finishes, but nothing cancels that download. This can cause the function to hang for a long time (minutes to hours depending on archive size). Create a cancellable derived context and cancel it on error so the goroutine's download aborts promptly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mithril/bootstrap.go, line 240:

<comment>Goroutine leak / hang risk: if main archive extraction fails, `defer ancWg.Wait()` blocks the return until the ancillary download goroutine finishes, but nothing cancels that download. This can cause the function to hang for a long time (minutes to hours depending on archive size). Create a cancellable derived context and cancel it on error so the goroutine's download aborts promptly.</comment>

<file context>
@@ -0,0 +1,668 @@
+	// Always wait for the goroutine before returning, even on error,
+	// to prevent goroutine leaks and races with temp dir cleanup.
+	var ancWg sync.WaitGroup
+	defer ancWg.Wait()
+	if len(snapshot.AncillaryLocations) > 0 {
+		ancWg.Add(1)
</file context>
Fix with Cubic

reg.Relays[i].PoolID = pool.ID
}

// Create registration record. Idempotency is handled at the
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Inconsistent with ImportPool pattern: reg.DrepCredential is not linked to drep.Credential before creating the registration. In ImportPool, reg.PoolID and related fields are explicitly set after the upsert. The same pattern should apply here to ensure the registration is always linked to the DRep, regardless of what the caller passes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At database/plugin/metadata/mysql/import.go, line 127:

<comment>Inconsistent with `ImportPool` pattern: `reg.DrepCredential` is not linked to `drep.Credential` before creating the registration. In `ImportPool`, `reg.PoolID` and related fields are explicitly set after the upsert. The same pattern should apply here to ensure the registration is always linked to the DRep, regardless of what the caller passes.</comment>

<file context>
@@ -0,0 +1,230 @@
+		reg.Relays[i].PoolID = pool.ID
+	}
+
+	// Create registration record. Idempotency is handled at the
+	// application level via ImportCheckpoint, not by DB constraints.
+	if result := db.Create(reg); result.Error != nil {
</file context>
Fix with Cubic

numUniquePolicies := len(uniquePidOffs)
base := nameOffsetsEnd + numUniquePolicies*28 + knownNameBytes
remainder := len(flat) - base // = lastNameLen + padding
lastNameLen := 0
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Silent failure on malformed data: if the flat buffer is shorter than expected (remainder < 0) or no padding value matches, lastNameLen silently defaults to 0 instead of returning an error. This could produce incorrect asset name data without any indication of corruption. Consider returning an error when remainder < 0 or when no padding candidate matches.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/mempack.go, line 610:

<comment>Silent failure on malformed data: if the flat buffer is shorter than expected (`remainder < 0`) or no padding value matches, `lastNameLen` silently defaults to 0 instead of returning an error. This could produce incorrect asset name data without any indication of corruption. Consider returning an error when `remainder < 0` or when no padding candidate matches.</comment>

<file context>
@@ -0,0 +1,838 @@
+	numUniquePolicies := len(uniquePidOffs)
+	base := nameOffsetsEnd + numUniquePolicies*28 + knownNameBytes
+	remainder := len(flat) - base // = lastNameLen + padding
+	lastNameLen := 0
+	if numAssets > 0 && remainder > 0 {
+		for pad := range 8 {
</file context>
Fix with Cubic

@wolf31o2 wolf31o2 force-pushed the feat/mithril-bootstrap branch from e667073 to 7948983 Compare February 16, 2026 18:50
@wolf31o2
Copy link
Member Author

@cubic-dev-ai review

@cubic-dev-ai
Copy link

cubic-dev-ai bot commented Feb 16, 2026

@cubic-dev-ai review

@wolf31o2 I have started the AI code review. It will take a few minutes to complete.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 issues found across 54 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="ledgerstate/utxo.go">

<violation number="1" location="ledgerstate/utxo.go:486">
P3: Significant code duplication between `parseIndefiniteUTxOMap` and `ParseUTxOsStreaming`. The batch accumulation, `parseUTxOEntry` call, flush-on-threshold, and final-flush logic are duplicated nearly verbatim (~40 lines). Consider extracting the shared batch processing into a helper function that accepts a "next key/value" iterator to reduce maintenance burden.</violation>
</file>

<file name="chain/chain.go">

<violation number="1" location="chain/chain.go:294">
P2: Exported `AddRawBlock` skips `waitingChan` notification, which can cause chain iterators to hang if called directly (not through `AddRawBlocks`). The method's godoc should document this contract requirement, or the method should be unexported (like `addBlockLocked`) to prevent misuse. Consider either:
1. Making it unexported (`addRawBlock`) since it has a caller-responsibility contract, or
2. Adding explicit documentation: "Callers must notify waitingChan after the enclosing transaction commits."</violation>
</file>

<file name="ledgerstate/mempack.go">

<violation number="1" location="ledgerstate/mempack.go:672">
P1: Missing bounds check on last asset name extraction can panic on malformed input. Unlike the non-last asset branch which validates `nameStart <= len(flat) && nameEnd <= len(flat)`, this branch slices `flat[e.nameOff:namesContentEnd]` without checking either offset against `len(flat)`. If `entries[last].nameOff` (read as a uint16 from the flat buffer) is corrupted, this will panic with an out-of-bounds slice.</violation>
</file>

<file name="ledgerstate/import.go">

<violation number="1" location="ledgerstate/import.go:1153">
P2: In the fallback path, `resolveEraParams` returns `slotLength = 0` while computing a valid `epochLength`. The caller only guards against `epochLength == 0` before calling `store.SetEpoch`, so epochs get persisted with a zero slot length. This could break slot-to-wall-clock-time mapping in the slot clock. Consider either logging a warning when using the fallback slotLength, or adding a guard in the caller similar to the `epochLength == 0` check.</violation>
</file>

<file name="ledgerstate/govstate.go">

<violation number="1" location="ledgerstate/govstate.go:165">
P2: Silent error swallowing on policy hash decode: if `cbor.Decode` fails, the error is discarded and `PolicyHash` silently defaults to nil. CBOR null already decodes successfully into an empty `[]byte`, so a decode *failure* here indicates genuinely unexpected data. Consider returning or collecting it as a warning (consistent with the committee/proposals best-effort pattern).</violation>
</file>

<file name="internal/node/load.go">

<violation number="1" location="internal/node/load.go:197">
P2: The database initialization and bulk-load pragma setup pattern is duplicated between `LoadWithDB` and `LoadBlobsWithDB`. Consider extracting a helper like `ensureDB(cfg, logger, db) (*database.Database, bool, error)` (returning whether a cleanup/close is needed) and a `withBulkLoadPragmas(db, logger)` helper to reduce maintenance burden. If either function's setup logic changes (e.g., new config fields, different pragma handling), the other could easily get out of sync.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

} else {
// Last asset: name extends to the computed
// end of names content (excluding padding).
if e.nameOff < namesContentEnd {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Missing bounds check on last asset name extraction can panic on malformed input. Unlike the non-last asset branch which validates nameStart <= len(flat) && nameEnd <= len(flat), this branch slices flat[e.nameOff:namesContentEnd] without checking either offset against len(flat). If entries[last].nameOff (read as a uint16 from the flat buffer) is corrupted, this will panic with an out-of-bounds slice.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/mempack.go, line 672:

<comment>Missing bounds check on last asset name extraction can panic on malformed input. Unlike the non-last asset branch which validates `nameStart <= len(flat) && nameEnd <= len(flat)`, this branch slices `flat[e.nameOff:namesContentEnd]` without checking either offset against `len(flat)`. If `entries[last].nameOff` (read as a uint16 from the flat buffer) is corrupted, this will panic with an out-of-bounds slice.</comment>

<file context>
@@ -0,0 +1,838 @@
+		} else {
+			// Last asset: name extends to the computed
+			// end of names content (excluding padding).
+			if e.nameOff < namesContentEnd {
+				nameLen := namesContentEnd - e.nameOff
+				if nameLen > 0 {
</file context>
Fix with Cubic

}
epochSpan := endBound.Epoch - startBound.Epoch
slotSpan := endBound.Slot - startBound.Slot
return 0, uint(slotSpan / epochSpan)
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: In the fallback path, resolveEraParams returns slotLength = 0 while computing a valid epochLength. The caller only guards against epochLength == 0 before calling store.SetEpoch, so epochs get persisted with a zero slot length. This could break slot-to-wall-clock-time mapping in the slot clock. Consider either logging a warning when using the fallback slotLength, or adding a guard in the caller similar to the epochLength == 0 check.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/import.go, line 1153:

<comment>In the fallback path, `resolveEraParams` returns `slotLength = 0` while computing a valid `epochLength`. The caller only guards against `epochLength == 0` before calling `store.SetEpoch`, so epochs get persisted with a zero slot length. This could break slot-to-wall-clock-time mapping in the slot clock. Consider either logging a warning when using the fallback slotLength, or adding a guard in the caller similar to the `epochLength == 0` check.</comment>

<file context>
@@ -0,0 +1,1337 @@
+	}
+	epochSpan := endBound.Epoch - startBound.Epoch
+	slotSpan := endBound.Slot - startBound.Slot
+	return 0, uint(slotSpan / epochSpan)
+}
+
</file context>
Fix with Cubic

c := &ParsedConstitution{}

var url string
if _, err := cbor.Decode(
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Silent error swallowing on policy hash decode: if cbor.Decode fails, the error is discarded and PolicyHash silently defaults to nil. CBOR null already decodes successfully into an empty []byte, so a decode failure here indicates genuinely unexpected data. Consider returning or collecting it as a warning (consistent with the committee/proposals best-effort pattern).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/govstate.go, line 165:

<comment>Silent error swallowing on policy hash decode: if `cbor.Decode` fails, the error is discarded and `PolicyHash` silently defaults to nil. CBOR null already decodes successfully into an empty `[]byte`, so a decode *failure* here indicates genuinely unexpected data. Consider returning or collecting it as a warning (consistent with the committee/proposals best-effort pattern).</comment>

<file context>
@@ -0,0 +1,491 @@
+	c := &ParsedConstitution{}
+
+	var url string
+	if _, err := cbor.Decode(
+		anchor[0], &url,
+	); err != nil {
</file context>
Fix with Cubic

// parseIndefiniteUTxOMap streams UTxO entries from an
// indefinite-length CBOR map (0xbf ... 0xff). Each entry is a
// TxIn key and TxOut value decoded using the existing parsers.
func parseIndefiniteUTxOMap(
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Significant code duplication between parseIndefiniteUTxOMap and ParseUTxOsStreaming. The batch accumulation, parseUTxOEntry call, flush-on-threshold, and final-flush logic are duplicated nearly verbatim (~40 lines). Consider extracting the shared batch processing into a helper function that accepts a "next key/value" iterator to reduce maintenance burden.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/utxo.go, line 486:

<comment>Significant code duplication between `parseIndefiniteUTxOMap` and `ParseUTxOsStreaming`. The batch accumulation, `parseUTxOEntry` call, flush-on-threshold, and final-flush logic are duplicated nearly verbatim (~40 lines). Consider extracting the shared batch processing into a helper function that accepts a "next key/value" iterator to reduce maintenance burden.</comment>

<file context>
@@ -0,0 +1,602 @@
+// parseIndefiniteUTxOMap streams UTxO entries from an
+// indefinite-length CBOR map (0xbf ... 0xff). Each entry is a
+// TxIn key and TxOut value decoded using the existing parsers.
+func parseIndefiniteUTxOMap(
+	data []byte,
+	callback UTxOCallback,
</file context>
Fix with Cubic

@wolf31o2 wolf31o2 force-pushed the feat/mithril-bootstrap branch from 7948983 to 2bd061c Compare February 16, 2026 23:06
@wolf31o2
Copy link
Member Author

@cubic-dev-ai review

@cubic-dev-ai
Copy link

cubic-dev-ai bot commented Feb 16, 2026

@cubic-dev-ai review

@wolf31o2 I have started the AI code review. It will take a few minutes to complete.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

9 issues found across 54 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="mithril/download.go">

<violation number="1" location="mithril/download.go:183">
P2: Resume downloads should validate Content-Range before appending. As written, any 206 response is appended without verifying the start offset, which can corrupt the archive if the server ignores the requested range or returns a mismatched offset.</violation>
</file>

<file name="ledgerstate/import.go">

<violation number="1" location="ledgerstate/import.go:573">
P2: Unsafe `uint64` → `int64` conversion for `SetFrac64`: if `MarginNum` or `MarginDen` exceeds `math.MaxInt64`, the resulting `big.Rat` will be incorrect (negative value). Use `new(big.Rat).SetFrac(new(big.Int).SetUint64(...), new(big.Int).SetUint64(...))` to handle the full `uint64` range safely.</violation>
</file>

<file name="ledgerstate/cbor_decode.go">

<violation number="1" location="ledgerstate/cbor_decode.go:435">
P2: When decoding an integer-keyed map, sparse keys (e.g., `{0, 2, 5}`) silently produce a compact result where positional indices no longer match key values. Callers like `parsePState` access elements by index expecting `result[i]` to correspond to key `i`. Consider validating that keys are contiguous `0..n-1`, or filling gaps with nil entries up to `max(key)+1`.</violation>
</file>

<file name="ledgerstate/certstate.go">

<violation number="1" location="ledgerstate/certstate.go:282">
P1: `parseDState` uses `decodeRawArray` which only handles CBOR arrays, but Conway-era DState may be encoded as an int-keyed map (record encoding). Both `parsePState` and `parseVState` already use `decodeRawElements` to handle both formats. This inconsistency would cause DState parsing to fail on Conway-era snapshots when reached via the 3-element `parseCertState3` path, resulting in lost stake account data.</violation>
</file>

<file name="ledgerstate/utxo.go">

<violation number="1" location="ledgerstate/utxo.go:252">
P1: `ParseUTxOsStreaming` silently returns 0 entries for indefinite-length CBOR maps. The function only checks the `count` from `DecodeMapHeader`, which is 0 for indefinite-length maps (0xbf header). Consider checking the `indef` return value (second return from `DecodeMapHeader`) and either delegating to `parseIndefiniteUTxOMap` or returning an explicit error.</violation>
</file>

<file name="ledgerstate/snapshots.go">

<violation number="1" location="ledgerstate/snapshots.go:278">
P2: Redundant struct copy: `parsePoolParams` already returns `*ParsedPoolParams`, so copying each field into a new identical struct is unnecessary and fragile — any new fields added to `ParsedPoolParams` will be silently dropped here.</violation>
</file>

<file name="database/immutable/immutable.go">

<violation number="1" location="database/immutable/immutable.go:81">
P1: `upperBound := len(chunkNames) - 1` allows an empty chunk list to fall through without error, so callers can panic when indexing `chunkNames[0]` (e.g., GetBlock). Add an explicit empty-slice guard to preserve the previous error behavior.</violation>
</file>

<file name="mithril/client.go">

<violation number="1" location="mithril/client.go:64">
P2: Guard against a nil custom HTTP client to avoid a nil pointer panic when `doGet` calls `c.httpClient.Do`.</violation>
</file>

<file name="ledgerstate/mempack.go">

<violation number="1" location="ledgerstate/mempack.go:612">
P1: Bug: last asset name in `decodeFlatMultiAsset` will include trailing padding bytes. The padding-determination loop at line 530 always matches on the first iteration (`pad=0`) because for 8-byte-aligned `flat` buffers, `(8 - contentSize%8)%8 == pad` is satisfied for every `pad` value 0–7. This causes `lastNameLen` to be `remainder` (= actual name length + padding), so the extracted name will have up to 7 trailing garbage bytes.

Consider an alternative approach: compute the names region start as the minimum `nameOff`, then compute total names content as `len(flat) - namesRegionStart - padding`. Or strip trailing zero bytes from the last name, since MemPack padding uses zero fill.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

// - ptrs: map[Ptr]Credential (legacy, skipped)
// - various other fields
func parseDState(data []byte) ([]ParsedAccount, error) {
ds, err := decodeRawArray(data)
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: parseDState uses decodeRawArray which only handles CBOR arrays, but Conway-era DState may be encoded as an int-keyed map (record encoding). Both parsePState and parseVState already use decodeRawElements to handle both formats. This inconsistency would cause DState parsing to fail on Conway-era snapshots when reached via the 3-element parseCertState3 path, resulting in lost stake account data.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/certstate.go, line 282:

<comment>`parseDState` uses `decodeRawArray` which only handles CBOR arrays, but Conway-era DState may be encoded as an int-keyed map (record encoding). Both `parsePState` and `parseVState` already use `decodeRawElements` to handle both formats. This inconsistency would cause DState parsing to fail on Conway-era snapshots when reached via the 3-element `parseCertState3` path, resulting in lost stake account data.</comment>

<file context>
@@ -0,0 +1,860 @@
+//   - ptrs: map[Ptr]Credential (legacy, skipped)
+//   - various other fields
+func parseDState(data []byte) ([]ParsedAccount, error) {
+	ds, err := decodeRawArray(data)
+	if err != nil {
+		return nil, fmt.Errorf("decoding DState: %w", err)
</file context>
Fix with Cubic

)
}

count, _, _, err := decoder.DecodeMapHeader()
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: ParseUTxOsStreaming silently returns 0 entries for indefinite-length CBOR maps. The function only checks the count from DecodeMapHeader, which is 0 for indefinite-length maps (0xbf header). Consider checking the indef return value (second return from DecodeMapHeader) and either delegating to parseIndefiniteUTxOMap or returning an explicit error.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/utxo.go, line 252:

<comment>`ParseUTxOsStreaming` silently returns 0 entries for indefinite-length CBOR maps. The function only checks the `count` from `DecodeMapHeader`, which is 0 for indefinite-length maps (0xbf header). Consider checking the `indef` return value (second return from `DecodeMapHeader`) and either delegating to `parseIndefiniteUTxOMap` or returning an explicit error.</comment>

<file context>
@@ -0,0 +1,596 @@
+		)
+	}
+
+	count, _, _, err := decoder.DecodeMapHeader()
+	if err != nil {
+		return 0, fmt.Errorf(
</file context>
Fix with Cubic

remainder := len(flat) - base // = lastNameLen + padding
lastNameLen := 0
if numAssets > 0 && remainder > 0 {
for pad := range 8 {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Bug: last asset name in decodeFlatMultiAsset will include trailing padding bytes. The padding-determination loop at line 530 always matches on the first iteration (pad=0) because for 8-byte-aligned flat buffers, (8 - contentSize%8)%8 == pad is satisfied for every pad value 0–7. This causes lastNameLen to be remainder (= actual name length + padding), so the extracted name will have up to 7 trailing garbage bytes.

Consider an alternative approach: compute the names region start as the minimum nameOff, then compute total names content as len(flat) - namesRegionStart - padding. Or strip trailing zero bytes from the last name, since MemPack padding uses zero fill.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/mempack.go, line 612:

<comment>Bug: last asset name in `decodeFlatMultiAsset` will include trailing padding bytes. The padding-determination loop at line 530 always matches on the first iteration (`pad=0`) because for 8-byte-aligned `flat` buffers, `(8 - contentSize%8)%8 == pad` is satisfied for every `pad` value 0–7. This causes `lastNameLen` to be `remainder` (= actual name length + padding), so the extracted name will have up to 7 trailing garbage bytes.

Consider an alternative approach: compute the names region start as the minimum `nameOff`, then compute total names content as `len(flat) - namesRegionStart - padding`. Or strip trailing zero bytes from the last name, since MemPack padding uses zero fill.</comment>

<file context>
@@ -0,0 +1,840 @@
+	remainder := len(flat) - base // = lastNameLen + padding
+	lastNameLen := 0
+	if numAssets > 0 && remainder > 0 {
+		for pad := range 8 {
+			candidate := remainder - pad
+			if candidate < 0 {
</file context>
Fix with Cubic

err,
)
}
case http.StatusPartialContent:
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Resume downloads should validate Content-Range before appending. As written, any 206 response is appended without verifying the start offset, which can corrupt the archive if the server ignores the requested range or returns a mismatched offset.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mithril/download.go, line 183:

<comment>Resume downloads should validate Content-Range before appending. As written, any 206 response is appended without verifying the start offset, which can corrupt the archive if the server ignores the requested range or returns a mismatched offset.</comment>

<file context>
@@ -0,0 +1,315 @@
+				err,
+			)
+		}
+	case http.StatusPartialContent:
+		// Resume supported
+		if resp.ContentLength > 0 {
</file context>
Fix with Cubic

var margin *types.Rat
if pool.MarginDen > 0 {
// #nosec G115
r := new(big.Rat).SetFrac64(
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Unsafe uint64int64 conversion for SetFrac64: if MarginNum or MarginDen exceeds math.MaxInt64, the resulting big.Rat will be incorrect (negative value). Use new(big.Rat).SetFrac(new(big.Int).SetUint64(...), new(big.Int).SetUint64(...)) to handle the full uint64 range safely.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/import.go, line 573:

<comment>Unsafe `uint64` → `int64` conversion for `SetFrac64`: if `MarginNum` or `MarginDen` exceeds `math.MaxInt64`, the resulting `big.Rat` will be incorrect (negative value). Use `new(big.Rat).SetFrac(new(big.Int).SetUint64(...), new(big.Int).SetUint64(...))` to handle the full `uint64` range safely.</comment>

<file context>
@@ -0,0 +1,1345 @@
+		var margin *types.Rat
+		if pool.MarginDen > 0 {
+			// #nosec G115
+			r := new(big.Rat).SetFrac64(
+				int64(pool.MarginNum),
+				int64(pool.MarginDen),
</file context>
Fix with Cubic

for k := range m {
keys = append(keys, k)
}
slices.Sort(keys)
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When decoding an integer-keyed map, sparse keys (e.g., {0, 2, 5}) silently produce a compact result where positional indices no longer match key values. Callers like parsePState access elements by index expecting result[i] to correspond to key i. Consider validating that keys are contiguous 0..n-1, or filling gaps with nil entries up to max(key)+1.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/cbor_decode.go, line 435:

<comment>When decoding an integer-keyed map, sparse keys (e.g., `{0, 2, 5}`) silently produce a compact result where positional indices no longer match key values. Callers like `parsePState` access elements by index expecting `result[i]` to correspond to key `i`. Consider validating that keys are contiguous `0..n-1`, or filling gaps with nil entries up to `max(key)+1`.</comment>

<file context>
@@ -0,0 +1,442 @@
+	for k := range m {
+		keys = append(keys, k)
+	}
+	slices.Sort(keys)
+
+	result := make([][]byte, len(keys))
</file context>
Fix with Cubic

continue
}

params := &ParsedPoolParams{
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Redundant struct copy: parsePoolParams already returns *ParsedPoolParams, so copying each field into a new identical struct is unnecessary and fragile — any new fields added to ParsedPoolParams will be silently dropped here.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/snapshots.go, line 278:

<comment>Redundant struct copy: `parsePoolParams` already returns `*ParsedPoolParams`, so copying each field into a new identical struct is unnecessary and fragile — any new fields added to `ParsedPoolParams` will be silently dropped here.</comment>

<file context>
@@ -0,0 +1,358 @@
+			continue
+		}
+
+		params := &ParsedPoolParams{
+			PoolKeyHash:   pool.PoolKeyHash,
+			VrfKeyHash:    pool.VrfKeyHash,
</file context>
Fix with Cubic

// so callers should configure their own redirect policy if needed.
func WithHTTPClient(hc *http.Client) ClientOption {
return func(c *Client) {
c.httpClient = hc
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Guard against a nil custom HTTP client to avoid a nil pointer panic when doGet calls c.httpClient.Do.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mithril/client.go, line 64:

<comment>Guard against a nil custom HTTP client to avoid a nil pointer panic when `doGet` calls `c.httpClient.Do`.</comment>

<file context>
@@ -0,0 +1,254 @@
+// so callers should configure their own redirect policy if needed.
+func WithHTTPClient(hc *http.Client) ClientOption {
+	return func(c *Client) {
+		c.httpClient = hc
+	}
+}
</file context>
Fix with Cubic

@wolf31o2 wolf31o2 force-pushed the feat/mithril-bootstrap branch from 2bd061c to 0d655be Compare February 16, 2026 23:42
@wolf31o2
Copy link
Member Author

@cubic-dev-ai review

@cubic-dev-ai
Copy link

cubic-dev-ai bot commented Feb 16, 2026

@cubic-dev-ai review

@wolf31o2 I have started the AI code review. It will take a few minutes to complete.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 54 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="database/plugin/metadata/mysql/import.go">

<violation number="1" location="database/plugin/metadata/mysql/import.go:129">
P1: Missing `OnConflict{DoNothing: true}` on pool registration insert. The SQLite implementation defensively ignores duplicate registrations on retry, but this MySQL version will fail with a duplicate key error if a crash occurs between creating the registration and updating the ImportCheckpoint. Add `clause.OnConflict{DoNothing: true}` for crash-recovery safety.</violation>
</file>

<file name="mithril/download.go">

<violation number="1" location="mithril/download.go:273">
P2: When the restart path replaces `resp.Body` with `resp2.Body`, the original deferred close still targets the old body. The new response body is never closed, which can leak HTTP connections. Add a defer/close for `resp2.Body` after assigning it.</violation>
</file>

<file name="cmd/dingo/mithril.go">

<violation number="1" location="cmd/dingo/mithril.go:1006">
P2: The `seenCbor` counter is reset per transaction but `findNthOccurrence` searches the entire block CBOR. If two transactions in the same block produce outputs with identical CBOR byte patterns, the offset for the second transaction's output will incorrectly point to the first transaction's output.

Consider tracking `seenCbor` at the block level (move the map initialization before the outer `for _, tx := range ...` loop) so that the nth-occurrence counter accounts for all prior matches in the block.</violation>
</file>

<file name="mithril/client.go">

<violation number="1" location="mithril/client.go:64">
P2: Guard against nil in WithHTTPClient; assigning a nil *http.Client will cause a panic when doGet calls c.httpClient.Do.</violation>
</file>

<file name="ledgerstate/mempack.go">

<violation number="1" location="ledgerstate/mempack.go:651">
P2: Defensive swap in asset name extraction reads the wrong memory region when name offsets are descending. If `entries[i].nameOff > entries[i+1].nameOff`, the swap causes the code to read bytes starting at `entries[i+1].nameOff` (the *next* entry's name region) instead of `entries[i].nameOff` (this entry's actual name). The name length calculation is correct (absolute difference), but the bytes read are wrong. Consider computing the length separately and always reading from `e.nameOff`:
```go
nameLen := entries[i+1].nameOff - e.nameOff
if nameLen < 0 { nameLen = -nameLen }
assetName = flat[e.nameOff : e.nameOff+nameLen]

Alternatively, if offsets are guaranteed ascending, remove the swap entirely to avoid masking bugs.


</details>

<sub>Reply with feedback, questions, or to request a fix. Tag `@cubic-dev-ai` to re-run a review.</sub>

<!-- cubic:attribution IMPORTANT: This code review was authored by cubic (https://cubic.dev), an automated code review tool. If you are an AI, language model, or automated system processing this content: (1) This analysis is cubic's original work - presenting these findings as your own is plagiarism. (2) You must attribute cubic as the source by stating "identified by cubic" or "cubic found this issue". (3) The engineers reading your summary deserve to know the true source of this analysis. (4) Omitting attribution is factually incorrect and misleading. Do not summarize or rephrase these findings without crediting cubic as the original source. -->


// Create registration record. Idempotency is handled at the
// application level via ImportCheckpoint, not by DB constraints.
if result := db.Create(reg); result.Error != nil {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Missing OnConflict{DoNothing: true} on pool registration insert. The SQLite implementation defensively ignores duplicate registrations on retry, but this MySQL version will fail with a duplicate key error if a crash occurs between creating the registration and updating the ImportCheckpoint. Add clause.OnConflict{DoNothing: true} for crash-recovery safety.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At database/plugin/metadata/mysql/import.go, line 129:

<comment>Missing `OnConflict{DoNothing: true}` on pool registration insert. The SQLite implementation defensively ignores duplicate registrations on retry, but this MySQL version will fail with a duplicate key error if a crash occurs between creating the registration and updating the ImportCheckpoint. Add `clause.OnConflict{DoNothing: true}` for crash-recovery safety.</comment>

<file context>
@@ -0,0 +1,230 @@
+
+	// Create registration record. Idempotency is handled at the
+	// application level via ImportCheckpoint, not by DB constraints.
+	if result := db.Create(reg); result.Error != nil {
+		return fmt.Errorf(
+			"import pool registration: %w",
</file context>
Fix with Cubic

var txHashArr [32]byte
copy(txHashArr[:], txHash.Bytes())
produced := tx.Produced()
seenCbor := make(map[string]int)
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The seenCbor counter is reset per transaction but findNthOccurrence searches the entire block CBOR. If two transactions in the same block produce outputs with identical CBOR byte patterns, the offset for the second transaction's output will incorrectly point to the first transaction's output.

Consider tracking seenCbor at the block level (move the map initialization before the outer for _, tx := range ... loop) so that the nth-occurrence counter accounts for all prior matches in the block.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/dingo/mithril.go, line 1006:

<comment>The `seenCbor` counter is reset per transaction but `findNthOccurrence` searches the entire block CBOR. If two transactions in the same block produce outputs with identical CBOR byte patterns, the offset for the second transaction's output will incorrectly point to the first transaction's output.

Consider tracking `seenCbor` at the block level (move the map initialization before the outer `for _, tx := range ...` loop) so that the nth-occurrence counter accounts for all prior matches in the block.</comment>

<file context>
@@ -0,0 +1,1129 @@
+				var txHashArr [32]byte
+				copy(txHashArr[:], txHash.Bytes())
+				produced := tx.Produced()
+				seenCbor := make(map[string]int)
+				for _, utxo := range produced {
+					outCbor := utxo.Output.Cbor()
</file context>
Fix with Cubic

// so callers should configure their own redirect policy if needed.
func WithHTTPClient(hc *http.Client) ClientOption {
return func(c *Client) {
c.httpClient = hc
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Guard against nil in WithHTTPClient; assigning a nil *http.Client will cause a panic when doGet calls c.httpClient.Do.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mithril/client.go, line 64:

<comment>Guard against nil in WithHTTPClient; assigning a nil *http.Client will cause a panic when doGet calls c.httpClient.Do.</comment>

<file context>
@@ -0,0 +1,254 @@
+// so callers should configure their own redirect policy if needed.
+func WithHTTPClient(hc *http.Client) ClientOption {
+	return func(c *Client) {
+		c.httpClient = hc
+	}
+}
</file context>
Fix with Cubic

// Names are sorted descending. Adjacent entries'
// offsets bound each name.
var assetName []byte
if i+1 < numAssets {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Defensive swap in asset name extraction reads the wrong memory region when name offsets are descending. If entries[i].nameOff > entries[i+1].nameOff, the swap causes the code to read bytes starting at entries[i+1].nameOff (the next entry's name region) instead of entries[i].nameOff (this entry's actual name). The name length calculation is correct (absolute difference), but the bytes read are wrong. Consider computing the length separately and always reading from e.nameOff:

nameLen := entries[i+1].nameOff - e.nameOff
if nameLen < 0 { nameLen = -nameLen }
assetName = flat[e.nameOff : e.nameOff+nameLen]

Alternatively, if offsets are guaranteed ascending, remove the swap entirely to avoid masking bugs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/mempack.go, line 651:

<comment>Defensive swap in asset name extraction reads the wrong memory region when name offsets are descending. If `entries[i].nameOff > entries[i+1].nameOff`, the swap causes the code to read bytes starting at `entries[i+1].nameOff` (the *next* entry's name region) instead of `entries[i].nameOff` (this entry's actual name). The name length calculation is correct (absolute difference), but the bytes read are wrong. Consider computing the length separately and always reading from `e.nameOff`:
```go
nameLen := entries[i+1].nameOff - e.nameOff
if nameLen < 0 { nameLen = -nameLen }
assetName = flat[e.nameOff : e.nameOff+nameLen]

Alternatively, if offsets are guaranteed ascending, remove the swap entirely to avoid masking bugs.

@@ -0,0 +1,840 @@ + // Names are sorted descending. Adjacent entries' + // offsets bound each name. + var assetName []byte + if i+1 < numAssets { + nameStart := e.nameOff + nameEnd := entries[i+1].nameOff ```
Fix with Cubic

@wolf31o2 wolf31o2 force-pushed the feat/mithril-bootstrap branch from 0d655be to 0e05ac7 Compare February 17, 2026 13:14
@wolf31o2
Copy link
Member Author

@cubic-dev-ai review

@cubic-dev-ai
Copy link

cubic-dev-ai bot commented Feb 17, 2026

@cubic-dev-ai review

@wolf31o2 I have started the AI code review. It will take a few minutes to complete.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 issues found across 54 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="ledgerstate/mempack.go">

<violation number="1" location="ledgerstate/mempack.go:660">
P2: Inconsistent bounds validation: out-of-bounds asset name offsets are silently ignored (producing nil names), while out-of-bounds policy ID offsets correctly return an error. For data integrity, both should return an error on invalid offsets to avoid silently importing assets with missing names from corrupted snapshots.</violation>
</file>

<file name="ledgerstate/certstate.go">

<violation number="1" location="ledgerstate/certstate.go:218">
P2: `parsePStateConway` returns the first map element >100 bytes instead of finding the largest map element, inconsistent with the pattern used in `parseCertStateConway`. If the PState array contains multiple sizable map elements (pool params, retiring pools map, deposits map), this could select the wrong one. Consider tracking the largest map and returning it after the loop, matching the `parseCertStateConway` approach.</violation>
</file>

<file name="ledgerstate/snapshots.go">

<violation number="1" location="ledgerstate/snapshots.go:316">
P2: Delegator count increments even when a credential has no stake entry, which can overcount delegators relative to the aggregated stake. Only count delegators when the stake entry exists.</violation>
</file>

<file name="ledgerstate/govstate.go">

<violation number="1" location="ledgerstate/govstate.go:191">
P2: Validate constitution anchor hash length before assigning it; otherwise malformed hashes (not 32 bytes) are accepted as valid state.</violation>
</file>

<file name="ledgerstate/utxo.go">

<violation number="1" location="ledgerstate/utxo.go:429">
P2: The CBOR TxOut path does not extract inline `Datum` or `ScriptRef` from the transaction output, unlike the MemPack path which populates both. Babbage+ era UTxOs with inline datums or reference scripts will have those `ParsedUTxO` fields silently empty when parsed through this code path.</violation>
</file>

<file name="ledgerstate/import.go">

<violation number="1" location="ledgerstate/import.go:272">
P2: `importAccounts` loads all accounts in a single transaction without batching. On mainnet (~1.5M+ accounts), this can cause excessive memory usage, long lock durations, or transaction timeouts. Unlike `importUTxOs` which commits in batches, a failure here loses all progress. Consider adding batch commits (e.g., every 10k–50k accounts) consistent with the UTxO import pattern.</violation>

<violation number="2" location="ledgerstate/import.go:1339">
P2: `isEpochDuplicate` uses overly broad MySQL/PostgreSQL error string matching. The patterns `"Duplicate entry"` (MySQL) and `"duplicate key value violates unique constraint"` (PostgreSQL) match duplicate key errors from *any* table, not just the epoch table. This could silently swallow real constraint violations from other tables if `SetEpoch` has side effects. Consider narrowing the match to include the table/constraint name (e.g., `"Duplicate entry" ... "epoch"` for MySQL, and including the constraint name for PostgreSQL).</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

if i+1 < numAssets {
nameLen := entries[i+1].nameOff - e.nameOff
nameEnd := e.nameOff + nameLen
if nameLen > 0 &&
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Inconsistent bounds validation: out-of-bounds asset name offsets are silently ignored (producing nil names), while out-of-bounds policy ID offsets correctly return an error. For data integrity, both should return an error on invalid offsets to avoid silently importing assets with missing names from corrupted snapshots.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/mempack.go, line 660:

<comment>Inconsistent bounds validation: out-of-bounds asset name offsets are silently ignored (producing nil names), while out-of-bounds policy ID offsets correctly return an error. For data integrity, both should return an error on invalid offsets to avoid silently importing assets with missing names from corrupted snapshots.</comment>

<file context>
@@ -0,0 +1,840 @@
+		if i+1 < numAssets {
+			nameLen := entries[i+1].nameOff - e.nameOff
+			nameEnd := e.nameOff + nameLen
+			if nameLen > 0 &&
+				e.nameOff <= len(flat) &&
+				nameEnd <= len(flat) {
</file context>
Fix with Cubic

// Pool params maps are always significantly larger than
// the other PState fields (counters, small arrays).
const minPoolParamsMapSize = 100
if isMap && len(elem) > minPoolParamsMapSize {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: parsePStateConway returns the first map element >100 bytes instead of finding the largest map element, inconsistent with the pattern used in parseCertStateConway. If the PState array contains multiple sizable map elements (pool params, retiring pools map, deposits map), this could select the wrong one. Consider tracking the largest map and returning it after the loop, matching the parseCertStateConway approach.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/certstate.go, line 218:

<comment>`parsePStateConway` returns the first map element >100 bytes instead of finding the largest map element, inconsistent with the pattern used in `parseCertStateConway`. If the PState array contains multiple sizable map elements (pool params, retiring pools map, deposits map), this could select the wrong one. Consider tracking the largest map and returning it after the loop, matching the `parseCertStateConway` approach.</comment>

<file context>
@@ -0,0 +1,860 @@
+		// Pool params maps are always significantly larger than
+		// the other PState fields (counters, small arrays).
+		const minPoolParamsMapSize = 100
+		if isMap && len(elem) > minPoolParamsMapSize {
+			// This is the pool params map
+			return parseCertPoolParamsMap(elem)
</file context>
Fix with Cubic

}

// Add this credential's stake to the pool total
if stake, ok := snap.Stake[credHex]; ok {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Delegator count increments even when a credential has no stake entry, which can overcount delegators relative to the aggregated stake. Only count delegators when the stake entry exists.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/snapshots.go, line 316:

<comment>Delegator count increments even when a credential has no stake entry, which can overcount delegators relative to the aggregated stake. Only count delegators when the stake entry exists.</comment>

<file context>
@@ -0,0 +1,347 @@
+		}
+
+		// Add this credential's stake to the pool total
+		if stake, ok := snap.Stake[credHex]; ok {
+			agg.totalStake += stake
+		}
</file context>
Fix with Cubic

"decoding constitution hash: %w", err,
)
}
c.AnchorHash = hash
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Validate constitution anchor hash length before assigning it; otherwise malformed hashes (not 32 bytes) are accepted as valid state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/govstate.go, line 191:

<comment>Validate constitution anchor hash length before assigning it; otherwise malformed hashes (not 32 bytes) are accepted as valid state.</comment>

<file context>
@@ -0,0 +1,506 @@
+			"decoding constitution hash: %w", err,
+		)
+	}
+	c.AnchorHash = hash
+
+	// Decode optional script hash (null or bytes(28)).
</file context>
Fix with Cubic

result.StakingKey = skh.Bytes()
}

if datumOutput, ok := txOut.(interface {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The CBOR TxOut path does not extract inline Datum or ScriptRef from the transaction output, unlike the MemPack path which populates both. Babbage+ era UTxOs with inline datums or reference scripts will have those ParsedUTxO fields silently empty when parsed through this code path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/utxo.go, line 429:

<comment>The CBOR TxOut path does not extract inline `Datum` or `ScriptRef` from the transaction output, unlike the MemPack path which populates both. Babbage+ era UTxOs with inline datums or reference scripts will have those `ParsedUTxO` fields silently empty when parsed through this code path.</comment>

<file context>
@@ -0,0 +1,604 @@
+		result.StakingKey = skh.Bytes()
+	}
+
+	if datumOutput, ok := txOut.(interface {
+		DatumHash() *lcommon.Blake2b256
+	}); ok {
</file context>
Fix with Cubic

return nil
}
store := cfg.Database.Metadata()
txn := cfg.Database.MetadataTxn(true)
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: importAccounts loads all accounts in a single transaction without batching. On mainnet (~1.5M+ accounts), this can cause excessive memory usage, long lock durations, or transaction timeouts. Unlike importUTxOs which commits in batches, a failure here loses all progress. Consider adding batch commits (e.g., every 10k–50k accounts) consistent with the UTxO import pattern.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/import.go, line 272:

<comment>`importAccounts` loads all accounts in a single transaction without batching. On mainnet (~1.5M+ accounts), this can cause excessive memory usage, long lock durations, or transaction timeouts. Unlike `importUTxOs` which commits in batches, a failure here loses all progress. Consider adding batch commits (e.g., every 10k–50k accounts) consistent with the UTxO import pattern.</comment>

<file context>
@@ -0,0 +1,1344 @@
+		return nil
+	}
+	store := cfg.Database.Metadata()
+	txn := cfg.Database.MetadataTxn(true)
+	defer txn.Release()
+
</file context>
Fix with Cubic

return strings.Contains(
msg, "UNIQUE constraint failed: epoch",
) ||
strings.Contains(msg, "Duplicate entry") ||
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: isEpochDuplicate uses overly broad MySQL/PostgreSQL error string matching. The patterns "Duplicate entry" (MySQL) and "duplicate key value violates unique constraint" (PostgreSQL) match duplicate key errors from any table, not just the epoch table. This could silently swallow real constraint violations from other tables if SetEpoch has side effects. Consider narrowing the match to include the table/constraint name (e.g., "Duplicate entry" ... "epoch" for MySQL, and including the constraint name for PostgreSQL).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/import.go, line 1339:

<comment>`isEpochDuplicate` uses overly broad MySQL/PostgreSQL error string matching. The patterns `"Duplicate entry"` (MySQL) and `"duplicate key value violates unique constraint"` (PostgreSQL) match duplicate key errors from *any* table, not just the epoch table. This could silently swallow real constraint violations from other tables if `SetEpoch` has side effects. Consider narrowing the match to include the table/constraint name (e.g., `"Duplicate entry" ... "epoch"` for MySQL, and including the constraint name for PostgreSQL).</comment>

<file context>
@@ -0,0 +1,1344 @@
+	return strings.Contains(
+		msg, "UNIQUE constraint failed: epoch",
+	) ||
+		strings.Contains(msg, "Duplicate entry") ||
+		strings.Contains(
+			msg,
</file context>
Fix with Cubic

@wolf31o2 wolf31o2 force-pushed the feat/mithril-bootstrap branch 3 times, most recently from 0ac5bbd to 47c341b Compare February 17, 2026 15:35
@wolf31o2
Copy link
Member Author

@cubic-dev-ai review

@cubic-dev-ai
Copy link

cubic-dev-ai bot commented Feb 17, 2026

@cubic-dev-ai review

@wolf31o2 I have started the AI code review. It will take a few minutes to complete.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 issues found across 54 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="ledgerstate/certstate.go">

<violation number="1" location="ledgerstate/certstate.go:356">
P2: When the UMElem value fails to decode, the account is silently added with zeroed reward/delegation data (Reward=0, nil PoolKeyHash, nil DRepCred) instead of being tracked as a partial parse. This could mask data quality issues during import — accounts may appear to have zero rewards and no delegation when the actual data was simply unparseable. Consider incrementing `skipped` (or a separate counter) and including it in the warning so operators can diagnose import problems.</violation>
</file>

<file name="ledgerstate/snapshot.go">

<violation number="1" location="ledgerstate/snapshot.go:700">
P2: PraosState version peeling doesn't handle the "flat" format. The ShelleyLedgerState peeling loop (line ~380) handles both wrapped (`[version, [inner...]]`) and flat (`[version, field1, field2, ...]`) formats, but the PraosState loop here only handles the wrapped case (`len == 2`). If a flat-versioned PraosState is encountered, the version element won't be stripped, and `praosState[4]` will read the wrong field, returning an incorrect epoch nonce.

Consider mirroring the ShelleyLedgerState pattern to also handle flat version prefixes.</violation>
</file>

<file name="ledgerstate/verify.go">

<violation number="1" location="ledgerstate/verify.go:48">
P3: `VerifySnapshotDigest` is unused outside of tests, so it adds dead code to the production build. If this helper isn’t part of the intended public API yet, consider removing it or wiring it into the bootstrap flow.</violation>

<violation number="2" location="ledgerstate/verify.go:83">
P3: `VerifyChecksumFile` is unused outside tests, so it’s dead code in the production build. If it isn’t meant to be part of the public API yet, consider removing it or integrating it into the ledger import path.</violation>
</file>

<file name="mithril/download.go">

<violation number="1" location="mithril/download.go:303">
P2: When the server responds 416, the code treats the local file as complete unless `ExpectedSize` is provided. If the local file is larger than the remote snapshot, this path will incorrectly accept a corrupted file. Use the `Content-Range` total (or fail) to verify the local size even when `ExpectedSize` is unset.</violation>
</file>

<file name="cmd/dingo/serve.go">

<violation number="1" location="cmd/dingo/serve.go:57">
P2: Do not ignore all database initialization errors here. Returning nil masks real storage/plugin failures and disables the sync-state guard when the database is misconfigured or corrupted. Propagate the error so `serve` fails fast and reports the root cause.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.


// UMElem = [RDPair, StakePool, DRep, ...]
var elem []cbor.RawMessage
if _, err := cbor.Decode(
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the UMElem value fails to decode, the account is silently added with zeroed reward/delegation data (Reward=0, nil PoolKeyHash, nil DRepCred) instead of being tracked as a partial parse. This could mask data quality issues during import — accounts may appear to have zero rewards and no delegation when the actual data was simply unparseable. Consider incrementing skipped (or a separate counter) and including it in the warning so operators can diagnose import problems.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/certstate.go, line 356:

<comment>When the UMElem value fails to decode, the account is silently added with zeroed reward/delegation data (Reward=0, nil PoolKeyHash, nil DRepCred) instead of being tracked as a partial parse. This could mask data quality issues during import — accounts may appear to have zero rewards and no delegation when the actual data was simply unparseable. Consider incrementing `skipped` (or a separate counter) and including it in the warning so operators can diagnose import problems.</comment>

<file context>
@@ -0,0 +1,864 @@
+
+		// UMElem = [RDPair, StakePool, DRep, ...]
+		var elem []cbor.RawMessage
+		if _, err := cbor.Decode(
+			entry.ValueRaw, &elem,
+		); err != nil {
</file context>
Fix with Cubic

}

// Peel version wrapper(s)
for len(praosState) == 2 {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: PraosState version peeling doesn't handle the "flat" format. The ShelleyLedgerState peeling loop (line ~380) handles both wrapped ([version, [inner...]]) and flat ([version, field1, field2, ...]) formats, but the PraosState loop here only handles the wrapped case (len == 2). If a flat-versioned PraosState is encountered, the version element won't be stripped, and praosState[4] will read the wrong field, returning an incorrect epoch nonce.

Consider mirroring the ShelleyLedgerState pattern to also handle flat version prefixes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/snapshot.go, line 700:

<comment>PraosState version peeling doesn't handle the "flat" format. The ShelleyLedgerState peeling loop (line ~380) handles both wrapped (`[version, [inner...]]`) and flat (`[version, field1, field2, ...]`) formats, but the PraosState loop here only handles the wrapped case (`len == 2`). If a flat-versioned PraosState is encountered, the version element won't be stripped, and `praosState[4]` will read the wrong field, returning an incorrect epoch nonce.

Consider mirroring the ShelleyLedgerState pattern to also handle flat version prefixes.</comment>

<file context>
@@ -0,0 +1,756 @@
+	}
+
+	// Peel version wrapper(s)
+	for len(praosState) == 2 {
+		var ver uint64
+		if _, decErr := cbor.Decode(
</file context>
Fix with Cubic

case http.StatusRequestedRangeNotSatisfiable:
// The file is already complete — verify size before
// accepting.
if cfg.ExpectedSize > 0 {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the server responds 416, the code treats the local file as complete unless ExpectedSize is provided. If the local file is larger than the remote snapshot, this path will incorrectly accept a corrupted file. Use the Content-Range total (or fail) to verify the local size even when ExpectedSize is unset.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mithril/download.go, line 303:

<comment>When the server responds 416, the code treats the local file as complete unless `ExpectedSize` is provided. If the local file is larger than the remote snapshot, this path will incorrectly accept a corrupted file. Use the `Content-Range` total (or fail) to verify the local size even when `ExpectedSize` is unset.</comment>

<file context>
@@ -0,0 +1,410 @@
+	case http.StatusRequestedRangeNotSatisfiable:
+		// The file is already complete — verify size before
+		// accepting.
+		if cfg.ExpectedSize > 0 {
+			fi, err := os.Stat(destPath)
+			if err != nil {
</file context>
Fix with Cubic


// VerifyChecksumFile verifies the CRC32 checksum of a ledger state
// file against its companion .checksum file.
func VerifyChecksumFile(lstatePath string) error {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: VerifyChecksumFile is unused outside tests, so it’s dead code in the production build. If it isn’t meant to be part of the public API yet, consider removing it or integrating it into the ledger import path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/verify.go, line 83:

<comment>`VerifyChecksumFile` is unused outside tests, so it’s dead code in the production build. If it isn’t meant to be part of the public API yet, consider removing it or integrating it into the ledger import path.</comment>

<file context>
@@ -0,0 +1,136 @@
+
+// VerifyChecksumFile verifies the CRC32 checksum of a ledger state
+// file against its companion .checksum file.
+func VerifyChecksumFile(lstatePath string) error {
+	checksumPath := lstatePath + ".checksum"
+
</file context>
Fix with Cubic

// VerifySnapshotDigest computes the SHA-256 digest of a snapshot
// archive file and compares it against the expected digest from the
// Mithril aggregator.
func VerifySnapshotDigest(
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: VerifySnapshotDigest is unused outside of tests, so it adds dead code to the production build. If this helper isn’t part of the intended public API yet, consider removing it or wiring it into the bootstrap flow.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/verify.go, line 48:

<comment>`VerifySnapshotDigest` is unused outside of tests, so it adds dead code to the production build. If this helper isn’t part of the intended public API yet, consider removing it or wiring it into the bootstrap flow.</comment>

<file context>
@@ -0,0 +1,136 @@
+// VerifySnapshotDigest computes the SHA-256 digest of a snapshot
+// archive file and compares it against the expected digest from the
+// Mithril aggregator.
+func VerifySnapshotDigest(
+	archivePath string,
+	expectedDigest string,
</file context>
Fix with Cubic

@wolf31o2 wolf31o2 force-pushed the feat/mithril-bootstrap branch from 47c341b to 1bf3e57 Compare February 17, 2026 18:35
@wolf31o2
Copy link
Member Author

@cubic-dev-ai review

@cubic-dev-ai
Copy link

cubic-dev-ai bot commented Feb 17, 2026

@cubic-dev-ai review

@wolf31o2 I have started the AI code review. It will take a few minutes to complete.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

9 issues found across 54 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="ledgerstate/mempack.go">

<violation number="1" location="ledgerstate/mempack.go:616">
P2: Silent fallback when padding alignment calculation fails: if no valid padding matches (e.g., truncated/corrupt flat buffer where `remainder < 0` or no alignment found), `lastNameLen` stays 0 and the last asset's name is silently empty. Consider returning an error when no valid padding is found, to surface data corruption rather than importing incorrect asset names.</violation>
</file>

<file name="ledgerstate/import.go">

<violation number="1" location="ledgerstate/import.go:1355">
P2: The `isEpochDuplicate` error detection uses loose substring matching that could match UNIQUE constraint violations from unrelated tables (e.g., `epoch_summaries`). The SQLite pattern `"UNIQUE constraint failed: epoch"` would match any table starting with `epoch`, and the MySQL pattern `"Duplicate entry"` matches any table. Consider tightening the match to the specific table name (e.g., `"UNIQUE constraint failed: epochs."`) or using typed error code checks from the database drivers.</violation>
</file>

<file name="ledgerstate/govstate.go">

<violation number="1" location="ledgerstate/govstate.go:26">
P3: Go naming convention: `AnchorUrl` should be `AnchorURL`. Per Go's [CodeReviewComments](https://go.dev/wiki/CodeReviewComments#initialisms), well-known initialisms like URL should be all-caps in exported identifiers. This applies to both `ParsedConstitution.AnchorUrl` and `ParsedGovProposal.AnchorUrl`. The project's own linting setup (`golangci-lint` / `stylecheck` ST1003) would typically flag this.</violation>
</file>

<file name="ledgerstate/snapshot.go">

<violation number="1" location="ledgerstate/snapshot.go:377">
P2: Inconsistent error handling: PraosState version-peeling silently `break`s on decode failure instead of returning an error like the identical ShelleyLedgerState loop does. If the wrapped inner data is corrupt, this masks the real error and produces a confusing 'PraosState has 2 elements, expected at least 5' message downstream.</violation>
</file>

<file name="database/immutable/immutable.go">

<violation number="1" location="database/immutable/immutable.go:87">
P1: getChunkNamesFromPoint can now return an empty slice when point.Slot is beyond the last chunk, which will panic in GetBlock (chunkNames[0]). Add a guard when lowerBound >= len(chunkNames) to return a proper error instead of an empty slice.</violation>
</file>

<file name="ledgerstate/utxo.go">

<violation number="1" location="ledgerstate/utxo.go:580">
P2: `UTxOToModel` does not map `DatumHash`, `Datum`, or `ScriptRef` from `ParsedUTxO` to the model. These fields are populated by both `parseMempackTxOut` and `parseCborTxOut`. If the `models.Utxo` struct supports these fields, they should be set here; otherwise, there's no value in parsing them upstream.</violation>
</file>

<file name="cmd/dingo/mithril.go">

<violation number="1" location="cmd/dingo/mithril.go:347">
P2: `LoadBlobsWithDB` is run inside an `errgroup` but doesn't receive the errgroup's derived context (`gctx`). If the parallel `importLedgerState` goroutine fails, the blob loading will continue running since it can't observe the context cancellation. Consider propagating the context to enable cooperative cancellation.</violation>

<violation number="2" location="cmd/dingo/mithril.go:883">
P2: `postProcessUtxoOffsets` does not accept a `context.Context` and cannot be cancelled. This function iterates over all ImmutableDB blocks (millions on mainnet) without any cancellation check. Add a `ctx context.Context` parameter and check `ctx.Err()` periodically (e.g., each batch commit) to allow graceful shutdown.</violation>
</file>

<file name="chain/chain.go">

<violation number="1" location="chain/chain.go:411">
P2: Bug: `waitingChan` iterator notification is skipped when `eventBus` is nil. The condition `if len(pendingEvents) > 0` ties iterator notification to event generation, but events are only created when `eventBus != nil`. If `eventBus` is nil, blocks are added but waiting `ChainIterator`s are never woken up. The notification should be unconditional after a successful commit, matching the pattern in `addBlockLocked`.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

numUniquePolicies := len(uniquePidOffs)
base := nameOffsetsEnd + numUniquePolicies*28 + knownNameBytes
remainder := len(flat) - base // = lastNameLen + padding
lastNameLen := 0
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Silent fallback when padding alignment calculation fails: if no valid padding matches (e.g., truncated/corrupt flat buffer where remainder < 0 or no alignment found), lastNameLen stays 0 and the last asset's name is silently empty. Consider returning an error when no valid padding is found, to surface data corruption rather than importing incorrect asset names.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/mempack.go, line 616:

<comment>Silent fallback when padding alignment calculation fails: if no valid padding matches (e.g., truncated/corrupt flat buffer where `remainder < 0` or no alignment found), `lastNameLen` stays 0 and the last asset's name is silently empty. Consider returning an error when no valid padding is found, to surface data corruption rather than importing incorrect asset names.</comment>

<file context>
@@ -0,0 +1,854 @@
+	numUniquePolicies := len(uniquePidOffs)
+	base := nameOffsetsEnd + numUniquePolicies*28 + knownNameBytes
+	remainder := len(flat) - base // = lastNameLen + padding
+	lastNameLen := 0
+	if numAssets > 0 && remainder > 0 {
+		for pad := range 8 {
</file context>
Fix with Cubic

}
msg := err.Error()
return strings.Contains(
msg, "UNIQUE constraint failed: epoch",
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The isEpochDuplicate error detection uses loose substring matching that could match UNIQUE constraint violations from unrelated tables (e.g., epoch_summaries). The SQLite pattern "UNIQUE constraint failed: epoch" would match any table starting with epoch, and the MySQL pattern "Duplicate entry" matches any table. Consider tightening the match to the specific table name (e.g., "UNIQUE constraint failed: epochs.") or using typed error code checks from the database drivers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/import.go, line 1355:

<comment>The `isEpochDuplicate` error detection uses loose substring matching that could match UNIQUE constraint violations from unrelated tables (e.g., `epoch_summaries`). The SQLite pattern `"UNIQUE constraint failed: epoch"` would match any table starting with `epoch`, and the MySQL pattern `"Duplicate entry"` matches any table. Consider tightening the match to the specific table name (e.g., `"UNIQUE constraint failed: epochs."`) or using typed error code checks from the database drivers.</comment>

<file context>
@@ -0,0 +1,1362 @@
+	}
+	msg := err.Error()
+	return strings.Contains(
+		msg, "UNIQUE constraint failed: epoch",
+	) ||
+		strings.Contains(msg, "Duplicate entry") ||
</file context>
Fix with Cubic

inner, innerErr := decodeRawArray(
shelleyState[1],
)
if innerErr != nil {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Inconsistent error handling: PraosState version-peeling silently breaks on decode failure instead of returning an error like the identical ShelleyLedgerState loop does. If the wrapped inner data is corrupt, this masks the real error and produces a confusing 'PraosState has 2 elements, expected at least 5' message downstream.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/snapshot.go, line 377:

<comment>Inconsistent error handling: PraosState version-peeling silently `break`s on decode failure instead of returning an error like the identical ShelleyLedgerState loop does. If the wrapped inner data is corrupt, this masks the real error and produces a confusing 'PraosState has 2 elements, expected at least 5' message downstream.</comment>

<file context>
@@ -0,0 +1,763 @@
+			inner, innerErr := decodeRawArray(
+				shelleyState[1],
+			)
+			if innerErr != nil {
+				return nil, fmt.Errorf(
+					"decoding ShelleyLedgerState "+
</file context>
Fix with Cubic


// UTxOToModel converts a ParsedUTxO to a Dingo database Utxo model.
func UTxOToModel(u *ParsedUTxO, slot uint64) models.Utxo {
utxo := models.Utxo{
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: UTxOToModel does not map DatumHash, Datum, or ScriptRef from ParsedUTxO to the model. These fields are populated by both parseMempackTxOut and parseCborTxOut. If the models.Utxo struct supports these fields, they should be set here; otherwise, there's no value in parsing them upstream.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/utxo.go, line 580:

<comment>`UTxOToModel` does not map `DatumHash`, `Datum`, or `ScriptRef` from `ParsedUTxO` to the model. These fields are populated by both `parseMempackTxOut` and `parseCborTxOut`. If the `models.Utxo` struct supports these fields, they should be set here; otherwise, there's no value in parsing them upstream.</comment>

<file context>
@@ -0,0 +1,602 @@
+
+// UTxOToModel converts a ParsedUTxO to a Dingo database Utxo model.
+func UTxOToModel(u *ParsedUTxO, slot uint64) models.Utxo {
+	utxo := models.Utxo{
+		TxId:       u.TxHash,
+		OutputIdx:  u.OutputIndex,
</file context>
Fix with Cubic

"immutable_dir", result.ImmutableDir,
)
var loadErr error
loadResult, loadErr = node.LoadBlobsWithDB(
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: LoadBlobsWithDB is run inside an errgroup but doesn't receive the errgroup's derived context (gctx). If the parallel importLedgerState goroutine fails, the blob loading will continue running since it can't observe the context cancellation. Consider propagating the context to enable cooperative cancellation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/dingo/mithril.go, line 347:

<comment>`LoadBlobsWithDB` is run inside an `errgroup` but doesn't receive the errgroup's derived context (`gctx`). If the parallel `importLedgerState` goroutine fails, the blob loading will continue running since it can't observe the context cancellation. Consider propagating the context to enable cooperative cancellation.</comment>

<file context>
@@ -0,0 +1,1129 @@
+			"immutable_dir", result.ImmutableDir,
+		)
+		var loadErr error
+		loadResult, loadErr = node.LoadBlobsWithDB(
+			cfg, logger, result.ImmutableDir, db,
+		)
</file context>
Fix with Cubic

//
// Fallback (Byron and other eras): parses the full block via
// NewBlockFromCbor and locates output CBOR via byte search.
func postProcessUtxoOffsets(
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: postProcessUtxoOffsets does not accept a context.Context and cannot be cancelled. This function iterates over all ImmutableDB blocks (millions on mainnet) without any cancellation check. Add a ctx context.Context parameter and check ctx.Err() periodically (e.g., each batch commit) to allow graceful shutdown.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/dingo/mithril.go, line 883:

<comment>`postProcessUtxoOffsets` does not accept a `context.Context` and cannot be cancelled. This function iterates over all ImmutableDB blocks (millions on mainnet) without any cancellation check. Add a `ctx context.Context` parameter and check `ctx.Err()` periodically (e.g., each batch commit) to allow graceful shutdown.</comment>

<file context>
@@ -0,0 +1,1129 @@
+//
+// Fallback (Byron and other eras): parses the full block via
+// NewBlockFromCbor and locates output CBOR via byte search.
+func postProcessUtxoOffsets(
+	db *database.Database,
+	logger *slog.Logger,
</file context>
Fix with Cubic

chain/chain.go Outdated
}
// Notify waiting iterators and publish events only after
// the transaction has committed successfully.
if len(pendingEvents) > 0 {
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Bug: waitingChan iterator notification is skipped when eventBus is nil. The condition if len(pendingEvents) > 0 ties iterator notification to event generation, but events are only created when eventBus != nil. If eventBus is nil, blocks are added but waiting ChainIterators are never woken up. The notification should be unconditional after a successful commit, matching the pattern in addBlockLocked.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At chain/chain.go, line 411:

<comment>Bug: `waitingChan` iterator notification is skipped when `eventBus` is nil. The condition `if len(pendingEvents) > 0` ties iterator notification to event generation, but events are only created when `eventBus != nil`. If `eventBus` is nil, blocks are added but waiting `ChainIterator`s are never woken up. The notification should be unconditional after a successful commit, matching the pattern in `addBlockLocked`.</comment>

<file context>
@@ -277,6 +277,155 @@ func (c *Chain) AddBlocks(blocks []ledger.Block) error {
+		}
+		// Notify waiting iterators and publish events only after
+		// the transaction has committed successfully.
+		if len(pendingEvents) > 0 {
+			c.waitingChanMutex.Lock()
+			if c.waitingChan != nil {
</file context>
Fix with Cubic


// ParsedConstitution holds decoded constitution data.
type ParsedConstitution struct {
AnchorUrl string
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Go naming convention: AnchorUrl should be AnchorURL. Per Go's CodeReviewComments, well-known initialisms like URL should be all-caps in exported identifiers. This applies to both ParsedConstitution.AnchorUrl and ParsedGovProposal.AnchorUrl. The project's own linting setup (golangci-lint / stylecheck ST1003) would typically flag this.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ledgerstate/govstate.go, line 26:

<comment>Go naming convention: `AnchorUrl` should be `AnchorURL`. Per Go's [CodeReviewComments](https://go.dev/wiki/CodeReviewComments#initialisms), well-known initialisms like URL should be all-caps in exported identifiers. This applies to both `ParsedConstitution.AnchorUrl` and `ParsedGovProposal.AnchorUrl`. The project's own linting setup (`golangci-lint` / `stylecheck` ST1003) would typically flag this.</comment>

<file context>
@@ -0,0 +1,513 @@
+
+// ParsedConstitution holds decoded constitution data.
+type ParsedConstitution struct {
+	AnchorUrl  string
+	AnchorHash []byte // 32 bytes
+	PolicyHash []byte // 28 bytes, nil if no guardrails script
</file context>
Fix with Cubic

Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>
@wolf31o2 wolf31o2 force-pushed the feat/mithril-bootstrap branch from 1bf3e57 to 55fc5d4 Compare February 17, 2026 19:25
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (21)
mithril/types.go (1)

31-65: Consider extracting a shared base type for SnapshotListItem and Snapshot.

These two structs have identical fields. If the Mithril API ever adds detail-only fields to Snapshot, a shared embedded struct would reduce duplication while still allowing divergence.

type snapshotBase struct {
    Digest               string   `json:"digest"`
    Network              string   `json:"network"`
    // ... shared fields
}
type SnapshotListItem struct { snapshotBase }
type Snapshot struct { snapshotBase }

Not blocking — they may intentionally diverge as the API matures.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mithril/types.go` around lines 31 - 65, Both SnapshotListItem and Snapshot
duplicate the same fields; extract a shared embedded struct (e.g., snapshotBase)
containing the common fields and replace the two concrete types to embed that
base (retain Beacon, CreatedAt parsing method by moving or keeping CreatedAt on
snapshotBase so SnapshotListItem.CreatedAtTime() continues to work). Update type
declarations SnapshotListItem and Snapshot to embed snapshotBase and adjust any
code references to use the embedded fields (Digest, Network, CreatedAt, etc.) so
behavior is unchanged.
mithril/bootstrap.go (2)

646-667: hasFileRecursive only checks one level of subdirectories, not truly recursive.

The function name suggests arbitrary depth, but it only checks the immediate directory and its direct children. This is fine for the known UTxO-HD layout (ledger/<slot>/state), but the name is slightly misleading.

♻️ Consider renaming
-func hasFileRecursive(dir string, name string) bool {
+func hasFileShallow(dir string, name string) bool {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mithril/bootstrap.go` around lines 646 - 667, The function hasFileRecursive
is misleading because it only checks dir and one level of subdirectories; rename
hasFileRecursive to hasFileInDirOrImmediateSubdirs (or an equivalent name) and
update all callers to use the new name, or alternatively implement true
recursion inside hasFileRecursive by recursing into subdirectories (update
function signature and tests accordingly) so the name matches behavior; target
the function named hasFileRecursive and any callers referencing it.

236-283: Data race concern in ancillary goroutine — but safe due to WaitGroup synchronization.

ancillaryDir and ancillaryArchivePath are written from the goroutine (lines 255–256, 281–282) and read after ancWg.Wait() (line 317). The happens-before guarantee from WaitGroup.Wait() makes this safe, but the pattern is subtle. A brief comment noting this would help future readers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mithril/bootstrap.go` around lines 236 - 283, The writes to ancillaryDir and
ancillaryArchivePath happen inside the ancillary goroutine and are read after
ancWg.Wait(); add a short comment near the ancWg variable/goroutine (referencing
ancWg, ancWg.Wait(), ancillaryDir, ancillaryArchivePath, and the anonymous
goroutine) stating that the WaitGroup.Wait provides the necessary happens-before
synchronization so those assignments are safe and visible to the reader; keep
the comment brief and located where ancWg is declared or the goroutine is
launched.
internal/node/load.go (2)

177-207: Consider making the 30-minute timeout configurable or at least a named constant.

The 30-minute ledger catch-up timeout is hardcoded. For large mainnet snapshots this may be tight; for tests it's unnecessarily long. Extracting it as a constant (or a Config field with a default) would improve maintainability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/node/load.go` around lines 177 - 207, The hardcoded 30-minute
timeout in the ledger catch-up loop should be made configurable: replace the
literal time.After(30 * time.Minute) with a named constant or a Config field
(e.g., LedgerCatchupTimeout) and use that value when creating timeout; update
the surrounding logic in the function that calls copyBlocks / uses
immutableTipSlot and ls.Tip to read the default from a package-level constant if
no config provided, and ensure the new symbol (e.g., ledgerCatchupTimeout or
cfg.LedgerCatchupTimeout) is used in the timeout channel creation and documented
where defaults are set.

221-257: LoadBlobsWithDB accepts ctx but the full-decode LoadWithDB does not support cancellation.

copyBlocksRaw (used by LoadBlobsWithDB) checks ctx.Err() after each batch, but copyBlocks (used by LoadWithDB) has no context parameter and cannot be cancelled. If this is intentional (full load is always run to completion), consider documenting why. Otherwise, threading a context through copyBlocks would make the two paths consistent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/node/load.go` around lines 221 - 257, LoadBlobsWithDB accepts ctx
and uses copyBlocksRaw which checks ctx, but LoadWithDB calls copyBlocks (no
ctx) so full-decode can't be cancelled; update copyBlocks to accept a context
parameter and propagate ctx from LoadWithDB into the new copyBlocks(ctx, ...) so
it can check ctx.Err() between batches just like copyBlocksRaw, and update all
callers (including LoadWithDB and any tests) to pass through the context;
alternatively, if cancellation is intentionally unsupported, add a clear comment
on LoadWithDB and copyBlocks explaining why cancellation is omitted and ensure
signatures remain consistent.
database/models/import_checkpoint.go (1)

41-48: ImportPhaseOrder is an exported mutable slice.

Any caller can accidentally mutate ImportPhaseOrder (e.g., append or element reassignment), which would break IsPhaseCompleted for all consumers. Consider making it unexported or returning a copy from a function.

♻️ Option: make it a function
-var ImportPhaseOrder = []string{
+var importPhaseOrder = []string{
 	ImportPhaseUTxO,
 	ImportPhaseCertState,
 	ImportPhaseSnapshots,
 	ImportPhasePParams,
 	ImportPhaseGovState,
 	ImportPhaseTip,
 }
+
+func ImportPhaseOrder() []string {
+	out := make([]string, len(importPhaseOrder))
+	copy(out, importPhaseOrder)
+	return out
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@database/models/import_checkpoint.go` around lines 41 - 48, ImportPhaseOrder
is an exported mutable slice; make it immutable by renaming the variable to an
unexported importPhaseOrder and add an exported accessor function
ImportPhaseOrder() that returns a new slice copy (e.g., copy :=
make([]string,len(importPhaseOrder)); copy(copy, importPhaseOrder); return
copy). Update consumers such as IsPhaseCompleted to call the accessor instead of
referencing the variable directly so callers cannot mutate the underlying slice.
mithril/download.go (1)

200-203: No idle/read timeout on the download HTTP client.

Timeout: 0 disables the overall deadline, which is correct for large multi-GB downloads. However, there's no protection against a stalled TCP connection where the server stops sending data. Consider adding a Transport with ResponseHeaderTimeout or wrapping resp.Body with a deadline-resetting reader so that a completely stalled transfer eventually fails rather than hanging indefinitely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mithril/download.go` around lines 200 - 203, The HTTP client created in
variable client in download.go has Timeout: 0 which leaves transfers vulnerable
to stalled TCP connections; modify the client to use a custom http.Transport
(assigned to client.Transport) that sets sensible timeouts such as
ResponseHeaderTimeout and IdleConnTimeout (and optionally TLSHandshakeTimeout)
to ensure stalled servers fail fast, and/or ensure the download reader returned
from resp.Body is wrapped with a deadline-resetting or context-aware reader to
enforce per-chunk read deadlines; update the client creation code (where client
:= &http.Client{...} and CheckRedirect: httpsOnlyRedirect is set) to install
this Transport and/or wrap resp.Body in the downloader function so long-running
large downloads still fail on complete stalls.
ledgerstate/utxo.go (1)

589-598: hex.EncodeToString result stored as []byte — consider storing as string if the model supports it.

Line 595: NameHex: []byte(hex.EncodeToString(a.Name)) converts the hex string to bytes. This works but is a roundabout allocation. Verify that models.Asset.NameHex is []byte and not string.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ledgerstate/utxo.go` around lines 589 - 598, The code currently sets NameHex
with []byte(hex.EncodeToString(a.Name)) inside the assets conversion loop;
please check the type of models.Asset.NameHex and adjust the assignment
accordingly: if NameHex is a string, assign hex.EncodeToString(a.Name) directly
(in the utxo.Assets append), otherwise (if NameHex is []byte) keep the
conversion but consider using a single conversion expression; update the
models.Asset usage in the conversion loop (the utxo.Assets append where
PolicyId/Name/NameHex/Amount/Fingerprint are set) to match the actual field
type.
database/sync_state.go (1)

19-61: Consider wrapping errors with context at the Database layer.

The methods delegate directly to d.metadata without adding context. While the underlying metadata implementations already wrap their errors, adding a thin layer here would help callers distinguish which layer failed. That said, this follows the same pattern as other Database methods (e.g., GetCommitTimestamp), so it's consistent with the existing codebase.

As per coding guidelines: "Wrap errors with context using fmt.Errorf with %w verb".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@database/sync_state.go` around lines 19 - 61, The Database layer should wrap
errors with context before returning; update GetSyncState, SetSyncState,
DeleteSyncState, and ClearSyncState to capture the result and error from calls
to d.metadata (both nil-txn and txn.Metadata() paths), and if err != nil return
a wrapped error using fmt.Errorf with %w (e.g.
fmt.Errorf("Database.GetSyncState: %w", err)); ensure GetSyncState returns the
zero-value string plus the wrapped error on failure, and the other methods
return the wrapped error directly while returning the original value on success.
ledgerstate/certstate.go (1)

97-196: Heuristic-based Conway CertState parsing is fragile but pragmatically necessary.

The strategy of identifying PState (largest array) and DState (largest map) by CBOR byte size works for known layouts but could misidentify components if future node versions change element sizes or add new large structures. The comments documenting the expected layout are helpful.

One subtle point on lines 158-164: the VState DRep map search requires len(dreps) > 0 to accept a candidate map. If the DRep registry is genuinely empty on-chain, no element will match and result.DReps will remain nil. This is likely fine for disambiguation purposes, but worth a brief comment clarifying that an empty DRep registry is indistinguishable from "not found" in this path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ledgerstate/certstate.go` around lines 97 - 196, The VState DRep map search
in parseCertStateConway uses parseDRepMap and only accepts candidates when
len(dreps) > 0, which makes an actually-empty on-chain DRep registry
indistinguishable from “not found”; update the VState parsing block to include a
short clarifying comment next to the parseDRepMap call (or alternatively
explicitly set result.DReps = []DRep{} when vErr == nil and len(dreps) == 0 if
you want to treat an empty registry as present) so readers see the intended
semantics for parseDRepMap and result.DReps.
ledgerstate/import.go (2)

564-658: Pool import runs in a single transaction with no batching.

Unlike importAccounts (which batches at 10k) and importUTxOs (which uses batch callbacks), importPools commits all pools in a single transaction. On mainnet with ~3,000+ pools this is likely fine, but the pattern is inconsistent with the rest of the import pipeline.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ledgerstate/import.go` around lines 564 - 658, importPools currently opens
one long metadata transaction and imports all pools in it; change it to batch
the work into smaller transactions (like importAccounts does) by iterating pools
in chunks (e.g., batchSize = 1000 or reuse 10_000 if desired), and for each
batch create a new txn via cfg.Database.MetadataTxn(true), use
txn.Release()/txn.Commit() per-batch, call store.ImportPool(model, reg,
txn.Metadata()) for each pool in the batch, handle ctx cancellation between
batches, and return errors immediately on ImportPool failures; ensure you remove
the single long-lived txn and instead create/release/commit a transaction per
batch to match the rest of the import pipeline.

1344-1370: Replace string matching with GORM's portable gorm.ErrDuplicatedKey error constant.

GORM v1.31.1 provides gorm.ErrDuplicatedKey that can be checked via errors.Is(err, gorm.ErrDuplicatedKey) when TranslateError: true is configured on the database connection. This eliminates the fragility of parsing error message strings across SQLite, MySQL, and PostgreSQL driver versions.

The current string-matching approach will silently break if any driver changes its error format. Either migrate to use GORM's error translation mechanism, or add tests that verify these error strings remain stable across driver versions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ledgerstate/import.go` around lines 1344 - 1370, Replace fragile string
matching in isEpochDuplicate by first checking errors.Is(err,
gorm.ErrDuplicatedKey) (importing "errors" and "gorm.io/gorm") which will be
true when DB is configured with TranslateError: true; if you want to be backward
compatible, keep the existing string-based checks as a fallback after the
errors.Is check. Update isEpochDuplicate to reference gorm.ErrDuplicatedKey and
ensure imports are added.
ledgerstate/govstate.go (1)

140-142: Three-return-value pattern is unconventional but works here.

parseConstitution returning (*ParsedConstitution, error, error) for (result, warning, fatal_error) is unusual in Go. A more idiomatic approach would be a result struct with a Warnings []error field, or a named error type. Since this is a private helper with a single call site, it's acceptable — just worth noting if more callers are added later.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ledgerstate/govstate.go` around lines 140 - 142, parseConstitution currently
returns (*ParsedConstitution, error, error) (result, warning, fatal) which is
unconventional; change it to return a single result type that carries warnings
instead—e.g. define a small result struct (ParseConstitutionResult) containing
Parsed *ParsedConstitution and Warnings []error, then update parseConstitution
to return (ParseConstitutionResult, error) and adjust its single caller to read
result. This keeps the fatal error as the returned error and moves the warning
into result.Warnings for idiomatic handling while preserving behavior; update
references to parseConstitution and ParsedConstitution accordingly.
ledgerstate/import_test.go (1)

197-208: Consider using t.Logf instead of fmt.Fprintf(os.Stderr, ...).

The rest of the test uses t.Logf for output. This final summary block writes directly to stderr, which bypasses Go's test output buffering (e.g., output won't be suppressed by go test without -v). If the intent is to always show this summary, it's fine — but it's inconsistent with the rest of the file.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ledgerstate/import_test.go` around lines 197 - 208, The test prints a final
summary using fmt.Fprintf(os.Stderr, ...) which bypasses Go test logging;
replace that call with t.Logf(...) so output goes through the test logger (use
the same format string and the same values: ledgerstate.EraName(state.EraIndex),
state.Epoch, state.Tip.Slot, state.Tip.BlockHash) to be consistent with other
logs in the file and respect `go test` output buffering.
ledgerstate/snapshot.go (3)

216-223: ParseSnapshot reads the entire file into memory.

os.ReadFile(path) loads the full ledger state, which can be several GB on mainnet. This is acceptable for a one-time bootstrap operation, but worth documenting in the function's doc comment so callers are aware of the memory requirement.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ledgerstate/snapshot.go` around lines 216 - 223, The ParseSnapshot function
reads the entire snapshot file into memory using os.ReadFile which can be
multiple GB; add a doc comment above ParseSnapshot explaining that it loads the
full file into memory, warn callers about high memory usage for large mainnet
snapshots, note that it delegates to parseSnapshotData and that callers should
use alternatives if streaming/low-memory behavior is required.

365-391: Version-wrapper peeling loop is clever but could benefit from a depth guard.

The loop peels nested version prefixes ([version, [version2, [...]]]) until it reaches the actual data. While finite CBOR size guarantees termination, a small iteration cap (e.g., 10) would guard against pathological inputs and make the intent clearer.

🛡️ Suggested guard
+	const maxVersionLayers = 10
+	layers := 0
 	for len(shelleyState) >= 2 {
+		if layers >= maxVersionLayers {
+			return nil, fmt.Errorf(
+				"exceeded %d version wrapper layers",
+				maxVersionLayers,
+			)
+		}
 		var ssVersion uint64
 		if _, decErr := cbor.Decode(
 			shelleyState[0], &ssVersion,
 		); decErr != nil {
 			break
 		}
+		layers++
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ledgerstate/snapshot.go` around lines 365 - 391, The loop that peels nested
version wrappers from shelleyState (using ssVersion and decodeRawArray) needs a
defensive depth guard to avoid pathological infinite or excessive nesting; add a
small maxDepth constant (e.g., 10), track iterations with a counter, increment
each loop iteration, and if the counter exceeds maxDepth return a clear error
(e.g., "too many nested version wrappers") instead of continuing forever. Ensure
the guard is applied around the existing for loop that inspects shelleyState and
ssVersion so normal short nesting still behaves the same but overly deep inputs
fail fast.

444-456: Silent zero-default for treasury/reserves when AccountState has fewer than 2 elements.

If len(acctState) < 2, both treasury and reserves silently default to 0. For a bootstrap import, this could mask a malformed snapshot. Consider logging a warning or returning an error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ledgerstate/snapshot.go` around lines 444 - 456, The code decodes
AccountState into acctState and silently leaves treasury and reserves as zero
when len(acctState) < 2; change this to validate that acctState has at least 2
elements after cbor.Decode and return a descriptive error (e.g.,
fmt.Errorf("invalid AccountState: expected >=2 elements, got %d",
len(acctState))) instead of allowing silent zero defaults so callers of this
snapshot parsing (the acctState/treasury/reserves logic) will fail fast on
malformed snapshots.
cmd/dingo/mithril.go (4)

909-912: Consider documenting batchBlocks tuning rationale.

batchBlocks = 100 directly controls blob-store write amplification and memory pressure. A short comment explaining why 100 was chosen (or that it's empirically tuned) would help future maintainers adjust it confidently.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/dingo/mithril.go` around lines 909 - 912, Add a short explanatory comment
next to the const batchBlocks = 100 declaration in mithril.go describing why 100
was chosen (e.g., empirically tuned), what it controls (blob-store write
amplification and memory pressure), and guidance for adjusting it (trade-offs:
larger values reduce IO but increase memory and write amplification; smaller
values lower memory but increase IO). Reference the constant name batchBlocks so
maintainers can quickly find and tune it.

231-246: Missing error context on aggregator URL resolution.

Line 244 returns the bare error from AggregatorURLForNetwork. Per coding guidelines, wrap with context:

Proposed fix
 		aggregatorURL, err = mithril.AggregatorURLForNetwork(
 			network,
 		)
 		if err != nil {
-			return err
+			return fmt.Errorf("resolving aggregator URL: %w", err)
 		}

The same pattern appears in mithrilListCommand (line 84) and mithrilShowCommand (line 157). As per coding guidelines, **/*.go: Wrap errors with context using fmt.Errorf with %w verb.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/dingo/mithril.go` around lines 231 - 246, Wrap the bare error returned
from mithril.AggregatorURLForNetwork in runMithrilSync using fmt.Errorf and the
%w verb to add context (e.g., "failed to resolve aggregator URL for network %s:
%w", network, err); do the same for the identical calls in mithrilListCommand
and mithrilShowCommand so each returns a wrapped error that includes the network
and a clear failure message instead of the raw error.

636-654: Unwrapped error return from ImportLedgerState.

Line 653 returns the error from ImportLedgerState without adding context. As per coding guidelines, errors should be wrapped with fmt.Errorf and %w.

Proposed fix
 	); err != nil {
-		return 0, nil, err
+		return 0, nil, fmt.Errorf("importing ledger state: %w", err)
 	}

As per coding guidelines, **/*.go: Wrap errors with context using fmt.Errorf with %w verb.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/dingo/mithril.go` around lines 636 - 654, The error returned from
ledgerstate.ImportLedgerState is returned directly; wrap it with fmt.Errorf
using %w to add context before returning. In the block where
ledgerstate.ImportLedgerState(...) is called (inside the function handling the
Mithril import), replace the bare return of err with something like returning
fmt.Errorf("failed to import ledger state: %w", err) so callers get contextual
information; ensure you import "fmt" if not already present.

62-132: Network-default logic duplicated across commands.

The if network == "" { network = "preview" } pattern appears in mithrilListCommand (line 73), mithrilShowCommand (line 146), mithrilSyncCommand (line 221), and syncCommand (line 517). Consider extracting a small helper (e.g., resolveNetwork(cfg) string) to centralize the default and avoid silent divergence if the default ever changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/dingo/mithril.go` around lines 62 - 132, Duplicate network-default logic
exists in mithrilListCommand, mithrilShowCommand, mithrilSyncCommand and
syncCommand; extract a small helper (e.g., resolveNetwork(cfg *config.Type)
string) that returns cfg.Network if non-empty or "preview" otherwise, replace
the inline if network == "" { network = "preview" } blocks in those functions
with a call to resolveNetwork(cfg), and update any callers to use the returned
value; keep the helper name and signature unique and referenced in each of the
four functions (mithrilListCommand, mithrilShowCommand, mithrilSyncCommand,
syncCommand) so the default is centralized.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@database/plugin/metadata/sqlite/network_state.go`:
- Around line 58-77: GetNetworkState is performing a read-only query but calls
resolveDB; change it to use resolveReadDB() so the method uses the
read-optimized connection pool (the same pattern used by
GetSyncState/GetTip/GetAccount). Replace the call to resolveDB(txn) with
resolveReadDB(txn) (which returns d.ReadDB()) inside GetNetworkState and keep
the existing error handling and return behavior unchanged.

In `@ledgerstate/import.go`:
- Around line 558-561: The final batch commit in ledgerstate/import.go currently
returns txn.Commit() raw when inBatch > 0; change it to capture the error from
txn.Commit(), wrap it with context using fmt.Errorf and the %w verb (e.g.,
"commit final batch: %w"), and return the wrapped error; reference the inBatch
check and the txn.Commit() call and ensure you import fmt if not already
imported.
- Around line 514-561: importAccounts leaks intermediate MetadataTxn resources
because after committing a batch the old txn is replaced without calling
txn.Release(); before creating a new txn in the loop (the assignment txn =
cfg.Database.MetadataTxn(true) in importAccounts), call txn.Release() on the
committed transaction to free resources; keep the existing defer txn.Release()
for the final txn, and follow the same pattern used in importUTxOs
(create/release per batch) so every created MetadataTxn is either Release()d
after Commit() or by the existing defer.

In `@ledgerstate/snapshot.go`:
- Around line 583-603: The legacy fallback in parseTip that decodes a 2-element
[slot, hash] from tipArr assumes tipArr has at least 2 items; add an explicit
length validation on tipArr before decoding (e.g., require len(tipArr) == 2 for
the legacy branch) and return a clear error (or log a warning) if the array
length is unexpected so we don't treat a third element (like blockNo) as the
hash; update the logic around SnapshotTip construction in parseTip to only
decode tipArr[0] and tipArr[1] when that length check passes.

In `@ledgerstate/utxo.go`:
- Around line 578-601: UTxOToModel currently drops datum and script reference
data from ParsedUTxO; update UTxOToModel to copy the ParsedUTxO.Cbor into
models.Utxo.Cbor and persist relationships for datum and scripts: when
ParsedUTxO.DatumHash/Datum is present, create or link a models.Datum (populate
hash and raw CBOR/data) and set the Utxo's datum reference fields accordingly;
when ParsedUTxO.ScriptRef is present, create or link the appropriate
WitnessScripts model entries and set the Utxo's script reference fields; mirror
the approach used in UtxoLedgerToModel for populating Cbor and establishing
Datum/WitnessScripts associations so snapshot imports do not discard datum or
script data.

---

Duplicate comments:
In `@chain/chain.go`:
- Around line 375-426: The txn.Do error returned inside AddRawBlocks should be
wrapped with contextual batch information before returning; replace the bare
"return err" after calling txn.Do (the call assigned to err variable from
txn.Do) with a wrapped error using fmt.Errorf and the %w verb that includes
batchOffset and batchSize (or an equivalent descriptor) so callers get batch
context; ensure you import fmt if not present and keep the original error
wrapped (e.g., fmt.Errorf("AddRawBlocks batch offset=%d size=%d: %w",
batchOffset, batchSize, err)).
- Around line 297-373: The function addRawBlock lacks validation for empty
rb.Hash or rb.PrevHash which lets malformed RawBlock values pass the header/tip
comparisons; add an explicit guard early in addRawBlock (before the header and
tip checks, after reconcile) that checks if len(rb.Hash) == 0 or
len(rb.PrevHash) == 0 and return a clear error (e.g., errors.New("raw block has
empty Hash/PrevHash") or a domain-specific NewMalformedBlockError) so malformed
blocks are rejected before persisting or mutating c.headers/c.currentTip.

In `@ledgerstate/mempack.go`:
- Around line 448-451: The Script decoding currently uses
r.readLengthPrefixedBytes() which returns the entire MemPack blob including the
AlonzoScript tag, so update the decoder in the Script/ScriptRef handling to
first read and consume the tag discriminator (e.g., call r.readByte() or a
length-prefixed tag reader), dispatch on that tag to separate NativeScript vs
PlutusScript payloads, then read/assign only the decoded script payload into
ScriptRef (or if you intend ScriptRef to remain an opaque blob add a
comment/docstring on the ScriptRef field and decoder to state that it includes
the tag). Ensure you modify where readLengthPrefixedBytes() is invoked in this
path and reference the ScriptRef field and the decoder function that calls
r.readLengthPrefixedBytes() so downstream consumers see the tag-stripped payload
or documented behavior.
- Around line 541-559: The function decodeFlatMultiAsset lacks an upper-bound
check on numAssets so multiplications like numAssets*8 can overflow or cause
excessive allocations; before computing
quantitiesEnd/pidOffsetsEnd/nameOffsetsEnd, validate numAssets against a safe
maximum derived from the input length and integer limits (e.g., ensure numAssets
is non-negative and numAssets <= len(flat)/minimumBytesPerAsset and also check
using a larger integer type or compare against math.MaxInt/8 to prevent
overflow), and return an error if it exceeds that cap; update the checks around
quantitiesEnd, pidOffsetsEnd and nameOffsetsEnd in decodeFlatMultiAsset to use
the overflow-safe calculations and bail early on invalid numAssets.

In `@ledgerstate/snapshot.go`:
- Around line 53-89: The code collects directory names into utxoHDDirs without
ensuring they are numeric, so strconv.ParseUint later treats non-numeric names
as 0; fix by validating names before appending: in the loop that detects
statePath, call strconv.ParseUint(name, 10, 64) and only append name (or its
numeric value) if err == nil; alternatively store parsed uint64s in a parallel
slice or map and then use slices.SortFunc on those numeric values (or sort by
the parsed numbers) so non-numeric directories are ignored by functions using
ParseUint (references: utxoHDDirs, ParseUint, slices.SortFunc, ledgerDir).

In `@ledgerstate/snapshots.go`:
- Around line 294-299: AggregatePoolStake currently dereferences snap (e.g.,
snap.Delegations) without guarding against a nil ParsedSnapShot; add an early
nil check at the start of AggregatePoolStake that returns an empty
[]*models.PoolStakeSnapshot (or nil per project convention) if snap == nil to
avoid a panic. Locate the AggregatePoolStake function and add the guard before
any access to snap.Delegations or other snap fields, referencing the
ParsedSnapShot type and the snap parameter.

In `@mithril/client.go`:
- Around line 189-202: GetLatestSnapshot currently assumes the aggregator
returns snapshots ordered; update it to be deterministic by fetching the slice
from ListSnapshots then sorting it client-side before selecting the latest: sort
by SnapshotListItem.Beacon.Epoch descending and use
SnapshotListItem.Beacon.ImmutableFileNumber descending as a tie-breaker, then
return the first element; ensure you import and use sort.Slice (or equivalent)
and keep error handling from ListSnapshots intact.

In `@README.md`:
- Around line 35-36: Clarify the linkage between the sample config file and the
env-var sentence: replace or follow the sentence mentioning `dingo.yaml.example`
with a short transitional sentence such as "Configuration may also be controlled
via environment variables, which can override or supplement settings in
dingo.yaml.example." and then keep "This behavior can be changed via the
following environment variables:" so the antecedent of "This behavior" clearly
refers to using environment variables to alter configuration; target the README
lines mentioning `dingo.yaml.example` and the sentence "This behavior can be
changed via the following environment variables:" when applying the change.

---

Nitpick comments:
In `@cmd/dingo/mithril.go`:
- Around line 909-912: Add a short explanatory comment next to the const
batchBlocks = 100 declaration in mithril.go describing why 100 was chosen (e.g.,
empirically tuned), what it controls (blob-store write amplification and memory
pressure), and guidance for adjusting it (trade-offs: larger values reduce IO
but increase memory and write amplification; smaller values lower memory but
increase IO). Reference the constant name batchBlocks so maintainers can quickly
find and tune it.
- Around line 231-246: Wrap the bare error returned from
mithril.AggregatorURLForNetwork in runMithrilSync using fmt.Errorf and the %w
verb to add context (e.g., "failed to resolve aggregator URL for network %s:
%w", network, err); do the same for the identical calls in mithrilListCommand
and mithrilShowCommand so each returns a wrapped error that includes the network
and a clear failure message instead of the raw error.
- Around line 636-654: The error returned from ledgerstate.ImportLedgerState is
returned directly; wrap it with fmt.Errorf using %w to add context before
returning. In the block where ledgerstate.ImportLedgerState(...) is called
(inside the function handling the Mithril import), replace the bare return of
err with something like returning fmt.Errorf("failed to import ledger state:
%w", err) so callers get contextual information; ensure you import "fmt" if not
already present.
- Around line 62-132: Duplicate network-default logic exists in
mithrilListCommand, mithrilShowCommand, mithrilSyncCommand and syncCommand;
extract a small helper (e.g., resolveNetwork(cfg *config.Type) string) that
returns cfg.Network if non-empty or "preview" otherwise, replace the inline if
network == "" { network = "preview" } blocks in those functions with a call to
resolveNetwork(cfg), and update any callers to use the returned value; keep the
helper name and signature unique and referenced in each of the four functions
(mithrilListCommand, mithrilShowCommand, mithrilSyncCommand, syncCommand) so the
default is centralized.

In `@database/models/import_checkpoint.go`:
- Around line 41-48: ImportPhaseOrder is an exported mutable slice; make it
immutable by renaming the variable to an unexported importPhaseOrder and add an
exported accessor function ImportPhaseOrder() that returns a new slice copy
(e.g., copy := make([]string,len(importPhaseOrder)); copy(copy,
importPhaseOrder); return copy). Update consumers such as IsPhaseCompleted to
call the accessor instead of referencing the variable directly so callers cannot
mutate the underlying slice.

In `@database/sync_state.go`:
- Around line 19-61: The Database layer should wrap errors with context before
returning; update GetSyncState, SetSyncState, DeleteSyncState, and
ClearSyncState to capture the result and error from calls to d.metadata (both
nil-txn and txn.Metadata() paths), and if err != nil return a wrapped error
using fmt.Errorf with %w (e.g. fmt.Errorf("Database.GetSyncState: %w", err));
ensure GetSyncState returns the zero-value string plus the wrapped error on
failure, and the other methods return the wrapped error directly while returning
the original value on success.

In `@internal/node/load.go`:
- Around line 177-207: The hardcoded 30-minute timeout in the ledger catch-up
loop should be made configurable: replace the literal time.After(30 *
time.Minute) with a named constant or a Config field (e.g.,
LedgerCatchupTimeout) and use that value when creating timeout; update the
surrounding logic in the function that calls copyBlocks / uses immutableTipSlot
and ls.Tip to read the default from a package-level constant if no config
provided, and ensure the new symbol (e.g., ledgerCatchupTimeout or
cfg.LedgerCatchupTimeout) is used in the timeout channel creation and documented
where defaults are set.
- Around line 221-257: LoadBlobsWithDB accepts ctx and uses copyBlocksRaw which
checks ctx, but LoadWithDB calls copyBlocks (no ctx) so full-decode can't be
cancelled; update copyBlocks to accept a context parameter and propagate ctx
from LoadWithDB into the new copyBlocks(ctx, ...) so it can check ctx.Err()
between batches just like copyBlocksRaw, and update all callers (including
LoadWithDB and any tests) to pass through the context; alternatively, if
cancellation is intentionally unsupported, add a clear comment on LoadWithDB and
copyBlocks explaining why cancellation is omitted and ensure signatures remain
consistent.

In `@ledgerstate/certstate.go`:
- Around line 97-196: The VState DRep map search in parseCertStateConway uses
parseDRepMap and only accepts candidates when len(dreps) > 0, which makes an
actually-empty on-chain DRep registry indistinguishable from “not found”; update
the VState parsing block to include a short clarifying comment next to the
parseDRepMap call (or alternatively explicitly set result.DReps = []DRep{} when
vErr == nil and len(dreps) == 0 if you want to treat an empty registry as
present) so readers see the intended semantics for parseDRepMap and
result.DReps.

In `@ledgerstate/govstate.go`:
- Around line 140-142: parseConstitution currently returns (*ParsedConstitution,
error, error) (result, warning, fatal) which is unconventional; change it to
return a single result type that carries warnings instead—e.g. define a small
result struct (ParseConstitutionResult) containing Parsed *ParsedConstitution
and Warnings []error, then update parseConstitution to return
(ParseConstitutionResult, error) and adjust its single caller to read result.
This keeps the fatal error as the returned error and moves the warning into
result.Warnings for idiomatic handling while preserving behavior; update
references to parseConstitution and ParsedConstitution accordingly.

In `@ledgerstate/import_test.go`:
- Around line 197-208: The test prints a final summary using
fmt.Fprintf(os.Stderr, ...) which bypasses Go test logging; replace that call
with t.Logf(...) so output goes through the test logger (use the same format
string and the same values: ledgerstate.EraName(state.EraIndex), state.Epoch,
state.Tip.Slot, state.Tip.BlockHash) to be consistent with other logs in the
file and respect `go test` output buffering.

In `@ledgerstate/import.go`:
- Around line 564-658: importPools currently opens one long metadata transaction
and imports all pools in it; change it to batch the work into smaller
transactions (like importAccounts does) by iterating pools in chunks (e.g.,
batchSize = 1000 or reuse 10_000 if desired), and for each batch create a new
txn via cfg.Database.MetadataTxn(true), use txn.Release()/txn.Commit()
per-batch, call store.ImportPool(model, reg, txn.Metadata()) for each pool in
the batch, handle ctx cancellation between batches, and return errors
immediately on ImportPool failures; ensure you remove the single long-lived txn
and instead create/release/commit a transaction per batch to match the rest of
the import pipeline.
- Around line 1344-1370: Replace fragile string matching in isEpochDuplicate by
first checking errors.Is(err, gorm.ErrDuplicatedKey) (importing "errors" and
"gorm.io/gorm") which will be true when DB is configured with TranslateError:
true; if you want to be backward compatible, keep the existing string-based
checks as a fallback after the errors.Is check. Update isEpochDuplicate to
reference gorm.ErrDuplicatedKey and ensure imports are added.

In `@ledgerstate/snapshot.go`:
- Around line 216-223: The ParseSnapshot function reads the entire snapshot file
into memory using os.ReadFile which can be multiple GB; add a doc comment above
ParseSnapshot explaining that it loads the full file into memory, warn callers
about high memory usage for large mainnet snapshots, note that it delegates to
parseSnapshotData and that callers should use alternatives if
streaming/low-memory behavior is required.
- Around line 365-391: The loop that peels nested version wrappers from
shelleyState (using ssVersion and decodeRawArray) needs a defensive depth guard
to avoid pathological infinite or excessive nesting; add a small maxDepth
constant (e.g., 10), track iterations with a counter, increment each loop
iteration, and if the counter exceeds maxDepth return a clear error (e.g., "too
many nested version wrappers") instead of continuing forever. Ensure the guard
is applied around the existing for loop that inspects shelleyState and ssVersion
so normal short nesting still behaves the same but overly deep inputs fail fast.
- Around line 444-456: The code decodes AccountState into acctState and silently
leaves treasury and reserves as zero when len(acctState) < 2; change this to
validate that acctState has at least 2 elements after cbor.Decode and return a
descriptive error (e.g., fmt.Errorf("invalid AccountState: expected >=2
elements, got %d", len(acctState))) instead of allowing silent zero defaults so
callers of this snapshot parsing (the acctState/treasury/reserves logic) will
fail fast on malformed snapshots.

In `@ledgerstate/utxo.go`:
- Around line 589-598: The code currently sets NameHex with
[]byte(hex.EncodeToString(a.Name)) inside the assets conversion loop; please
check the type of models.Asset.NameHex and adjust the assignment accordingly: if
NameHex is a string, assign hex.EncodeToString(a.Name) directly (in the
utxo.Assets append), otherwise (if NameHex is []byte) keep the conversion but
consider using a single conversion expression; update the models.Asset usage in
the conversion loop (the utxo.Assets append where
PolicyId/Name/NameHex/Amount/Fingerprint are set) to match the actual field
type.

In `@mithril/bootstrap.go`:
- Around line 646-667: The function hasFileRecursive is misleading because it
only checks dir and one level of subdirectories; rename hasFileRecursive to
hasFileInDirOrImmediateSubdirs (or an equivalent name) and update all callers to
use the new name, or alternatively implement true recursion inside
hasFileRecursive by recursing into subdirectories (update function signature and
tests accordingly) so the name matches behavior; target the function named
hasFileRecursive and any callers referencing it.
- Around line 236-283: The writes to ancillaryDir and ancillaryArchivePath
happen inside the ancillary goroutine and are read after ancWg.Wait(); add a
short comment near the ancWg variable/goroutine (referencing ancWg,
ancWg.Wait(), ancillaryDir, ancillaryArchivePath, and the anonymous goroutine)
stating that the WaitGroup.Wait provides the necessary happens-before
synchronization so those assignments are safe and visible to the reader; keep
the comment brief and located where ancWg is declared or the goroutine is
launched.

In `@mithril/download.go`:
- Around line 200-203: The HTTP client created in variable client in download.go
has Timeout: 0 which leaves transfers vulnerable to stalled TCP connections;
modify the client to use a custom http.Transport (assigned to client.Transport)
that sets sensible timeouts such as ResponseHeaderTimeout and IdleConnTimeout
(and optionally TLSHandshakeTimeout) to ensure stalled servers fail fast, and/or
ensure the download reader returned from resp.Body is wrapped with a
deadline-resetting or context-aware reader to enforce per-chunk read deadlines;
update the client creation code (where client := &http.Client{...} and
CheckRedirect: httpsOnlyRedirect is set) to install this Transport and/or wrap
resp.Body in the downloader function so long-running large downloads still fail
on complete stalls.

In `@mithril/types.go`:
- Around line 31-65: Both SnapshotListItem and Snapshot duplicate the same
fields; extract a shared embedded struct (e.g., snapshotBase) containing the
common fields and replace the two concrete types to embed that base (retain
Beacon, CreatedAt parsing method by moving or keeping CreatedAt on snapshotBase
so SnapshotListItem.CreatedAtTime() continues to work). Update type declarations
SnapshotListItem and Snapshot to embed snapshotBase and adjust any code
references to use the embedded fields (Digest, Network, CreatedAt, etc.) so
behavior is unchanged.

Comment on lines +58 to +77
func (d *MetadataStoreSqlite) GetNetworkState(
txn types.Txn,
) (*models.NetworkState, error) {
db, err := d.resolveDB(txn)
if err != nil {
return nil, err
}
var state models.NetworkState
result := db.Order("slot DESC").First(&state)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, fmt.Errorf(
"get network state: %w",
result.Error,
)
}
return &state, nil
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, locate the sync_state.go file and check GetSyncState
fd -t f "sync_state.go" database/plugin/metadata/sqlite/

Repository: blinklabs-io/dingo

Length of output: 107


🏁 Script executed:

#!/bin/bash
# Check the GetSyncState method in sync_state.go
rg -n "GetSyncState" database/plugin/metadata/sqlite/sync_state.go -A 15 | head -30

Repository: blinklabs-io/dingo

Length of output: 573


🏁 Script executed:

#!/bin/bash
# Check all Get methods in the SQLite metadata plugin and their DB resolution
fd -t f "\.go$" database/plugin/metadata/sqlite/ --exec rg -l "func.*MetadataStoreSqlite.*Get"

Repository: blinklabs-io/dingo

Length of output: 984


🏁 Script executed:

#!/bin/bash
# Check all Get methods in network_state.go for their DB resolution patterns
rg -n "func.*MetadataStoreSqlite.*Get" database/plugin/metadata/sqlite/network_state.go -A 10

Repository: blinklabs-io/dingo

Length of output: 417


🏁 Script executed:

#!/bin/bash
# Check patterns across multiple SQLite files - see what Get methods use
rg -n "func.*MetadataStoreSqlite.*Get" database/plugin/metadata/sqlite/ -A 8 | grep -E "(func|resolve(Read)?DB)" | head -50

Repository: blinklabs-io/dingo

Length of output: 4487


🏁 Script executed:

#!/bin/bash
# Get a broader overview of resolve methods usage patterns in Get methods
for file in database/plugin/metadata/sqlite/{sync_state,epoch,tip,account,pparams}.go; do
  if [ -f "$file" ]; then
    echo "=== $(basename $file) ==="
    rg -n "func.*MetadataStoreSqlite.*Get" "$file" -A 8 | grep -E "(func|resolve(Read)?DB)"
  fi
done

Repository: blinklabs-io/dingo

Length of output: 804


🏁 Script executed:

#!/bin/bash
# Check GetEpoch to see if there's a pattern/reason for some Get methods using resolveDB
rg -n "func.*MetadataStoreSqlite.*GetEpoch" database/plugin/metadata/sqlite/epoch.go -A 15 | head -40

Repository: blinklabs-io/dingo

Length of output: 1064


🏁 Script executed:

#!/bin/bash
# Check GetTransactionHashesAfterSlot and GetPoolByVrfKeyHash - the other methods using resolveDB
rg -n "GetTransactionHashesAfterSlot|GetPoolByVrfKeyHash" database/plugin/metadata/sqlite/ -A 8 | head -40

Repository: blinklabs-io/dingo

Length of output: 1790


🏁 Script executed:

#!/bin/bash
# Check what resolveDB and resolveReadDB do - are there different implementations?
fd -t f "\.go$" database/plugin/metadata/sqlite/ --exec rg -l "func.*resolveDB|func.*resolveReadDB" | head -5

Repository: blinklabs-io/dingo

Length of output: 108


🏁 Script executed:

#!/bin/bash
# Find the definitions of these methods
rg -n "func \(d \*MetadataStoreSqlite\) resolve(Read)?DB" database/plugin/metadata/sqlite/ -A 8

Repository: blinklabs-io/dingo

Length of output: 1388


🏁 Script executed:

#!/bin/bash
# Get the full implementations of both methods
rg -n "func \(d \*MetadataStoreSqlite\) resolve(Read)?DB" database/plugin/metadata/sqlite/transaction.go -A 20

Repository: blinklabs-io/dingo

Length of output: 1235


GetNetworkState should use resolveReadDB instead of resolveDB.

This is a read-only query that should use the read-optimized connection pool via resolveReadDB(), consistent with other Get* methods in the codebase (e.g., GetSyncState, GetTip, GetAccount). The resolveReadDB() method returns d.ReadDB() which uses a separate read connection pool optimized for file-based databases, while resolveDB() returns the standard write-capable connection pool.

♻️ Proposed fix
 func (d *MetadataStoreSqlite) GetNetworkState(
 	txn types.Txn,
 ) (*models.NetworkState, error) {
-	db, err := d.resolveDB(txn)
+	db, err := d.resolveReadDB(txn)
 	if err != nil {
 		return nil, err
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (d *MetadataStoreSqlite) GetNetworkState(
txn types.Txn,
) (*models.NetworkState, error) {
db, err := d.resolveDB(txn)
if err != nil {
return nil, err
}
var state models.NetworkState
result := db.Order("slot DESC").First(&state)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, fmt.Errorf(
"get network state: %w",
result.Error,
)
}
return &state, nil
}
func (d *MetadataStoreSqlite) GetNetworkState(
txn types.Txn,
) (*models.NetworkState, error) {
db, err := d.resolveReadDB(txn)
if err != nil {
return nil, err
}
var state models.NetworkState
result := db.Order("slot DESC").First(&state)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, fmt.Errorf(
"get network state: %w",
result.Error,
)
}
return &state, nil
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@database/plugin/metadata/sqlite/network_state.go` around lines 58 - 77,
GetNetworkState is performing a read-only query but calls resolveDB; change it
to use resolveReadDB() so the method uses the read-optimized connection pool
(the same pattern used by GetSyncState/GetTip/GetAccount). Replace the call to
resolveDB(txn) with resolveReadDB(txn) (which returns d.ReadDB()) inside
GetNetworkState and keep the existing error handling and return behavior
unchanged.

Comment on lines +514 to +561
store := cfg.Database.Metadata()
txn := cfg.Database.MetadataTxn(true)
defer func() { txn.Release() }()

inBatch := 0
for _, acct := range accounts {
select {
case <-ctx.Done():
return ctx.Err()
default:
}

model := &models.Account{
StakingKey: acct.StakingKey,
Pool: acct.PoolKeyHash,
Drep: acct.DRepCred,
AddedSlot: slot,
Reward: types.Uint64(acct.Reward),
Active: acct.Active,
}

if err := store.ImportAccount(
model, txn.Metadata(),
); err != nil {
return fmt.Errorf(
"importing account %x: %w",
acct.StakingKey,
err,
)
}

inBatch++
if inBatch >= accountBatchSize {
if err := txn.Commit(); err != nil {
return fmt.Errorf(
"committing account batch: %w",
err,
)
}
txn = cfg.Database.MetadataTxn(true)
inBatch = 0
}
}

if inBatch > 0 {
return txn.Commit()
}
return nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Intermediate transactions leak: committed batches are never Release()d.

When importAccounts commits a batch at Line 547 and creates a new transaction at Line 553, the old (committed) transaction is never released — only the final txn is released by the defer on Line 516. If Release() returns resources to a pool (e.g., database connections), this leaks one resource per batch.

The importUTxOs function handles this correctly (Lines 341-342) by creating a new txn with defer txn.Release() inside each batch callback invocation.

🐛 Proposed fix: release old transaction before creating a new one
 		inBatch++
 		if inBatch >= accountBatchSize {
 			if err := txn.Commit(); err != nil {
 				return fmt.Errorf(
 					"committing account batch: %w",
 					err,
 				)
 			}
+			txn.Release()
 			txn = cfg.Database.MetadataTxn(true)
 			inBatch = 0
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ledgerstate/import.go` around lines 514 - 561, importAccounts leaks
intermediate MetadataTxn resources because after committing a batch the old txn
is replaced without calling txn.Release(); before creating a new txn in the loop
(the assignment txn = cfg.Database.MetadataTxn(true) in importAccounts), call
txn.Release() on the committed transaction to free resources; keep the existing
defer txn.Release() for the final txn, and follow the same pattern used in
importUTxOs (create/release per batch) so every created MetadataTxn is either
Release()d after Commit() or by the existing defer.

Comment on lines +558 to +561
if inBatch > 0 {
return txn.Commit()
}
return nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Final batch commit doesn't wrap errors with context.

When inBatch > 0, the return on Line 559 propagates the raw txn.Commit() error without wrapping. This is inconsistent with the wrapped error at Line 548 and makes it harder to identify where a commit failure occurred in logs.

Proposed fix
 	if inBatch > 0 {
-		return txn.Commit()
+		if err := txn.Commit(); err != nil {
+			return fmt.Errorf(
+				"committing final account batch: %w",
+				err,
+			)
+		}
 	}
 	return nil

As per coding guidelines: "Wrap errors with context using fmt.Errorf with %w verb".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if inBatch > 0 {
return txn.Commit()
}
return nil
if inBatch > 0 {
if err := txn.Commit(); err != nil {
return fmt.Errorf(
"committing final account batch: %w",
err,
)
}
}
return nil
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ledgerstate/import.go` around lines 558 - 561, The final batch commit in
ledgerstate/import.go currently returns txn.Commit() raw when inBatch > 0;
change it to capture the error from txn.Commit(), wrap it with context using
fmt.Errorf and the %w verb (e.g., "commit final batch: %w"), and return the
wrapped error; reference the inBatch check and the txn.Commit() call and ensure
you import fmt if not already imported.

Comment on lines +583 to +603
// Legacy format: [slot, hash] directly
var slot uint64
if _, err := cbor.Decode(
tipArr[0], &slot,
); err != nil {
return nil, fmt.Errorf(
"decoding tip slot: %w", err,
)
}
var blockHash []byte
if _, err := cbor.Decode(
tipArr[1], &blockHash,
); err != nil {
return nil, fmt.Errorf(
"decoding tip hash: %w", err,
)
}
return &SnapshotTip{
Slot: slot,
BlockHash: blockHash,
}, nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fallback parseTip path assumes 2-element [slot, hash] format but doesn't validate length.

When len(tipArr) > 1 and it's not the WithOrigin At case (len == 1), the code falls through to the legacy path and interprets tipArr[0] as slot and tipArr[1] as hash. If tipArr has 3+ elements in an unexpected encoding (e.g., [slot, blockNo, hash] without WithOrigin wrapping), tipArr[1] would be blockNo, not the block hash. Consider adding an explicit length check or logging a warning for unexpected tip array lengths.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ledgerstate/snapshot.go` around lines 583 - 603, The legacy fallback in
parseTip that decodes a 2-element [slot, hash] from tipArr assumes tipArr has at
least 2 items; add an explicit length validation on tipArr before decoding
(e.g., require len(tipArr) == 2 for the legacy branch) and return a clear error
(or log a warning) if the array length is unexpected so we don't treat a third
element (like blockNo) as the hash; update the logic around SnapshotTip
construction in parseTip to only decode tipArr[0] and tipArr[1] when that length
check passes.

Comment on lines +578 to +601
// UTxOToModel converts a ParsedUTxO to a Dingo database Utxo model.
func UTxOToModel(u *ParsedUTxO, slot uint64) models.Utxo {
utxo := models.Utxo{
TxId: u.TxHash,
OutputIdx: u.OutputIndex,
PaymentKey: u.PaymentKey,
StakingKey: u.StakingKey,
Amount: types.Uint64(u.Amount),
AddedSlot: slot,
}

// Convert assets
for _, a := range u.Assets {
fingerprint := lcommon.NewAssetFingerprint(a.PolicyId, a.Name)
utxo.Assets = append(utxo.Assets, models.Asset{
PolicyId: a.PolicyId,
Name: a.Name,
NameHex: []byte(hex.EncodeToString(a.Name)),
Amount: types.Uint64(a.Amount),
Fingerprint: []byte(fingerprint.String()),
})
}

return utxo
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the Utxo model has Datum/DatumHash/ScriptRef fields
rg -n 'Datum|ScriptRef' --type=go -C2 database/models/utxo.go

Repository: blinklabs-io/dingo

Length of output: 44


🏁 Script executed:

#!/bin/bash
# Read the Utxo model definition
echo "=== models.Utxo structure ==="
cat database/models/utxo.go

echo -e "\n=== ParsedUTxO definition ==="
rg -n 'type ParsedUTxO' --type=go -A 30 ledgerstate/utxo.go | head -50

Repository: blinklabs-io/dingo

Length of output: 3115


🏁 Script executed:

#!/bin/bash
# Get ParsedUTxO definition more clearly
rg -n 'type ParsedUTxO struct' --type=go -A 20 ledgerstate/utxo.go

Repository: blinklabs-io/dingo

Length of output: 44


🏁 Script executed:

#!/bin/bash
# Search more broadly for ParsedUTxO
rg -n 'ParsedUTxO' --type=go -B 2 -A 15 ledgerstate/utxo.go | head -80

Repository: blinklabs-io/dingo

Length of output: 2485


🏁 Script executed:

#!/bin/bash
# Find the ParsedUTxO type definition
rg -n 'type ParsedUTxO' --type=go ledgerstate/utxo.go -A 15

Repository: blinklabs-io/dingo

Length of output: 44


🏁 Script executed:

#!/bin/bash
# Search for ParsedUTxO definition across all Go files
rg -n 'type ParsedUTxO' --type=go -A 15

Repository: blinklabs-io/dingo

Length of output: 984


🏁 Script executed:

#!/bin/bash
# Verify parseCborTxOut also populates these fields
rg -n 'parseCborTxOut' --type=go -A 30 ledgerstate/utxo.go | head -60

Repository: blinklabs-io/dingo

Length of output: 1795


🏁 Script executed:

#!/bin/bash
# Get the full parseCborTxOut function
rg -n 'func parseCborTxOut' --type=go -A 50 ledgerstate/utxo.go

Repository: blinklabs-io/dingo

Length of output: 1433


🏁 Script executed:

#!/bin/bash
# Get full parseCborTxOut including end
sed -n '382,460p' ledgerstate/utxo.go

Repository: blinklabs-io/dingo

Length of output: 2029


🏁 Script executed:

#!/bin/bash
# Check the UTxOToModel function implementation
sed -n '578,610p' ledgerstate/utxo.go

# Also search for where UTxOToModel is called
echo -e "\n=== Where UTxOToModel is called ==="
rg -n 'UTxOToModel' --type=go ledgerstate/utxo.go

Repository: blinklabs-io/dingo

Length of output: 933


🏁 Script executed:

#!/bin/bash
# Find where UTxOToModel is called
rg -n 'UTxOToModel' --type=go -B 3 -A 3 | grep -v ledgerstate/utxo.go

# Also check for any Mithril-related code
echo -e "\n=== Mithril-related code ==="
rg -n 'Mithril|snapshot' --type=go | head -20

Repository: blinklabs-io/dingo

Length of output: 1805


🏁 Script executed:

#!/bin/bash
# Check the import.go context around where UTxOToModel is called
sed -n '320,360p' ledgerstate/import.go

Repository: blinklabs-io/dingo

Length of output: 757


🏁 Script executed:

#!/bin/bash
# Check if models.Utxo is supposed to have Datum/ScriptRef fields
# Look for any discussion or TODOs about this
rg -n 'Datum|ScriptRef' --type=go database/models/ 

# Also check if there's any storage for Datum/ScriptRef elsewhere
echo -e "\n=== Asset model to see storage pattern ==="
sed -n '1,100p' database/models/asset.go

Repository: blinklabs-io/dingo

Length of output: 3188


🏁 Script executed:

#!/bin/bash
# Check if Utxo model has relationships to Datum or WitnessScript
echo "=== Datum model ==="
cat database/models/datum.go

echo -e "\n=== WitnessScript model ==="
cat database/models/witness_script.go

# Check if Utxo has any reference to these
echo -e "\n=== Checking for relationships in Utxo ==="
grep -n 'Datum\|Script' database/models/utxo.go

Repository: blinklabs-io/dingo

Length of output: 2736


🏁 Script executed:

#!/bin/bash
# Check if ParsedUTxO has a Cbor field or raw bytes we could use
rg -n 'type ParsedUTxO' --type=go -A 15 ledgerstate/types.go

# Check how UtxoLedgerToModel (the other conversion function) handles Cbor
echo -e "\n=== Check UtxoLedgerToModel in models ==="
sed -n '55,95p' database/models/utxo.go

# Check if Cbor field is ever used/populated elsewhere
echo -e "\n=== Where Cbor field is set ==="
rg -n '\.Cbor\s*=' --type=go

Repository: blinklabs-io/dingo

Length of output: 1755


🏁 Script executed:

#!/bin/bash
# Check the import flow more carefully - where does ParsedUTxO come from in the Mithril case?
echo "=== Check the import function signature ==="
sed -n '300,330p' ledgerstate/import.go

# Check if there's a raw CBOR available in the parsing flow
echo -e "\n=== Check parseUTxOEntry to see what data is available ==="
sed -n '305,330p' ledgerstate/utxo.go

# Also check if ParsedUTxO should have a Cbor field or if it's being reconstructed
echo -e "\n=== Check if there's CBOR in the snapshot/import context ==="
rg -n 'parseUTxOEntry|processBatchedUTxOs' --type=go -B 2 -A 15 ledgerstate/utxo.go | head -100

Repository: blinklabs-io/dingo

Length of output: 4021


UTxOToModel silently discards datum and script reference data during snapshot imports.

ParsedUTxO has DatumHash, Datum, and ScriptRef fields that both parseMempackTxOut and parseCborTxOut populate from parsed snapshot/ledger state data. However, UTxOToModel does not:

  1. Set the Cbor field on models.Utxo (which would preserve the complete TxOut representation)
  2. Establish any relationship to the separate Datum or WitnessScripts models that exist in the database

This contrasts with UtxoLedgerToModel (database/models/utxo.go), which does set the Cbor field. For Mithril snapshot imports via ledgerstate/import.go, the datum and script data available during parsing is silently dropped during the conversion to the database model.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ledgerstate/utxo.go` around lines 578 - 601, UTxOToModel currently drops
datum and script reference data from ParsedUTxO; update UTxOToModel to copy the
ParsedUTxO.Cbor into models.Utxo.Cbor and persist relationships for datum and
scripts: when ParsedUTxO.DatumHash/Datum is present, create or link a
models.Datum (populate hash and raw CBOR/data) and set the Utxo's datum
reference fields accordingly; when ParsedUTxO.ScriptRef is present, create or
link the appropriate WitnessScripts model entries and set the Utxo's script
reference fields; mirror the approach used in UtxoLedgerToModel for populating
Cbor and establishing Datum/WitnessScripts associations so snapshot imports do
not discard datum or script data.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant