Commit Graph
211 Commits
Author SHA1 Message Date
zeekayandHanzo Dev d87600fbef quantumvm: a block carries no signature of its own
ParseBlock demanded a field the wire has no room for. The block format is
timestamp, height, parentID, tx lengths, tx blob; the stamp lived only on the
object the builder made and was gone the moment the block was serialized, which
is every time it leaves the process. So the gate refused every block that ever
crossed the wire, genesis included, and Q-Chain accepted nothing on any network
for as long as it ran -- certAccepted=0, appliedHead=0, everywhere.

The two doors into the format disagreed, which is what made it a loop rather
than a stall. A responder answers a catch-up request through vm.parseBlock, which
has no gate and succeeds; the requester admits the reply through VM.ParseBlock,
which had the gate and failed. Same bytes, two verdicts: every node served the
block every node refused.

Putting the stamp on the wire would be worse than leaving it off. The block id is
sha256 of its own bytes, so a stamp in those bytes makes the id depend on who
signed, and two honest nodes compute two ids for one block. seedGenesis already
refuses wall-clock time for exactly this reason. The stamp could not have been
load-bearing anyway: it was made with a key generated fresh for each block, and a
signature that ships its own throwaway public key states nothing an attacker
cannot also state. Its thirty-second validity window is also the opposite of what
catch-up needs, which is to serve blocks from hours ago.

A block-level signature belongs to consensus, where it already is: a cert is a
quorum's statement about a block, checkable by anyone holding the validator set,
and it is not a field on the block. Quasar still signs there. Transaction-level
quantum signatures are untouched -- those are serialized, and they are verified.

The tests stayed green throughout because none of them sent a block through
Bytes() and back. Three do now: parse what we serialize, parse GENESIS, and the
two parse paths reach the same verdict on identical bytes. Restoring the gate
fails all three.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.26
2026-08-20 11:44:10 -07:00
zeekayandHanzo Dev b9f95308a8 mpcvm: custody answers to the transport, not to the request body
Signing authority was resolved from a name the requester wrote about itself.
CrossChainRequest already receives the sender's chain id from the transport,
which authenticated it, and then passed req.RequestingChain to the permission
lookup instead -- so the proven identity sat unused in the signature while a
field the sender controlled decided what it was allowed to do. The stock table
grants B-Chain sign, keygen and reshare at 100k/day, so the claim was worth
having.

Caller carries an authenticated chain and nothing else can be substituted for
it: the only constructor takes the transport's chain id, the fields are
unexported, and the zero value grants nothing, so a path that forgets to
authenticate fails closed instead of quietly inheriting rights. Making it a type
rather than a convention is what turns "remember not to read the body" into
something the compiler checks -- it listed every call site that had been passing
a claim.

Authorization binds to the chain ID, never to the label. A label is an
operator's convenience and anyone can spell it; the id is the identity consensus
itself uses. An entry with no chainId is bound to no chain and grants nothing,
and Initialize names those entries at boot, because silence there would read as
"custody is configured" right up until the first release failed.

HTTP keygen and sign are gone rather than guarded. That transport authenticates
no chain, so there was no check to write -- a body can say it is the bridge as
easily as it can say anything else. Ceremonies are requested by chains over the
cross-chain path; the HTTP surface reads replicated state, which needs no
identity.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.25
2026-08-20 10:47:08 -07:00
zeekayandHanzo Dev a449465dc6 fhe: batch work follows the cores the machine has
The pure-Go batch paths split every batch across exactly four goroutines, so a
machine with more cores than that ran the remainder idle and throughput stopped
improving once a batch outgrew four chunks. Measured on an 8-core node (6
allocatable), forward NTT:

     batch    before          after
         8    10,229/s        11,223/s
        64    10,878/s        15,252/s
       256    12,617/s        18,989/s

Worker count now follows GOMAXPROCS, capped by the batch (an empty chunk is a
scheduler slot spent on nothing) and floored at one (a constrained GOMAXPROCS
must not yield a zero-worker loop that skips the batch).

The path had no test of any kind: every other file in the package is
//go:build cgo, so a CGO_ENABLED=0 build compiled this file and ran nothing
against it -- while being the build a CPU-only node runs, and the only one that
cross-compiles to a cluster node. Adds a round-trip correctness test and
benchmarks at the same ring parameters as the cgo ones, including 4 and 8 to
straddle the sequential/parallel boundary.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.24
2026-08-18 21:50:07 -07:00
zeekayandHanzo Dev 4a0611f1fb Merge remote-tracking branch 'origin/main'
Co-authored-by: Hanzo Dev <[email protected]>
v1.7.23
2026-08-18 18:25:32 -07:00
zeekayandHanzo Dev b0763957cc quantumvm: write the genesis block so Q-Chain can start
Bootstrap opens by asking each beacon which block it holds, and an answer
naming no block is not counted as a responder -- it neither backs a tip nor
raises the response floor (tally, node chains/bootstrap_trust.go). The VM
derived its state lazily from the signer config and never wrote a block, so
last-accepted stayed empty, every node answered with nothing, every reply was
dropped, and all five waited on each other for as long as the chain ran:

  waiting for the beacon quorum: 0 of 5 beacons responded, need 3

State you can execute against is not the same as a block you can name.

Initialize now writes height 0 when the chain has none. The block is a
constant -- fixed timestamp, empty parent, no transactions -- so every node
computes the same id alone; wall-clock time would give each node its own
genesis and make this a fork. It commits, or the tip lives in the version
layer only and the next start names nothing again.

Tests pin the id rather than compare two local VMs: the wire carries Unix
seconds, so two VMs seeded in the same second agree even with a wall-clock
timestamp, and that assertion would pass for a build that forks in production.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-17 15:08:21 -07:00
zeekayandHanzo Dev 464956eded the signing endpoint no longer asks for a private key
It took one as a JSON argument and then refused every call — but the body is
decoded before the handler runs, so the key arrived regardless, at an endpoint
that needs no credential to reach. A method that cannot sign and has no callers
is not worth the parameter it advertises, so it goes.

