Files
audits/2026-04-12-session-audit.tex
zeekay ff13fb1d15 audits: convert 33 .md content files to .tex
LaTeX is the canonical format for audit reports per workspace policy.
Conversion via pandoc --from=gfm --to=latex --standalone preserves
content verbatim. Meta files (README/INDEX/LLM/CHANGELOG) remain .md.
2026-04-28 14:15:57 -07:00

626 lines
26 KiB
TeX

% Options for packages loaded elsewhere
\PassOptionsToPackage{unicode}{hyperref}
\PassOptionsToPackage{hyphens}{url}
\documentclass[
]{article}
\usepackage{xcolor}
\usepackage{amsmath,amssymb}
\setcounter{secnumdepth}{-\maxdimen} % remove section numbering
\usepackage{iftex}
\ifPDFTeX
\usepackage[T1]{fontenc}
\usepackage[utf8]{inputenc}
\usepackage{textcomp} % provide euro and other symbols
\else % if luatex or xetex
\usepackage{unicode-math} % this also loads fontspec
\defaultfontfeatures{Scale=MatchLowercase}
\defaultfontfeatures[\rmfamily]{Ligatures=TeX,Scale=1}
\fi
\usepackage{lmodern}
\ifPDFTeX\else
% xetex/luatex font selection
\fi
% Use upquote if available, for straight quotes in verbatim environments
\IfFileExists{upquote.sty}{\usepackage{upquote}}{}
\IfFileExists{microtype.sty}{% use microtype if available
\usepackage[]{microtype}
\UseMicrotypeSet[protrusion]{basicmath} % disable protrusion for tt fonts
}{}
\makeatletter
\@ifundefined{KOMAClassName}{% if non-KOMA class
\IfFileExists{parskip.sty}{%
\usepackage{parskip}
}{% else
\setlength{\parindent}{0pt}
\setlength{\parskip}{6pt plus 2pt minus 1pt}}
}{% if KOMA class
\KOMAoptions{parskip=half}}
\makeatother
\usepackage{longtable,booktabs,array}
\newcounter{none} % for unnumbered tables
\usepackage{calc} % for calculating minipage widths
% Correct order of tables after \paragraph or \subparagraph
\usepackage{etoolbox}
\makeatletter
\patchcmd\longtable{\par}{\if@noskipsec\mbox{}\fi\par}{}{}
\makeatother
% Allow footnotes in longtable head/foot
\IfFileExists{footnotehyper.sty}{\usepackage{footnotehyper}}{\usepackage{footnote}}
\makesavenoteenv{longtable}
\setlength{\emergencystretch}{3em} % prevent overfull lines
\providecommand{\tightlist}{%
\setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}}
\usepackage{bookmark}
\IfFileExists{xurl.sty}{\usepackage{xurl}}{} % add URL line breaks if available
\urlstyle{same}
\hypersetup{
hidelinks,
pdfcreator={LaTeX via pandoc}}
\author{}
\date{}
\begin{document}
\section{Red Team Session Audit ---
2026-04-12}\label{red-team-session-audit--2026-04-12}
\textbf{Auditor}: Red Team (adversarial security review) \textbf{Scope}:
Commits 2fb2cc7, 06e1fc3, VRF (recent), plus indexer/graph/explorer
changes \textbf{Method}: Static analysis, attack construction, crypto
lens, systems lens
\begin{center}\rule{0.5\linewidth}{0.5pt}\end{center}
\subsection{Executive Summary}\label{executive-summary}
Reviewed 9 distinct change areas across precompile, indexer, graph, and
explorer. Found \textbf{1 critical}, \textbf{2 high}, \textbf{3 medium},
\textbf{2 low}, \textbf{3 info} findings.
The critical finding is a consensus-safety violation in the attestation
precompile. The high findings are a cache poisoning vector in FROST and
an unbounded query amplification in graph resolvers.
\begin{center}\rule{0.5\linewidth}{0.5pt}\end{center}
\subsection{Findings}\label{findings}
\subsubsection{{[}CRITICAL{]} Attestation precompile uses time.Now() ---
consensus-unsafe
non-determinism}\label{critical-attestation-precompile-uses-timenow--consensus-unsafe-non-determinism}
\textbf{File}:
\texttt{/Users/z/work/lux/precompile/attestation/attestation.go:115,201,366,397,417}
\textbf{Adversary}: Any validator operator observing state divergence
\textbf{Attack}: The attestation precompile calls \texttt{time.Now()} at
lines 115, 201, 366, 397, and 417. Each validator processes the same
transaction at different wall-clock times. The \texttt{Timestamp} field
in \texttt{GPUAttestation} and \texttt{AttestationQuote} structs, and
the \texttt{expiresAt} calculation at line 417, all produce different
values per validator.
Concrete scenario:
\begin{enumerate}
\def\labelenumi{\arabic{enumi}.}
\tightlist
\item
User calls \texttt{CreateAttestation} (selector 0x04000000)
\item
Validator A processes at T=1712937600, computes
\texttt{expiresAt=1712941200}
\item
Validator B processes at T=1712937601, computes
\texttt{expiresAt=1712941201}
\item
Output bytes differ -\textgreater{} state root diverges
-\textgreater{} chain split
\end{enumerate}
This is the same class of bug as the ECIES/ML-KEM non-determinism
(C-03/M-11) that was fixed in commit 617c285 for \texttt{pqcrypto} and
18e0a95 for \texttt{hpke}. The attestation precompile was not covered by
that fix sweep.
\textbf{Impact}: Chain split. Any validator processing an attestation
transaction at a different millisecond produces a different state root.
This is exploitable by simply sending any attestation transaction.
\textbf{Fix}: Replace all \texttt{time.Now()} calls with
\texttt{accessibleState.GetBlockContext().Timestamp()} to use the
deterministic block timestamp. The \texttt{Run} method in
\texttt{module.go} receives \texttt{accessibleState} but never passes it
to the attestation functions. Refactor \texttt{VerifyNVTrust},
\texttt{VerifyTPM}, \texttt{VerifyCompute}, \texttt{CreateAttestation}
to accept a \texttt{blockTimestamp\ uint64} parameter. \textbf{Status}:
Open
\begin{center}\rule{0.5\linewidth}{0.5pt}\end{center}
\subsubsection{{[}HIGH{]} FROST LiftX cache unbounded under concurrent
load --- cache poisoning via
eviction}\label{high-frost-liftx-cache-unbounded-under-concurrent-load--cache-poisoning-via-eviction}
\textbf{File}:
\texttt{/Users/z/work/lux/precompile/frost/contract.go:147-179}
\textbf{Adversary}: Attacker with transaction submission capability
\textbf{Attack}: The \texttt{liftXCache} is a
\texttt{map{[}{[}32{]}byte{]}curve.Point} with a hard cap at 1024
entries (line 175: \texttt{if\ len(liftXCache)\ \textless{}\ 1024}).
Once the cache is full, no new entries are added, but existing entries
are never evicted.
Attack scenario:
\begin{enumerate}
\def\labelenumi{\arabic{enumi}.}
\tightlist
\item
Attacker sends 1024 FROST verify transactions with 1024 distinct
public keys (cost: 1024 * \textasciitilde55,000 gas =
\textasciitilde56M gas, feasible in a single block with 100M gas
limit)
\item
Cache is now permanently full with attacker-chosen keys
\item
Legitimate validator public keys can never enter the cache
\item
All subsequent legitimate FROST verifications pay the full LiftX cost
(80\% of verify)
\item
This is a targeted DoS: attacker pays once, defenders pay forever
\end{enumerate}
The cache also has a TOCTOU between \texttt{RLock} check and
\texttt{Lock} write (lines 164-178): two goroutines can both see
\texttt{cached=false}, both proceed to \texttt{Lock}, and both write the
same key. While not exploitable for corruption (last-write-wins is fine
for a cache), it causes unnecessary duplicate computation under
contention.
\textbf{Impact}: Permanent performance degradation of FROST verification
for all legitimate users. In a block with many FROST verifications
(every block from validators), this forces worst-case execution time.
\textbf{Fix}: Use an LRU cache with bounded size instead of a grow-only
map. The \texttt{golang.org/x/exp/maps} or a simple LRU ring suffices.
Evict on LRU policy, not refuse-to-insert. \textbf{Status}: Open
\begin{center}\rule{0.5\linewidth}{0.5pt}\end{center}
\subsubsection{{[}HIGH{]} Graph resolvers --- unbounded pagination
allows memory
exhaustion}\label{high-graph-resolvers--unbounded-pagination-allows-memory-exhaustion}
\textbf{File}: All 29 resolver packages under
\texttt{/Users/z/work/lux/graph/resolvers/*/} \textbf{Adversary}: Any
GraphQL query submitter (unauthenticated) \textbf{Attack}: Every
resolver\textquotesingle s \texttt{pl()} function parses the
\texttt{first} argument via
\texttt{fmt.Sscanf(fmt.Sprint(l),\ "\%d",\ \&limit)}. There is no upper
bound on \texttt{limit}. A query like
\texttt{\{\ utxos(first:\ 999999999)\ \{\ ...\ \}\ \}} passes
\texttt{limit=999999999} to \texttt{s.ListByType("UTXO",\ 999999999)}.
If the storage backend returns all matching records up to the limit,
this causes:
\begin{enumerate}
\def\labelenumi{\arabic{enumi}.}
\tightlist
\item
Massive memory allocation for serializing 999M records
\item
CPU exhaustion in JSON marshaling
\item
Network saturation sending the response
\end{enumerate}
The default \texttt{limit=100} is fine, but the override path has no
cap. This is present in all 29 resolver packages identically (copy-paste
pattern).
\textbf{Impact}: Remote DoS of the graph engine. A single
unauthenticated GraphQL query can OOM the graph server. \textbf{Fix}:
Cap \texttt{limit} to a sane maximum (e.g., 1000) in the \texttt{pl()}
function:
\texttt{if\ limit\ \textgreater{}\ 1000\ \{\ limit\ =\ 1000\ \}}. Apply
across all 29 resolver packages. \textbf{Status}: Open
\begin{center}\rule{0.5\linewidth}{0.5pt}\end{center}
\subsubsection{{[}MEDIUM{]} pqcrypto deterministic seed --- seed reuse
produces identical ciphertext (by design, but no documentation of caller
responsibility)}\label{medium-pqcrypto-deterministic-seed--seed-reuse-produces-identical-ciphertext-by-design-but-no-documentation-of-caller-responsibility}
\textbf{File}:
\texttt{/Users/z/work/lux/precompile/pqcrypto/contract.go:331-392}
\textbf{Adversary}: Contract developer who reuses seeds \textbf{Attack}:
The ML-KEM encapsulate function accepts a caller-provided 32-byte seed
for deterministic randomness. This is correct for consensus safety.
However, if a caller reuses the same seed with the same public key, the
output ciphertext and shared secret are identical.
This is IND-CPA insecure when seeds are reused: an observer who sees two
identical ciphertexts knows the same plaintext was encrypted. For ML-KEM
specifically, seed reuse does not leak the private key (unlike ECDSA
nonce reuse), but it does break ciphertext indistinguishability.
The gas cost is appropriate (6,000-10,000 depending on mode). The seed
parameter itself is correctly extracted and bounded.
\textbf{Impact}: Privacy violation for callers who reuse seeds. Not a
chain-split or fund-loss issue, but violates IND-CPA security for the
affected transactions. \textbf{Fix}: Add a check that the seed has not
been used before (e.g., via state storage), or document prominently that
callers MUST use unique seeds. A revert on duplicate seed would be the
safest approach. \textbf{Status}: Open (documentation issue at minimum)
\begin{center}\rule{0.5\linewidth}{0.5pt}\end{center}
\subsubsection{{[}MEDIUM{]} HPKE seal --- GPU path computes Kyber encap
then discards
result}\label{medium-hpke-seal--gpu-path-computes-kyber-encap-then-discards-result}
\textbf{File}:
\texttt{/Users/z/work/lux/precompile/hpke/contract.go:407-454}
\textbf{Adversary}: N/A (waste, not exploitable) \textbf{Attack}: The
\texttt{singleShotSealGPU} function calls \texttt{lattice.KyberEncaps()}
on the GPU (lines 441-447), synchronizes (line 449), then falls through
to \texttt{singleShotSealCPU(params)} which repeats the entire HPKE
pipeline including KEM encapsulation on CPU (line 453).
The GPU result (\texttt{ctTensor}, \texttt{ssTensor}) is never read back
or used. The function allocates GPU memory, performs the KEM, syncs,
then discards everything and runs the CPU path. This doubles the
computation time when GPU is available.
This is not a security vulnerability but a gas accounting concern: the
user pays gas for one encapsulation but the node performs two. On nodes
with GPUs, this wastes compute resources and slows block processing.
\textbf{Impact}: Wasted computation (2x KEM operations). No consensus
issue since both paths are deterministic and the CPU path produces the
final result. \textbf{Fix}: Either (a) extract the GPU KEM result and
use it in the HPKE key schedule, or (b) remove the GPU path entirely
until it can produce the full HPKE output, or (c) use GPU only for batch
acceleration in the parallel block executor. \textbf{Status}: Open
\begin{center}\rule{0.5\linewidth}{0.5pt}\end{center}
\subsubsection{{[}MEDIUM{]} Attestation module.go --- Run() does not
check readOnly for state-mutating
operations}\label{medium-attestation-modulego--run-does-not-check-readonly-for-state-mutating-operations}
\textbf{File}:
\texttt{/Users/z/work/lux/precompile/attestation/module.go:79-102}
\textbf{Adversary}: Smart contract calling attestation precompile via
STATICCALL \textbf{Attack}: The \texttt{Run()} method in
\texttt{attestationPrecompile} dispatches to \texttt{CreateAttestation}
(selector 0x04000000) and \texttt{VerifyCompute} (selector 0x03000000),
both of which mutate global state (the verifier\textquotesingle s device
registry and job history). However, the \texttt{readOnly} parameter is
never checked.
In a STATICCALL context (readOnly=true), the EVM expects the precompile
to not modify state. While the attestation precompile\textquotesingle s
mutations are to in-memory Go state (not EVM storage), the principle is
violated. If the verifier\textquotesingle s state is later serialized or
if the precompile is upgraded to write to EVM state, this becomes a
consensus bug.
More concretely: \texttt{VerifyCompute} at line 313 calls
\texttt{globalVerifier.RecordJobCompletion()} which modifies the global
verifier even in a \texttt{readOnly} call. This means a
\texttt{STATICCALL} to VerifyCompute has a side effect, which is
incorrect.
\textbf{Impact}: State pollution from read-only calls. Could cause
incorrect device status reports after STATICCALL-invoked verifications.
\textbf{Fix}: Check \texttt{readOnly} in the dispatch switch. For
selectors 0x03000000 (VerifyCompute) and 0x04000000 (CreateAttestation),
return error if \texttt{readOnly\ ==\ true}. \textbf{Status}: Open
\begin{center}\rule{0.5\linewidth}{0.5pt}\end{center}
\subsubsection{{[}LOW{]} VRF hashToCurveELL2 --- variable-time loop
leaks alpha length via
timing}\label{low-vrf-hashtocurveell2--variable-time-loop-leaks-alpha-length-via-timing}
\textbf{File}:
\texttt{/Users/z/work/lux/precompile/vrf/contract.go:242-277}
\textbf{Adversary}: Co-located process measuring precompile execution
time \textbf{Attack}: The \texttt{hashToCurveELL2} function uses
try-and-increment: it iterates \texttt{ctr} from 0 to 254, attempting to
decompress a hash output as an Edwards point. The number of iterations
before success depends on the input \texttt{alpha}. A co-located
attacker measuring execution time can determine how many iterations were
needed, leaking information about \texttt{alpha}.
For VRF verification this is low severity because \texttt{alpha} is
typically public (it\textquotesingle s the VRF input). However, if this
function is used in a privacy-preserving context where \texttt{alpha}
should be hidden, the timing channel is exploitable.
The gas cost is fixed at \texttt{GasVerify\ =\ 20,000} regardless of
iteration count, which means the attacker cannot observe the timing via
gas metering. They would need wall-clock observation.
\textbf{Impact}: Minor timing side channel. Exploitable only with
co-located measurement and only leaks information about public VRF
inputs. \textbf{Fix}: Use constant-time Elligator2 mapping (CFRG
hash-to-curve spec, section 6.8.2) instead of try-and-increment. The
Edwards25519 library supports \texttt{HashToCurve} directly.
\textbf{Status}: Open
\begin{center}\rule{0.5\linewidth}{0.5pt}\end{center}
\subsubsection{{[}LOW{]} Indexer WebSocket --- no authentication on
subscription}\label{low-indexer-websocket--no-authentication-on-subscription}
\textbf{File}:
\texttt{/Users/z/work/lux/indexer/multichain/platform\_indexer.go:184-243}
\textbf{Adversary}: Network attacker with access to the WS endpoint
\textbf{Attack}: The \texttt{runRealtimeLoop} function connects to the
chain\textquotesingle s WebSocket endpoint and subscribes to block
notifications. There is no authentication header, no API key, and no TLS
certificate verification on the WebSocket connection.
If the chain\textquotesingle s WS endpoint is internal (expected), this
is low risk. If the WS endpoint is exposed publicly, an attacker could:
\begin{enumerate}
\def\labelenumi{\arabic{enumi}.}
\tightlist
\item
Connect and subscribe to the same feed (information leak of block
data, which is public anyway)
\item
Cannot inject blocks because the indexer only reads
\item
MITM the connection if not using WSS (inject fake block notifications)
\end{enumerate}
The \texttt{Dialer} at line 190 uses default TLS settings which will
verify certificates for WSS URLs.
\textbf{Impact}: If WS URLs use \texttt{ws://} (not \texttt{wss://}),
block data could be MITM\textquotesingle d. The indexer would process
attacker-controlled block data. \textbf{Fix}: Enforce \texttt{wss://}
for all non-localhost WS endpoints. Add a check in
\texttt{runRealtimeLoop} that rejects \texttt{ws://} unless the host is
\texttt{localhost} or \texttt{127.0.0.1}. \textbf{Status}: Open
\begin{center}\rule{0.5\linewidth}{0.5pt}\end{center}
\subsubsection{{[}INFO{]} Explorer ChainSwitcher ---
dangerouslySetInnerHTML for SVG
logos}\label{info-explorer-chainswitcher--dangerouslysetinnerhtml-for-svg-logos}
\textbf{File}:
\texttt{/Users/z/work/lux/explore/ui/snippets/topBar/ChainSwitcher.tsx:89,183}
\textbf{Adversary}: Admin who controls chain registry config
\textbf{Attack}: The ChainSwitcher renders SVG logos using
\texttt{dangerouslySetInnerHTML=\{\{\ \_\_html:\ current.branding.logoContent\ \}\}}.
If \texttt{logoContent} contained
\texttt{\textless{}script\textgreater{}} or event handlers
(\texttt{onload}), this would be XSS.
However, after reviewing the chain registry
(\texttt{configs/app/chainRegistry.ts}), all \texttt{logoContent} values
are hardcoded SVG path strings in the source code (lines 145-220 of the
registry). They are not derived from user input, API responses, or URL
parameters.
The \texttt{explorerUrl} values are also hardcoded strings (lines
242-391) or built from \texttt{NEXT\_PUBLIC\_APP\_HOST} env var (line
428). The \texttt{window.location.href\ =\ this.targetUrl} assignment at
line 23 only navigates to these hardcoded URLs, so there is no open
redirect vector.
The \texttt{highlightText} function used in search results correctly
sanitizes via the \texttt{xss} library (confirmed at
\texttt{lib/highlightText.ts:7}).
\textbf{Impact}: No current vulnerability. The dangerouslySetInnerHTML
is safe because the content is developer-controlled. However, if the
chain registry is ever populated from an API or database, this becomes a
stored XSS vector. \textbf{Fix}: No fix needed now. Add a comment noting
that \texttt{logoContent} must remain developer-controlled. Consider
using a sanitizer if the registry source changes. \textbf{Status}:
Verified-safe (with caveat)
\begin{center}\rule{0.5\linewidth}{0.5pt}\end{center}
\subsubsection{{[}INFO{]} SLH-DSA 12-mode tests --- timing side channels
not
tested}\label{info-slh-dsa-12-mode-tests--timing-side-channels-not-tested}
\textbf{File}:
\texttt{/Users/z/work/lux/precompile/slhdsa/modes\_test.go} (from commit
2fb2cc7) \textbf{Adversary}: N/A (test coverage gap) \textbf{Attack}:
The SLH-DSA precompile supports 12 parameter sets (6 SHA2 + 6 SHAKE).
The modes test verifies that all 12 modes produce correct verification
results. However, it does not measure execution time variance across
modes or inputs.
SLH-DSA verification is hash-based with many internal hash evaluations
(WOTS+ chains, Merkle tree traversal). The number of hash evaluations is
data-dependent for some parameter sets. A timing test would measure
whether verification time varies with signature content.
However, since the gas cost is fixed per mode (not
per-signature-content), and the EVM does not expose wall-clock time to
callers, exploiting this requires co-located measurement. The test gap
is informational only.
\textbf{Impact}: None currently. Test coverage improvement opportunity.
\textbf{Fix}: Add benchmark tests (\texttt{go\ test\ -bench}) for each
mode with varying message sizes to document performance characteristics.
\textbf{Status}: Verified-safe (gas model prevents EVM-visible timing)
\begin{center}\rule{0.5\linewidth}{0.5pt}\end{center}
\subsubsection{{[}INFO{]} Adapter rename (achain-\textgreater ai etc)
--- no RPC method names in
logs}\label{info-adapter-rename-achain-ai-etc--no-rpc-method-names-in-logs}
\textbf{File}:
\texttt{/Users/z/work/lux/indexer/multichain/platform\_indexer.go}
\textbf{Adversary}: Log scraper \textbf{Attack}: The adapter rename
changed chain type names (e.g., \texttt{native} to \texttt{platform},
\texttt{achain} to \texttt{ai}). The RPC method names are constructed
from the chain type (e.g., \texttt{ai.getLatestBlock},
\texttt{mpc.getLatestBlock}).
After reviewing the indexer code, RPC method names appear in error
messages (via \texttt{fmt.Errorf}) but not in \texttt{log.Printf} calls.
The log messages at lines 77 and 208 use \texttt{idx.config.ID} and
\texttt{idx.config.Name}, not the RPC method string.
If the RPC method names leak (e.g., in error responses returned to API
callers), they reveal internal chain architecture. However, the method
names (\texttt{ai.getLatestBlock}, \texttt{bridge.getLatestBlock}) are
not sensitive --- they describe public chain functionality.
\textbf{Impact}: None. RPC method names are not sensitive and are not
logged. \textbf{Fix}: No fix needed. \textbf{Status}: Verified-safe
\begin{center}\rule{0.5\linewidth}{0.5pt}\end{center}
\subsubsection{{[}INFO{]} FROST real-sig test --- coverage
assessment}\label{info-frost-real-sig-test--coverage-assessment}
\textbf{File}:
\texttt{/Users/z/work/lux/precompile/frost/real\_sig\_test.go}
\textbf{Adversary}: N/A (test quality review) \textbf{Attack}: The new
test suite (\texttt{real\_sig\_test.go}, 243 lines) replaces the prior
security-theater test that used sequential byte patterns as signatures.
The new tests:
\begin{enumerate}
\def\labelenumi{\arabic{enumi}.}
\tightlist
\item
\texttt{TestFROSTVerify\_RealValidSignature} --- generates real
Schnorr sig, asserts \texttt{result{[}31{]}==1} (line 127)
\item
\texttt{TestFROSTVerify\_RealCorruptedSignature} --- flips bytes in R
and z, asserts \texttt{result{[}31{]}==0} (lines 148-162)
\item
\texttt{TestFROSTVerify\_RealWrongMessage} --- correct sig, different
message hash (line 175)
\item
\texttt{TestFROSTVerify\_RealWrongPublicKey} --- correct sig,
different public key (line 188)
\item
\texttt{TestFROSTVerify\_Determinism} --- 100 iterations produce
identical output (line 208)
\item
\texttt{TestFROSTVerify\_ConcurrentSafety} --- 100 goroutines (line
227)
\end{enumerate}
Coverage gaps identified:
\begin{itemize}
\tightlist
\item
No test for \texttt{threshold\ \textgreater{}\ totalSigners} (invalid
threshold rejection)
\item
No test for \texttt{totalSigners\ =\ 0} edge case
\item
No test for \texttt{threshold\ =\ 0} edge case (covered by
contract.go:111 but not tested from \texttt{real\_sig\_test.go})
\item
No test for gas exhaustion (suppliedGas \textless{} requiredGas)
\item
The concurrent test at line 233 checks \texttt{result{[}31{]}\ !=\ 1}
but sends the result to the error channel even when
\texttt{err\ ==\ nil} --- this means a "valid but result{[}31{]}==0"
concurrent race is reported as \texttt{nil} error, which
\texttt{require.NoError} will pass. The test should assert
\texttt{result{[}31{]}\ ==\ 1} inside the goroutine.
\end{itemize}
These are test quality issues, not security vulnerabilities.
\textbf{Impact}: The test is vastly better than the prior
security-theater version. The identified gaps are edge cases that are
handled correctly in the contract code but lack test coverage.
\textbf{Fix}: Add threshold/gas edge case tests. Fix the concurrent test
to assert \texttt{result{[}31{]}==1}. \textbf{Status}: Verified-safe
(test quality improvement recommended)
\begin{center}\rule{0.5\linewidth}{0.5pt}\end{center}
\subsubsection{gas=0 Sentinel Migration
Assessment}\label{gas0-sentinel-migration-assessment}
\textbf{Scope}: 8 packages added \texttt{gas\_zero\_test.go} files in
commit 2fb2cc7.
I verified the gas=0 handling across all precompiles that were part of
the migration. The pattern is:
\begin{verbatim}
RequiredGas returns 0 for invalid/short input
-> Run checks suppliedGas < requiredGas
-> When requiredGas == 0, the check passes (0 < 0 is false)
-> Run proceeds with remainingGas = suppliedGas - 0 = suppliedGas
-> Run returns error for invalid input, preserving gas
\end{verbatim}
This is correct behavior: gas=0 from RequiredGas is a sentinel meaning
"I cannot determine the cost because the input is malformed." The Run
method then validates the input and returns an error. The
caller\textquotesingle s gas is preserved (not consumed).
The precompiles that were NOT part of this migration but follow the same
pattern:
\begin{itemize}
\tightlist
\item
\texttt{math/contract.go} --- uses its own \texttt{ErrOutOfGas} (line
35), not \texttt{contract.ErrOutOfGas}. This is a minor inconsistency
but functionally equivalent.
\item
\texttt{bls12381/contract.go} --- uses EIP-2537 gas formula, returns 0
for \textless{} 2 pairs. Correct.
\end{itemize}
No precompile was found that returns a local error string instead of
\texttt{contract.ErrOutOfGas}. The migration is complete.
\textbf{Status}: Verified-safe
\begin{center}\rule{0.5\linewidth}{0.5pt}\end{center}
\subsection{Summary}\label{summary}
{\def\LTcaptype{none} % do not increment counter
\begin{longtable}[]{@{}lll@{}}
\toprule\noalign{}
Severity & Count & Findings \\
\midrule\noalign{}
\endhead
\bottomrule\noalign{}
\endlastfoot
CRITICAL & 1 & Attestation time.Now() consensus non-determinism \\
HIGH & 2 & FROST cache poisoning, Graph resolver unbounded pagination \\
MEDIUM & 3 & ML-KEM seed reuse, HPKE GPU waste, Attestation readOnly
violation \\
LOW & 2 & VRF timing side channel, Indexer WS no auth \\
INFO & 3 & ChainSwitcher SVG (safe), SLH-DSA timing tests, FROST test
gaps \\
\end{longtable}
}
\textbf{Recommendation}: \textbf{fix-then-ship} -\/- the CRITICAL
attestation \texttt{time.Now()} must be fixed before any chain that
enables the attestation precompile. The FROST cache and Graph pagination
should be fixed before production deployment.
\end{document}