The test beside it reads the package's own argument types and fails if any of
them asks a caller for a secret again.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.22
2026-08-15 05:04:29 -07:00
zeekayandHanzo Dev 5493e3a2ed quantumvm: a validator key parses its secret once and keeps it
Sign now asks the key for its ML-DSA form instead of rebuilding one per
call. A key that arrives as bytes parses on first use and reuses that
key for every signature after; a freshly generated key already holds its
parsed form. A secret that will not parse fails closed on every call.

Takes crypto v1.20.5, where a private key copies the secret it is given
and hands back a copy, so the bytes a caller persists out of a generated
key stay readable for as long as the caller holds them.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.21
2026-08-15 00:52:02 -07:00
zeekayandHanzo Dev 9427d78408 evm v1.104.30 — the builder waits for the pool to reach the head
Co-authored-by: Hanzo Dev <[email protected]>
v1.7.20
2026-08-13 12:02:51 -07:00
zeekayandHanzo Dev abf54b46ee evm v1.104.29 — an empty build no longer halts the chain
The C-Chain discarded any assembled block with no transactions, which reads
"nobody asked for anything" and also reads "the assembler missed work that is
sitting in the pool". The second one is fatal: no block means no nonce advances,
so the pending transaction is never mined, so the pool never drains.

lux-devnet stopped at 20:38 UTC on 2026-08-11 holding one executable transaction
and stayed down 41 hours. v1.104.29 asks the pool instead of the block.

Skips .11 through .28, which this module never pinned.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.19
2026-08-13 10:58:37 -07:00
zooqueen f9a900f7cb three minio modules down to one, and the last is not ours
The S3 client moves off hanzos3/go-sdk, and consensus, database, geth, container
and zapdb all come up to the releases where each stopped resolving minio.

go-sdk pointed at our checksum forks with `replace`, which Go ignores in a
non-main module — so it redirected nothing for anything importing chains, and the
real minio packages resolved regardless.

What remains is github.com/minio/sha256-simd, reached through
github.com/ferranbt/fastssz. Third-party, and not this repo's to remove.

Verified package by package against an untouched clone, because a full build
cannot run on this machine — luxfi/dex needs dex_gpu.h and cevm needs pkg-config
lux-cevm, neither installed. Both trees: 48 packages build, the same 4 fail.
Identical.

go directive held at 1.26.4.
v1.7.18
2026-08-10 21:56:16 -07:00
zeekayandHanzo Dev 67e2aaa177 ownership: a lookup starts from a node, so Subject is a function too
The verifier looks up an attestation by node before it holds any claim, so the
subject encoding has to be reachable without one. Exporting it keeps a single
definition instead of every caller re-deriving the same 20-into-32 copy.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.17
2026-08-08 05:00:49 -07:00
zeekayandHanzo Dev 85e1287852 mpcvm: an ownership claim is a signed fact about a token, not a copy of it
An entitlement-gated chain needs a credential its operator cannot forge. The
collection that confers the entitlement lives on another chain's EVM, so the
node cannot read it and must not be asked to: a check a node runs against data
its own operator supplies is not a control.

So M threshold-signs the fact instead. chains/ownership is the frozen wire
format both sides share -- the claim's field layout, the domain tag, the signed
payload, and one verification entry point. It is dependency-free and
non-internal because the verifier is a node in another module that must never
link the M-Chain VM.

Authorizes answers authenticity and node-binding together. There is no exported
way to ask only one, because a caller that checked the signature and forgot the
binding would let any node activate on a peer's entitlement.

The domain registers additively, reusing ComputeAttestationPayload rather than
defining a parallel digest, so equivocation detection and quorum verification
already apply. The claim's block is the epoch, which makes two facts about one
node at one block contradictory while re-attesting later is how ownership moves.

KAT vectors freeze the bytes; a test asserts M's payload and the verifier's are
byte-identical, since nothing else forces those two functions to agree.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.16
2026-08-08 04:58:32 -07:00
zeekayandHanzo Dev dd3ff1c9b5 dexvm: delete the D-Chain proxy, keep the asset registry
The proxy built for the canonical D-Chain slot (VMID "dexvm",
mDVT5EWMumBp3LCqvKwuyZQeY1VXr1jvjGNAt8nL4UFiXvqXr) that luxfi/dex cmd/dexd
already fills, and its wire spoke clob_* while the venue registers dex_*.
Installing it produced a node that loaded a VM, passed health, and answered
nothing — a failure that reads as a working chain. Nothing imported it but its
own plugin main, and the release matrix published it as an image on every tag.

dexvm/registry stays: it is the DEX asset registry, imported by the EVM value
wiring and dex-assets-validate, and it holds the Gate-A primitives its own
gate_test.go covers. The deleted enforce.go was a config adapter over it.

internal/slot pins the invariant by AST, so a comment may discuss the slot but
a declaration may not claim it: no package here names the D VMID, none declares
a clob_* method, and the release matrix ships no image for the slot. Runs
unfiltered on every push — a path filter would miss a NEW package claiming an
already-filled slot, which is the case worth catching.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.15
2026-08-07 16:32:00 -07:00
zeekayandHanzo Dev 3e9fd77e98 rpc: /ext/ -> /v1/ on our own hosts
luxd serves /v1/*; /ext/* 404s. Third-party chains keep their own paths.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-03 21:15:23 -07:00
zeekayandHanzo Dev 230fbb3fe0 ci: one CI path — .hanzo/workflows
None of the three workflows here could run. release.yml's `plan` job named
`lux-build-amd64` and validate-asset-manifests.yml named the bare
`['self-hosted', 'linux', 'amd64']` triple; the pool on this forge is
`lux-build-linux-amd64` and nothing answers to either. A job naming a label no
runner carries does not fail — it waits. So Gate A has not been gating
anything, and it is the gate that proves every DEX asset manifest entry
resolves to a real on-chain asset.

release.yml had a second, independent reason to never produce anything: it
delegated the actual build to
`hanzoai/.github/.github/workflows/docker-build.yml@main`, and a cross-org
workflow_call from luxfi/* into hanzoai/.github fails before any job starts.
luxfi/operator recorded exactly this against exactly this reusable —
"startup_failure on EVERY run since the migration to that reusable ... every
attempt failed at 0-2s with zero jobs dispatched" — and inlined its own build
for that reason. These thirteen plugin images have not been built by this
workflow.

So the plugin build is inlined too, in .hanzo/workflows/release.yml. It is
short: compile the VM to build/<vm> on the runner, then Dockerfile.plugin copies
that one binary into a scratch image, one matrix leg per entry in
.github/images.json. The manifest file stays where it is and is still read from
`.github/images.json` — it is repo metadata, not a workflow.

Gate A becomes .hanzo/workflows/ci.yml with its three checks intact.

Two other things had to change for these to work off GitHub:

  * ghcr auth. The reusable was handed the ambient credentials; the direct build
    uses GHCR_USER and GHCR_TOKEN, the org Actions secrets sourced from KMS. The
    instance GITHUB_TOKEN is a Hanzo Git token and has no rights at ghcr.io.

  * arm64. The reusable was asked for an arm64 leg on a
    `["self-hosted","linux","arm64"]` pool that does not exist. These images are
    amd64-only until we own an arm64 runner and it can return as a native matrix
    entry.

Deleted rather than migrated: workflow-sanity.yml. It is
`uses: hanzoai/.github/.github/workflows/workflow-sanity.yml@main` — the same
cross-org reusable call that fails before dispatch, so it has never reported on
anything. The label checking it was meant to provide is what
.github/actionlint.yaml is for, locally.

All three files are accounted for, which is the constraint: the forge stops at
the first workflow directory that exists, so a repo's entire .github/workflows
suite goes dark the moment .hanzo/workflows appears.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-30 17:22:19 -07:00
zeekayandHanzo Dev 37d8835a07 cevm: doc ExecuteBlock as itself
The comment read "ExecuteBlock is the legacy name for ExecuteBlock ... Deprecated:
use ExecuteBlock" — a rename rewrote both the old and new name to the same string,
leaving the only ExecuteBlock marked deprecated in favour of itself.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.14
2026-07-30 05:48:02 -07:00
zeekayandHanzo Dev 3e85a4dc82 quantumvm/stamper: batch verification must check the same things, for the right stamp
The accelerated batch path re-implemented two things the sequential path already
did, and got both wrong.

1. It re-listed the block-correspondence fields itself and omitted ReceiptsRoot,
   which verifyStampSync checks. A stamp carrying a forged receipts root was
   therefore accepted whenever accel was available and the batch cleared
   DilithiumBatchThreshold, and refused otherwise — acceptance decided by host
   hardware. Both paths now call stampMatchesBlock, the one definition.

2. The hybrid post-quantum leg read signDataSlice[0] for every stamp:
   signDataSlice is indexed by BATCH POSITION while indices maps position to stamp
   index, and the loop discarded the position. Every hybrid stamp's SLH-DSA
   signature was verified against the FIRST batch entry's signing bytes. That both
   false-rejects honest stamps and, since a batch submitter chooses what entry zero
   contains, accepts a signature made over data the stamp does not commit to.

Proven against the pre-fix logic: a correctly signed hybrid stamp at batch
position 1 failed; a stamp carrying a signature over another stamp's bytes passed;
a forged ReceiptsRoot passed the batch field list.

Tests: TestHybridBatchVerifiesEachStampAgainstItsOwnData,
TestHybridBatchRejectsSignatureOverAnotherStampsData,
TestStampMatchesBlockCoversReceiptsRoot.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00
zeekayandHanzo Dev af5096e6fe quantumvm: count distinct validators toward both finality thresholds
AddCoronaSignature and AddBLSSignature appended caller-supplied signatures with no
signer check, and TryFinalize compares len(signatures) against q.threshold. One
peer resending the same signature therefore drove the count to the threshold on
its own. Proven: three calls carrying one validator's signature left three entries
in the list.

The Corona leg is the sharp end: TryFinalize finalizes it on count alone and
verifies none of the signatures, unlike the BLS leg which aggregates and verifies.
So a single peer satisfied the post-quantum half of quantum finality by itself.

Insertion for both legs now goes through addBLS/addCorona, which record one
signature per ValidatorID, and SignBlock's own contribution goes through the same
door instead of appending directly.

Tests: TestOneValidatorCannotReachCoronaThreshold,
TestDistinctValidatorsReachCoronaThreshold, TestOneValidatorCannotReachBLSThreshold.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00
zeekayandHanzo Dev bcf93b97db bridgevm: one acceptance predicate for a block MPC signature
batchVerifyBlockSignatures had two answers to "is this signature valid", chosen
by accel.Available():

  - the CPU loop verified against blockHash[:] (accel_verify.go:69) while the GPU
    layout verified against sha256(blockHash[:]) (accel_verify.go:96,107). Same
    signature set, two different signed messages, so a node with an accelerator
    and a node without reached opposite verdicts on the same block —
    bridgevm/block.go:219 turns validSignatures into accept or reject, so this is
    an accept/reject split decided by host hardware.

  - a signature shorter than 64 bytes, or a public key that would not marshal,
    was silently zero-filled (`if len >= sigSize`, `if mErr != nil { continue }`)
    while the entry kept its row in the result vector, and validCount was taken
    straight from the kernel's output for that row. The CPU path rejects those
    entries outright via deserializeSignature. Correctness of the count therefore
    rested on an out-of-tree kernel refusing degenerate input, and one
    spuriously-true row is enough to admit a block.

The predicate is now verifyBlockSig, and the CPU loop is its only evaluator.
packBlockSigBatch lays out the accelerator's rows over the same message bytes and
returns an error for any row it cannot represent exactly, which sends the batch to
that one authority instead of guessing.

Tests: TestBatchSignsTheBlockHashItself, TestBatchRefusesRowsItCannotRepresent,
TestVerifyBlockSigIsTheOneAuthority, TestBatchVerifyCountsNoSignatureWithoutConfig.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00
zeekayandHanzo Dev 9a2e4fa7d3 bridgevm: build a block from its inputs, not from map iteration order
BuildBlock collected the block's bridge requests by ranging vm.pendingBridges, a
map. Go randomises map iteration, and that iteration decided two things that must
be functions of the pending set:

  - the ORDER of BridgeRequests, which is hashed into the block id
    (block.go computeID writes each req.ID in slice order) and serialised to the
    wire. The same pending set therefore produced a different block on every
    build. The sibling map in the same wire format is already sorted by NodeID
    (wire.go:140) — BridgeRequests was the one that was missed.
  - WHICH requests the block carries, because the 100-request cap broke out of
    the range. With more than 100 ready, the included subset was a random draw,
    so a request could be passed over indefinitely for no reason.

Proven: two builds over one 24-request pending set produced different orders, and
the capped build over 140 requests carried a random subset.

Candidates are now sorted by request id and the cap takes a prefix of that order.

Tests: TestBuildBlockOrdersRequestsDeterministically,
TestBuildBlockCapIsADeterministicPrefix.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00
zeekayandHanzo Dev c7aa384e15 aivm: compare the recorded receipt_root unconditionally
verifyImported exists so every validator reaches byte-identical engine state from
the same committed intents, and its comparison was written
`wantRoot != (common.Hash{}) && got != wantRoot`. The zero-hash exemption made the
guarantee opt-out: a proposer recorded intents, wrote an all-zero receipt_root,
and every follower applied those intents and returned nil without ever checking
its resulting engine state matched the proposer's. Proven: a block whose
receipt_root was zeroed while the engine's real root was non-zero verified with
no error.

Nothing honest relied on the exemption. A proposer stamps the engine's own root
(vm.go:622), and before anything settles the root is legitimately zero on both
sides — so plain equality admits every honest block and rejects only a genuine
divergence.

Test: TestVerifyRejectsBlockClaimingZeroReceiptRoot.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00
zeekayandHanzo Dev 14b1b66956 identityvm: judge credential expiry at block time, and stop letting a missing
proof satisfy RequireZKProofs

Two defects.

1. Block.verifyCredential compared cred.ExpirationDate against time.Now() inside
   block verification. The verdict therefore depended on when a node happened to
   verify: the same block is valid before the expiry instant and invalid after,
   so two validators either side of it disagree. Worse, it is unconditional on
   replay — a node bootstrapping the chain rejects every historical block whose
   credentials have since expired, which after enough time is all of them.
   Proven: a block timestamped 48h ago carrying a credential that was live then
   failed verification. Now compared against b.BlockTimestamp, the one clock
   every node agrees on, already bounded against the parent and local time
   earlier in Verify.

2. VerifyCredential gated the ZK requirement on the presence of the thing it
   requires: `if vm.config.RequireZKProofs && cred.Proof != nil`. A credential
   carrying no proof object skipped the branch and returned (true, nil) on a
   chain configured to require proofs. Proven: VerifyCredential returned true for
   a proofless credential with RequireZKProofs set. The requirement now holds
   whenever it is configured.

Tests: TestCredentialExpiryIsJudgedAtBlockTime,
TestRequireZKProofsCannotBeSkippedByOmittingTheProof.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00
zeekayandHanzo Dev 3d38bef46f zkvm/fhe: count distinct parties toward the decryption threshold
SubmitDecryptionShare stored the share in a map keyed by partyID, which dedups,
but incremented a separate ShareCount on every call and compared THAT to the
threshold. One party calling three times on a 3-of-5 session drove ShareCount to
3 and flipped the session to StatusProcessing, so a single share holder
satisfied the quorum and the value t-of-n exists to protect was released to it.
Proven: three calls from partyID 1 left ShareCount == 3.

The share set is now the only source of truth — ShareCount is len(Shares) — and
a repeat submission from a party that already contributed is refused outright.
The sibling implementations in mpcvm/fhe already dedup before counting.

Tests: TestOnePartyCannotReachDecryptionThreshold,
TestDistinctPartiesReachDecryptionThreshold.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00
zeekayandHanzo Dev f24ec103d2 zkvm: refuse a block or vertex that spends one shielded note twice
verifyTransaction checks each nullifier against nullifierDB, which only holds
nullifiers spent in ACCEPTED state; a block's own nullifiers are marked spent in
Accept, after Verify. So two transactions in one block sharing a nullifier both
passed — neither was spent yet — and one shielded note funded two spends, each
creating outputs. Supply inflation, which is the exact event the nullifier set
exists to prevent. A single transaction listing the same nullifier twice got
through the same way: the mempool consults its conflict map before inserting the
transaction's own nullifiers, so [n, n] passes admission too.

Proven: Vertex.Verify on a vertex whose two txs share a nullifier returned nil.
BuildVertex already refuses to batch conflicting txs (usedNullifiers), so the
invariant was intended — it just was not enforced on anything that arrived over
the wire, and BuildBlock never had it at all.

Block.Verify and Vertex.Verify now require every nullifier in the block to be
distinct, checked before proof verification because it is the cheaper gate.

Also bounds the vertex decoder's counts by the bytes actually present.
parentCount and txCount are attacker-controlled uint32 read straight into
make(); the length checks came after the allocation. A 16-byte vertex claiming
2^32-1 parents requested 128 GiB, and a 20-byte one claiming 2^24 txs measurably
allocated 671 MB before returning its error — remote unrecoverable
out-of-memory from a payload smaller than a UDP header.

Tests: TestBlockVerifyRejectsDuplicateNullifier,
TestBlockVerifyRejectsNullifierRepeatedInOneTx,
TestVertexVerifyRejectsDuplicateNullifier,
TestDeserializeVertexBoundsParentCount, TestDeserializeVertexBoundsTxCount.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00
zeekayandHanzo Dev df5ccabc99 zkvm: make the state root one pure function, not a hardware-dependent one
Two defects in the same computation, both consensus-fatal.

1. StateTree.ComputeRoot returned a GPU Poseidon digest when accel.Available()
   and a SHA-256 digest otherwise, over inputs truncated to 8 bytes each on the
   GPU path. The root is committed in the block and hard-compared in
   Block.Verify, so a validator with an accelerator and one without computed
   different roots for identical inputs and rejected each other's blocks —
   a chain split decided by host hardware.

2. computeStateRoot mutated the tree it was measuring: Block.Verify called it,
   ApplyTransaction appended to pendingAdds/pendingRemoves, and only Accept ->
   Finalize cleared them. Verifying the same block twice, or verifying a block
   that loses a poll and then its winner, folded the earlier block's
   commitments into the later root. Proven: computing the root for one tx set
   twice in a row returns two different roots, so a valid block is rejected
   with errInvalidStateRoot and the node stalls.

The root is now RootAfter: SHA-256 over currentRoot, then every output
commitment in tx order, then every nullifier in tx order. It reads the tree
and never writes it; Finalize is the only mutation and Accept its only caller.
Genesis now finalizes its own root instead of leaving its txs to be re-folded
into every later block.

Also deletes the dead computeRootFromLeaves/updateLeafPath cluster, which
ranged a map while sequentially rewriting st.currentRoot.

Tests: TestComputeStateRootIsPure, TestStateRootHasOneDefinition,
TestStateRootAdvancesOnlyOnFinalize.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:48:10 -07:00
zeekayandHanzo Dev ae28f35b97 deps: drop go.sum lines that disagree with the checksum log
Recorded from direct VCS fetches made while GOPRIVATE covered
github.com/luxfi/*, which switches off the proxy and checksum verification
together. proxy.golang.org still serves the originally-published bytes and they
verify against the log, so dropping only the disagreeing lines restores a
verified graph with no version change.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.12
2026-07-28 16:18:27 -07:00
zeekayandHanzo Dev 10eafbf673 build: mark lux-private private, not all of luxfi
GOPRIVATE switches off the module proxy AND checksum verification together.
Covering github.com/luxfi/* meant every luxfi module was fetched straight from
git and trusted without verification — which is how moved tags got captured into
module caches and served from them indefinitely, making the same commit build on
one machine and fail on another.

Nearly all of luxfi is public and on proxy.golang.org, so it belongs under
normal verification. The private org is lux-private.

Twelve luxfi repos are still private AND still 404 on the proxy, so they are
listed individually rather than by wildcard — coverage now matches what actually
needs a direct fetch, and every other luxfi module is verified against
sum.golang.org again:

  bridge broker compliance erc20-go fasthttp graph hsm indexer
  mpc ordering password pool

The right end state is those repos moving to lux-private; until then the
explicit list keeps the exemption honest instead of blanket.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 16:08:52 -07:00
zeekayandHanzo Dev e845b509c5 chore: reconcile go.sum after luxfi history rewrite
Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 10:51:28 -07:00
zeekayandHanzo Dev 1231964a90 fix: give the Metal anchor a package-unique symbol name
deps: gpu v1.1.2 — the older tags name the wrong pkg-config package

gpu v1.0.1/v1.1.0 declare `#cgo pkg-config: lux-gpu` while including
<lux/gpu.h>. Those are two different libraries with confusingly similar names:

  luxgpu.pc   unified GPU acceleration, -lluxgpu,   Cflags -I${includedir}
  lux-gpu.pc  the DEX order matcher,    -llux_gpu,  Cflags -I${includedir}/lux

Under lux-gpu the include resolves to .../include/lux/lux/gpu.h, so every cgo
build failed with "fatal error: lux/gpu.h file not found" — the arm64 stall
signature. libluxgpu.dylib is also what actually exports _lux_gpu_ecrecover_batch.

gpu v1.1.2 already declares luxgpu; only the pin was stale.

With that fixed the link got one step further and hit a duplicate symbol:
cevm and dex/pkg/lx both define a global lux_secp256k1_metal_anchor, so any
binary linking both refuses. The anchor exists only to keep the Metal driver
alive through linker dead-code elimination — luxcpp resolves the DRIVER symbol
(secp256k1_ecrecover_address_batch_metal) via dlsym, never the anchor, so the
anchor name is internal and safe to qualify. Named lux_cevm_secp256k1_metal_anchor
after the package that owns it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 08:43:24 -07:00
zeekayandHanzo Dev 7a830a1666 deps: drop go.sum lines that disagree with the checksum log
These hashes were recorded from direct VCS fetches made while GOPRIVATE covered
github.com/luxfi/*, which switches off the module proxy and checksum
verification together. Tags moved afterwards, so what was written down no longer
matches what sum.golang.org holds, and the build refuses to verify.

proxy.golang.org still serves the originally-published bytes and those verify
against the log, so dropping only the disagreeing lines and re-tidying restores
the build with no version change. Lines that agree were left alone — this is not
a regenerated go.sum.

The mismatch followed the machine, not the repository: the same commit built
wherever the cache was clean. Any machine or CI runner that fetched luxfi
modules under the old GOPRIVATE holds the same poisoned entries.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 02:14:08 -07:00
zeekay c2f513536a chore: sync working tree
Commits 2 outstanding change(s) that were sitting uncommitted.
No build artifacts and no secrets in the changeset (both checked).
2026-07-26 10:02:10 -07:00
zeekay 4d3e4c6cc1 fix(deps): patch the AWS SDK EventStream DoS
Panic-driven denial of service in the AWS SDK for Go v2 EventStream decoder:
eventstream -> v1.7.8, service/s3 -> v1.97.3 (minimum patched versions, so the
change carries the fix and nothing else).

Verified against a measured baseline: go build ./... exit 0, and go test ./...
is 23 ok / 3 fail BOTH with and without this change — identical, so any failures are
pre-existing and none is a regression from this commit.

Only go.mod/go.sum staged; other working-tree changes in this repo are left
untouched.
2026-07-26 07:57:03 -07:00
zeekayandHanzo Dev cd4972ec28 mpcvm: docs said this was not a chain; it is M-Chain
README.md led with "ThresholdVM is a Go library, not a chain" and LLM.md
instructed agents "do not turn this back into a chain, no new VM ID",
both describing an import graph (chains/mchain, chains/fchain) that does
not exist. While that stood, the VM declared a private thresholdvm vmID
matching nothing else in the stack and the node installed the plugin
under that dead name — M-Chain could not have started.

Rewritten to describe what is actually here: the M-Chain VM, its
canonical vmID and every declaration that must agree with it, the K-vs-t
rule and why the quorum is spelled "3-of-5", the consensus/node-private
state split, what makes a block verifiable, and what is still unproven
(no cross-process DKG yet, no resharing).

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.11
2026-07-25 15:09:22 -07:00
zeekayandHanzo Dev f01758f3a8 mpcvm: sign with the policy's quorum, and prove 3-of-5 end to end
Signing used the key's whole participant set, so a 3-of-5 key signed with
five parties. That works, but it never exercises the quorum: a key that
was accidentally degree-3 would sign identically, which is precisely the
failure this VM exists to catch.

RunSign now selects K signers by ranking participants on
H(tag ‖ keyID ‖ digest ‖ party) and taking the first K. The selection is a
pure function of the task, so every node — signer or not — computes the
same quorum and the same ceremony id with no election. Ranking by digest
rather than taking the first K in canonical order spreads signing across
the committee instead of loading the same K parties for every transfer,
and stops an adversary steering which subset signs a message it does not
control. Nodes outside the quorum return ErrNotInQuorum and verify the
result like any other validator.

Verify deliberately does not pin WHICH K-subset signed. The signature
verifies under the group key or it does not, and an adversary who can
produce one already holds K shares, so constraining the subset buys no
security while leaving it open lets availability-aware reselection ship
later without a consensus change.

TestBridgeCustody_ThreeOfFive is the gate: five validators run a real
CGGMP21 DKG over the gossip fabric at degree 2, exactly three sign, two
decline, the signature verifies under the group key, a block carrying it
is verified and accepted by all five — including the two that never
touched the ceremony — and every node ends on the same state root.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.10
2026-07-25 14:45:13 -07:00
zeekayandHanzo Dev 1c7f329fbd mpcvm: make M-Chain a real custody chain
M-Chain is the MPC/threshold-custody VM that replaces off-chain signer
clusters for bridge custody. This makes it one.

vmID — the one-way door. factory.go declared a private `thresholdvm`
literal that matched nothing else in the stack, and node/Dockerfile
installed the plugin under that name's CB58 while genesis declares
constants.MPCVMID. The two never met, so M-Chain could not have started.
VMID is now the shared constant, and TestVMID_IsCanonicalAndStable pins
the CB58 (qCURact1n41Fco...) with the list of every declaration that must
move together if it ever changes before launch.

k vs t. types.Ceremony carried Threshold/Total as bare uint16s: Validate
read Threshold as the signer count (enforcing threshold*2 > total) while
the VM read it as the polynomial degree and passed it straight to
cmp.Keygen. The same field meant K in one file and t in another, and the
two differ by one — the same silent off-by-one that produces a 4-of-5
wallet from a config saying 3-of-5. Both now carry a quorum.Policy, and t
is only ever obtained by calling Degree(). The majority floor survives as
types.HasUniqueQuorum, defined once and enforced on every key record.

State. The VM kept its custody keys in a map persisted only on a clean
Shutdown, with the key share excluded from the wire entirely, and reset
lastAccepted to a recomputed genesis block on every Initialize — so a
restart discarded the chain and came back unable to sign. State is now
persisted at the moment each fact becomes true, split into a replicated
c/ keyspace (key registry, ceremony log, blocks, height index, root) and
a node-private n/ keyspace (this validator's shares), and Initialize
resumes the accepted tip.

Blocks verify. A block carries its post-state root and every operation's
artifact. Verify re-checks a signature against the group key already in
the registry — a proposer cannot fabricate one without forging ECDSA — and
recomputes the root, so two validators that would diverge cannot both
accept. Keygen operations carry a proof of possession by the new group key
over its own registration; a participant additionally cross-checks the
declared degree against its own share, so one honest participant rejects a
mis-declared key.

Ceremonies are leaderless. The id is derived from the task
(key, digest, sorted signers), so every validator converges on the same
ceremony with no announce round and no coordinator, and the committee is
the chain's own validator set rather than an operator roster.

Address derivation was SHA-256 where every EVM chain uses Keccak-256, so
the published custody address did not belong to the group key. Funds sent
there would have been unspendable.

Deletes the duplicate signing paths and the in-memory session machinery:
one way to generate a key, one way to sign.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-25 14:36:22 -07:00
zeekayandHanzo Dev a8980c537b cevm: one entry point, one result type
The package exported ExecuteBlockV1, V2, V3, V4 and ExecuteBlock for a single
operation, plus BlockResult and BlockResultV2 for a single result. V2/V3/V4
were pure argument adapters over the same implementation and V1 additionally
downcast the result into a lossy struct, so the versions described how the API
had grown rather than anything a caller needs to choose between.

Now: ExecuteBlock(backend, numThreads, txs, ctx, state) -> *BlockResult.
Callers without a block context or state snapshot pass nil, which is what the
adapters did on their behalf. BlockResult carries the full shape; the lossy
variant is gone. Nothing outside the package used the versioned names.

The duplicate tests went with them — TestExecuteBlockEmpty existed three times
and the backend smoke test twice, one per version, all exercising the same
function. Merged, keeping every distinct assertion.

go vet clean; ./evm/cevm and ./evm/cevm/parallel both pass.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-25 13:34:03 -07:00
zeekayandHanzo Dev 5cc29b3da0 cevm: regression test for Metal resource exhaustion
Executes 250 blocks on the Metal backend. Before the luxcpp/cevm fix that
built the device, queue and pipelines once, this exhausted Metal and faulted
inside the driver; the crash needed volume, so no single-execution test caught
it. Runs in about a second.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-25 13:20:09 -07:00
zeekayandHanzo Dev cb072af0c3 cevm: bind to the clean unversioned C ABI, drop the compat shim
The Go bindings called gpu_execute_block_v4 / gpu_free_result_v2, which
luxcpp/cevm main does not export — it renamed those to gpu_execute_block /
gpu_free_result. A luxd built CGO_ENABLED=1 against the shipped libs would have
failed to link, not merely mismatched a version. It stayed hidden because
production builds pure-Go and this box had stale branch-built libs installed.

cevm_v4compat.h and cevm_abi_v4compat_cgo.go existed only to bridge that gap —
113 lines whose own comment described the header and library being out of step.
Deleted; cgo already includes the library header directly.

ABIVersion is now taken from that header (C.EVM_GPU_ABI_VERSION) instead of a
hand-maintained Go constant that had drifted to 7 while the header said 6. One
definition, so the two cannot disagree; a runtime panic no longer has to catch
what the compiler can.

go vet clean; ./evm/cevm and ./evm/cevm/parallel both pass.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-25 13:19:09 -07:00
zeekay 8d0335a46b chore(chains): remove dexvm/plugin — gRPC transport, ZAP-native only
//go:build grpc importing node/vms/rpcchainvm. Unbuildable (Makefile takes the
cmd/plugin branch), unreferenced, and a second transport where ZAP is the one.
All 11 sibling VMs have cmd/plugin and only cmd/plugin.
2026-07-25 12:26:39 -07:00
zeekayandHanzo Dev 9ec41cf150 Native-ZAP struct-is-wire for the four straggler VMs (aivm/keyvm/quantumvm/dexvm)
Completes the "one and only one way" wire migration: every VM in this repo now
serializes blocks and txs through luxfi/zap struct-is-wire at fixed field
offsets — no encoding/json, no hand-rolled big-endian, no cursor codec.

- aivm: Block + AIVertex + nested CIntent native-encoded. Fixes a latent
  data-loss bug — AIVertex had unexported fields, so encoding/json silently
  marshalled it as {}; the native encoder round-trips every field.
- keyvm: Transaction (signing preimage = SigningBytes(), excludes Auth/Sig;
  full wire = signing || sig object, so the signed bytes stay a genuine prefix
  and authenticate() re-derives a byte-identical preimage) + Block.
- quantumvm: Block + BaseTransaction. Block wire is the quantum-signature
  preimage (deterministic fixed offsets, ordered tx list).
- dexvm: block envelope (txs were already ZAP). Preserves UnixNano timestamps,
  deterministic tx order, and content-addressed carried fills.

Hardening from the adversarial crypto review (must-holds all verified: no
keyvm signature malleability/replay, no aivm CIntent id-forgery/escrow tamper,
no dexvm ordering/fill tamper, all parsers fail closed):

- keyvm (M1): make the wire strictly canonical. zap follows the root offset
  and ignores unreferenced padding inside a message's declared size, so a twin
  buffer could decode to identical fields with a different hash (id-malleability).
  ParseTransaction now binds the id to the re-serialized (canonical) form and
  rejects any non-canonical input at the door — exactly one byte-string
  authenticates per logical tx. Regression test: the padded twin is rejected.
- quantumvm (M2): derive block + tx ids as sha256(Bytes()), matching the other
  three VMs. The prior ids.ToID(parent||height) over 40 bytes (and ids.ToID over
  the >=32-byte tx wire) silently yielded ids.Empty for every block/tx,
  collapsing the block store and tx pool to a single slot. The block id is no
  longer stored in the wire, so the invariant id == sha256(Bytes()) holds
  unconditionally. Regression tests: ids are non-Empty and collision-distinct.

Not in scope (tracked separately, fail-closed today): aivm RewardPerOperator is
escrowed but not bound by ComputeIntentID — a pre-existing design gap that needs
a coordinated A-side + C-side intent-id preimage bump before any non-fail-closed
CCommitVerifier ships.

Builds + tests green under the canonical CGO=0 -mod=readonly recipe; go.mod/go.sum
are the go mod tidy canonical set (indirect deps resolved forward, all within
major). Cryptographer verdict: SAFE TO PUBLISH.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.9
2026-07-16 13:44:24 -07:00
zeekayandHanzo Dev 7786bf1a56 dexvm: remove dead blockInterval field; lock ns-resolution sub-second cadence + monotonic clamp
dexvm (D-Chain / DEX) is ALREADY 1ms-capable: nanosecond wire timestamps (UnixNano round-trip on
both linear Block and DAG DexVertex paths), Config.BlockInterval default already 1ms ('1ms blocks
for HFT'), deriveBlockHash on UnixNano, no whole-second truncation anywhere. The one artifact was a
dead ChainVM.blockInterval field (100ms, never read repo-wide) that falsely implied a floor — removed
(zero readers → semantically inert). Config.BlockInterval is now the single source of truth. Added two
//go:build redteam tests locking the previously-untested monotonicity clamp at ns resolution across
BOTH proposer entrypoints (BuildVertex + BuildBlock): a 1ms clock advance survives byte-exactly, the
sub-second component is preserved, a backward clock is clamped non-decreasing. Fairness invariants
unchanged (height is the strict total-order sequencer; block time non-decreasing; distinct sub-second
times → distinct block hashes; proposer-carried time, no wall-clock ordering race). Settlement/matching
untouched. Combined with the sub-second proposervm (node v1.36.19 UnixMilli window), a co-located
fiber/InfiniBand DEX runs 1ms blocks natively.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.8
2026-07-16 11:01:36 -07:00
zeekay 5f992370d3 chore(deps): bump geth v1.20.1 + luxfi deps — stack unification v1.7.7 2026-07-15 11:17:08 -07:00
zeekayandHanzo Dev 17af562c54 deps: realign to node v1.36.9 / utxo v0.5.7 / zap v1.2.5 (surviving tags)
Bumps the plugin subgraph off wiped intermediate tags (was node v1.36.4 → utxo
v0.5.1) onto the current surviving set. dexvm/txs/wire.go: setBase helpers take
zap.ObjectBuilder by value (v1.2.4+ StartObject returns a value type). Full
chains build clean; migrated-VM + mpcvm/bridgevm suites green.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.6
2026-07-14 15:55:09 -07:00
zeekayandHanzo Dev 2c8810fde4 graphvm: migrate block wire json→native ZAP (G-Chain codec-free)
The G-Chain block Bytes()/ID path used json.Marshal(blockWire{...}); now a
fixed-offset ZAP object (ParentID 32B, Height u64, Timestamp i64, Payload bytes).
blockID = hash(canonical ZAP wire), deterministic across nodes/restarts, trailing
bytes rejected. Dropped encoding/json from block.go. Round-trip + determinism +
trailing-byte tests green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-14 15:39:27 -07:00
zeekayandHanzo Dev c7e0109555 identityvm: migrate block/credential wire json→native ZAP (I-Chain codec-free)
Block, Credential, CredentialProof, Identity, ServiceEndpoint, RevocationEntry
all encode as fixed-offset ZAP objects (nested lists via u32-lens + concat blob,
mpcvm/bridgevm idiom). blockID = sha256(canonical ZAP wire). block.Bytes(),
computeID, and the 3 vm.go block-parse sites + identity/revocation persistence
all native ZAP; dropped encoding/json + encoding/binary from block.go.
HOLDOUT (correct): Credential.Claims is a schemaless map[string]interface{} VC
document — carried as a canonical key-sorted JSON blob inside a ZAP bytes slot
(encoding arbitrary dynamic JSON 'natively' = re-introducing a reflection codec).
Structural wire is 100% ZAP. 34/34 tests green (round-trip all fields,
determinism incl multi-key claims canonicalization, VM parse-block path).

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-14 15:35:52 -07:00
zeekayandHanzo Dev 3769a8ab27 dexvm: migrate tx wire json→native ZAP (~20-30x faster, D-Chain codec-free)
All 5 concrete tx types (ImportTx/ExportTx/RelayOrderTx/PlaceOrderTx/
CancelOrderTx) + nested AtomicInput/AtomicOutput now encode as
[typeByte][ZAP object] at fixed offsets instead of [typeByte][json body].
Repeated fields use the bridgevm blob-list idiom (u32 lens-list + concat blob
via SetList/SetBytes) — chains-module zap v1.2.0 has AddObjectPtr but not the
List.ObjectPtr reader. Deterministic by construction; TxID = Checksum256(wire);
canonical trailing-byte reject. Bench (ImportTx 2in/2out): marshal 18255→912ns
(~20x), parse 14923→498ns (~30x), far fewer allocs. Round-trip + determinism +
signature-through-wire tests green; dexvm redteam/conservation/custody suites
green (parse txs through this codec e2e).

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-14 15:35:09 -07:00
zeekayandHanzo Dev cf6e2972c0 schain: migrate tx wire json→native ZAP (last codec on S-Chain)
PutManifestTx + AllocateTx now encode as [typeByte][ZAP object] instead of
[typeByte][json body]. Fixed-offset ZAP objects (FileIDs/pubkey/sig as
variable tails, NodeID/Fingerprint as fixed bytes) — deterministic by
construction, TxID = Checksum256(wire). Parser dispatches on data[0], rejects
trailing bytes (canonical). Removed the unused generic json Marshal[T].
11 txs tests green (round-trip all fields incl ML-DSA pubkey/sig, determinism,
trailing-byte + bad-type rejection); schain/pinning/state suites green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-14 15:32:59 -07:00
zeekayandHanzo Dev ce3cd0b35b strip 40 dead serialize:"true" tags (aivm/zkvm/graphvm/mpcvm)
These VMs marshal Config via JSON and wire via hand-rolled/ZAP — nothing reads
the serialize tags (the reflection codec is gone). Pure dead metadata. Full
suites green (byte-identical), confirming no reader existed.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-14 15:14:30 -07:00
zeekayandHanzo Dev 42fae205b8 deps: pin node v1.36.4 (real tag, was wiped v1.30.6) + realign genproto
chains pinned github.com/luxfi/[email protected].6 — a tag that no longer exists on the
remote (node's tags were mass-wiped; v1.30.6's lineage is disjoint anyway), which
broke every hermetic build of the VM plugins with 'unknown revision v1.30.6'.
Repin to node v1.36.4 (the current published codec-kill release; all 10 VM
cmd/plugin targets verified compiling against it). No local replace directive.
Realigned genproto so the split googleapis/rpc resolves by longest-prefix (no
monolith ambiguity), matching node's go.mod. tidy + -mod=readonly build clean;
bridgevm/zkvm/mpcvm tests green.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.5
2026-07-12 21:36:03 -07:00
zeekayandHanzo Dev 4ba9544b51 bridgevm: B<->M release path + canonical attestation seam
Completes the bridge cross-chain release leg the recon flagged as missing.

- internal/bridgeattest: the ONE canonical definition of the bridge-transfer
  attestation seam between B (bridgevm) and M (mpcvm). M threshold-signs a
  domain-bound bridge-transfer digest; B and every EVM gateway holding the group
  key's address verifies before mint/release. Domain-bound + replay-safe; test
  proves a signature over one transfer verifies for THAT transfer and no other.
- evmclient.go: the concrete EVM ChainClient B instantiates per external EVM
  (Zoo 200201, Lux testnet C 96368) — chainClients was never populated because
  the interface had SendTransaction but no concrete EVM client; B could not
  broadcast a release. Now wired via evmByChainID in factory.
- release.go: on a confirmed lock/burn in an accepted block, B builds the
  destination release call, obtains + verifies M's threshold attestation, and
  broadcasts to the destination gateway. Both directions (Zoo<->Lux).
- attest.go: injects the two runtime deps (Warp->M, KMS relayer key) as clean
  interface boundaries rather than hard-wiring transports.

Co-authored-by: Hanzo Dev <[email protected]>
v1.7.4
2026-07-12 16:44:17 -07:00