100 Commits
Author SHA1 Message Date
zeekayandHanzo Dev b97c5e778f Take threshold v1.12.16 and fhe v1.11.3
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 14:04:16 -07:00
zeekayandHanzo Dev ac06110e68 Threshold FHE in the default build
The wallet sessions and the encrypted-policy gate run
luxfi/threshold/protocols/tfhe. The experimental_tfhe tag and the
not-implemented stub are gone.

Key generation runs across nodes: each samples its own contribution,
broadcasts the collective-key share and unicasts one Shamir point per peer,
then folds its inbox into a share. The reference polynomial comes from the
wallet and the committee in canonical order, and a message may only speak
for the position of the member that sent it.

Decryption collects contributions bound by digest to the ciphertext and
keeps a round open until they recombine; anything short is an error, which
the verifier reads as a denial. A go/ast gate refuses any call that would
form a whole FHE key.

go.mod needs the two dependency patches tagged, then `go get` and tidy.


Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 13:46:52 -07:00
zeekayandHanzo Dev 52b4270ad6 Build on Go 1.27
The main module builds, vets and tests clean on the 1.27.1 toolchain.
The e2e module is left alone: its go.sum records a different hash for
github.com/luxfi/age v1.5.0 than the module cache serves, which fails
the same way on the old toolchain.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 13:24:01 -07:00
zeekayandHanzo Dev 85762116d9 Typecheck on the native compiler, keep 6 for the tools
typescript 7.0.2 is the native compiler and exports no JavaScript API. The ui and
docs projects have no consumer of that API — vite, and next 16, which reads its
own tsconfig setup — so they take the pin directly: 0 diagnostics before and
after in ui, 25 down to 3 in docs. The dashboard runs next 15, which cannot read
it, so 7.0.2 comes in aliased as tsgo and does the typechecking while typescript
stays on the 6 line: 3 diagnostics down to 1.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 00:27:34 -07:00
zeekay 33904098d4 A release that does not fail at what it cannot do here
The image and manifest jobs never had a builder on this plane. A job runs in an
ephemeral pod with no docker daemon, so the only builder is the cluster's
buildkit, and its NetworkPolicy does not expose it to the namespace CI runs in.
That is deliberate: the daemon speaks plaintext and its own config says it is not
hardened for multi-tenant use, so admitting a namespace that runs arbitrary
repository code is exactly what the scoping prevents.

Attempting it anyway left every tag with a red release. A pipeline that always
fails is not a signal, it is a thing people learn to scroll past — and this one
was hiding that the binaries and the release notes were fine.

So this copy publishes the binaries and cuts the release, and says in full where
images come from instead: the recipe that built v1.17.38, including the part
that is easy to get wrong — address the buildkitd POD and not the service, or an
amd64 build lands on the arm64 node and dies inside QEMU.

Two things would bring the jobs back and both are decisions rather than edits:
TLS on the daemon, or CI in the build namespace, where the existing policy
already admits it and the existing egress deny already contains it.
2026-09-11 00:05:30 -07:00
zeekay 365093d6c1 Reach the builder instead of starting one
Both image jobs died at builder init:

  dial unix /var/run/docker.sock: connect: no such file or directory

A job here runs in an ephemeral pod that has the docker CLI and no daemon, so
`driver: docker-container` has nothing to start a builder in and QEMU has
nothing to register with. That is the shape of the runner, not a fault in it.

The cluster already runs buildkit as a DaemonSet, one node per architecture,
behind a service. So the builder is reached rather than started, and the
emulation question goes away with it: each node builds its own platform
natively, which is also faster than the QEMU path this replaces.
2026-09-10 22:57:29 -07:00
zeekay 53c38ddce8 The artifact version each plane will accept
The first run of this workflow on github.com failed both binary jobs at step
one, before checkout, with:

  This request has been automatically failed because it uses a deprecated
  version of `actions/upload-artifact: v3`

github.com fails the whole JOB for that, not the step — so nothing in the
workflow had run, and the two jobs that depend on those sat queued behind them.
It reads exactly like a stuck runner, which is what I took it for until the job
log said otherwise.

The forge refuses v4 as plainly as github.com refuses v3, and a v4 download
cannot read a v3 artifact, so both sides move together or neither does. The
version is the one line the two copies cannot share, and each now says so.
2026-09-10 22:45:11 -07:00
zeekay a7b829a6ac The release workflow where the tags are
v1.17.33, .34 and .35 were tagged and none of them produced an image: the
registry answers 404 for all three and only v1.17.32 resolves. That is the image
the MPC quorum runs, which is why the endpoint a caller needs to learn the key it
seals to is not there to call, and why the data-plane master is still read from
an environment variable instead of opened by a quorum.

The workflow was correct and was somewhere nothing triggered it. Its twin under
.hanzo/workflows is what the forge collects; this copy is what github.com
collects, and github.com is where the tags are.

The note in the twin about github.com carrying no self-hosted runners was true
when it was written. It is not now: the hanzo-platform App is installed across
the luxfi org and provisions a runner per queued job, and GHCR_USER/GHCR_TOKEN
are org Actions secrets there.
2026-09-10 20:21:50 -07:00
zeekayandHanzo Dev 7dca922b69 go 1.26.8
One toolchain across the estate. The tree carried 1.26.0, .1, .3, .4 and .5
at once, so which compiler built a binary depended on which repo it came
from.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-09 00:00:44 -07:00
zeekayandHanzo Dev 6ab4e2e48b ci: a runner label names the platform, not the org
hanzo-build-linux-amd64, lux-build-linux-amd64, zoo-build-linux-amd64 and
every other org-prefixed spelling all meant the same machine. Which org a
job belongs to does not change which host can build it, so the org bought
one pool name per org per arch and each could go stale on its own -- the
evo host alone answered to fourteen names for one label.

One vocabulary now: linux-amd64, linux-arm64, macos-arm64, win-amd64. The
generic labels every plain workflow uses (ubuntu-latest, self-hosted) are
untouched, so naive CI keeps working.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 12:08:05 -07:00
zeekay 6e1fc792ca every Hanzo dependency is the version npm serves
Ranges drift once nothing moves them: a caret that has not been rewritten
since it was written pins whatever was current that day, and an exact pin
cannot even take a patch. Both are updated here.

One line per package, so a surface reads the same components, the same
tokens and the same face as every other one.
2026-09-01 21:01:02 -07:00
zeekay 6180883f76 every Hanzo dependency is the version npm serves
Ranges drift once nothing moves them: a caret that has not been rewritten
since it was written pins whatever was current that day, and an exact pin
cannot even take a patch. Both are updated here.

One line per package, so a surface reads the same components, the same
tokens and the same face as every other one.
2026-09-01 16:02:57 -07:00
zeekay a3861b8c46 Keep the forward pins only where the module builds
The previous commit carried go.mod changes from modules that do not build,
which is not a state worth publishing. Restore those and keep the pins for
the modules that resolve and build clean.
2026-09-01 13:30:03 -07:00
zeekay 2c82107216 Pin past the versions the proxy can no longer serve
The bulk retag moved published tags. go.sum and sum.golang.org hold the
original bytes, proxy.golang.org answers not-found, and the direct fetch
disagrees, so builds stopped at a checksum before reaching a compiler.
Pin forward to versions the proxy still serves.
2026-09-01 13:11:47 -07:00
zeekay 3907d343d6 fix(deps): re-record the hashes of the tags that moved
luxfi/[email protected], luxfi/[email protected].2, luxfi/[email protected] and
luxfi/[email protected] were force-moved on 2026-07-15 after these files recorded
them, so every build stopped at SECURITY ERROR: checksum mismatch before
reaching a compiler. [email protected] was recorded in go.work.sum rather than
go.sum, which is why the workspace failed even once the module was clean.

The replacement bytes are ours: the go.mod each tag now serves is
byte-identical to the one at our own tag. go.sum held each tag's earlier
content.

Verified on a machine with the native libraries: go build ./... exits 0.
That also needed luxfi/threshold, this workspace's sibling, to carry the same
correction and to be current — protocols/reveal, which pkg/reveal imports,
landed there after this checkout was last updated.

sum.golang.org is append-only and still holds the ORIGINAL bytes, so these
versions can never verify against the public notary again. The durable fix is
to stop moving tags: a version is an address, and the remedy for a bad release
is the next patch, never the same number twice.
2026-08-28 20:47:10 -07:00
zeekay 8260df659e the corporate name is Lux Industries Inc
Lux Partners Limited is not the entity behind this work. Every place the
old name stood — copyright lines, site footers, brand config, legal and
compliance text — now names the company that actually holds it.

lux.fund is the exception and is not renamed to match: that site belongs
to Lux Assets Management LLC, a different company, and it says so.

Two kinds of line were left for a person rather than rewritten. A dated
news item recording that Lux Partners Limited was established in the Isle
of Man is a historical fact, and renaming it would falsify the record. And
a sentence asserting that the company is domiciled in the Isle of Man is a
claim about a jurisdiction, not a name — an Inc or an LLC is not an Isle
of Man company, so swapping the name there would assert something untrue.
2026-08-28 13:03:33 -07:00
zeekay 7ce84ffddf the binary serves the face it names
pkg/api embedded 56KB of genuine Geist and served it at /fonts/Geist-Variable.woff2, and the landing page's @font-face asked for it by name — so mpc.hanzo.ai painted its only pre-auth surface in a typeface the estate does not use. The bytes are Zen now; checked at the name table, family 'Zen', with the OFL notice the licence requires intact.

The dashboard was worse than wrong, it was blank: its layout published --font-geist-sans off the geist package while tailwind.config.ts asked for --font-zen-sans, so font-sans resolved to nothing and every screen fell back to the browser default. The docs layout imported Zen under the name GeistSans, which compiles and lies.
2026-08-28 11:36:40 -07:00
zeekay 781be44de6 fix(type): the binding is named for the face it holds
The docs layout named three different faces in one place: a binding called
`inter` or `geist`, constructed from next/font/google so it actually held Inter,
publishing `--font-geist-sans`, which @hanzo/font does not set. Whichever a
reader trusted, two were wrong.

It takes Zen and ZenMono from @hanzo/font — named `sans` and `mono` for the role
they fill, setting the properties the package publishes.
2026-08-28 09:27:41 -07:00
zeekay 3a2cd1b6b1 fix(docs): self-hosted Zen, not a third-party font request 2026-08-23 16:02:31 -07:00
zeekayandHanzo Dev c84366bc62 Sign EVM transactions through the MPC, no assembled key
Deploys and contract calls on the Lux C-Chain signed from a plaintext
mnemonic or a k8s-held private key. Route them through the MPC instead:
pkg/evm derives the account from the keygen secp256k1 pubkey, sends the
transaction digest to the existing CGGMP21 /v1/mpc/sign oracle, and
assembles r,s,v — the key is never reconstructed in one place. SignTx
recovers the sender from the signature it just applied and refuses if it
is not the signer's own account, so a wrong recovery id is caught before
broadcast rather than after. `mpc evm address|recover|sign|tx` drives it
from the CLI. The /v1/mpc/sign HTTP responses now also return v, matching
the KMS and ZAP paths.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-21 13:42:46 -07:00
zeekay 008f3c7bea group imports the way .golangci.yml asks
The lint job runs goimports with local-prefixes github.com/luxfi/mpc, so a
luxfi/mpc import has to sit in its own group after the third-party ones. Three
files did not, and the job has been red on all three.

server.go also carried a trailing blank line left where a const was removed.
2026-08-21 01:19:30 -07:00
zeekay c130a582ad answer /favicon.ico, which a browser asks for on its own
The page names an SVG icon and that is what a current browser uses. One that
does not read SVG icons asks only for /favicon.ico, got a 404, and showed a
blank tab — the defect the SVG was meant to close, still open for that reader.

The ICO is the same mark, byte-for-byte what hanzo.ai serves, and it hangs off
the brand row like everything else: a host with no row still answers 404, which
is the right answer for an operator with no mark.
2026-08-21 01:06:56 -07:00
zeekay af382fa0b3 the page at / names the host that answered
One operator's page was compiled into the binary, so every host this runs behind
served the same wordmark, the same links and a bridge on someone else's domain.
mpc.hanzo.ai answered with a page carrying no operator name at all — the reader
had no way to tell whose custody service they were looking at.

brand.go resolves the operator from the Host header and landing.go renders the
page for it: wordmark, mark, docs and site link. A host with no row renders
unbranded, which is what anyone running this themselves should get, and is why
adding a row is the whole cost of onboarding an operator.

The mark doubles as /favicon.svg so the tab is not blank, and Geist is served
from the binary at /fonts — a cross-origin stylesheet is refused by the browser
and would leave every reader on system-ui while every rule asked for Geist.

Colours are the operator's, not a gradient: the two greys that sat under 4.5:1
on black are lifted to 4.9 and 6.1.
2026-08-21 00:44:55 -07:00
zeekay 282480e0a6 carry the github copy onto the forge
github.com held commits this host lacked, which refuses the mirror push
("fetch first") and stops github receiving anything at all. Merging rather
than overwriting, so the mirror is a fast-forward again and neither side's
work is dropped.
2026-08-20 21:55:59 -07:00
zeekay 3e7d19198d run on the native fleet
Adds .hanzo/workflows/cicd.yml calling the canonical pipeline and drops the
GitHub-hosted workflows it replaces. Lands here because this is where the repo
is written — git.hanzo.ai is upstream and github.com is its push mirror, so the
same change made on github.com never reaches the runner fleet.
2026-08-20 20:13:03 -07:00
zeekay 1f834c42cc track the name agents read, so a clone documents itself
LLM.md is the source and stays the source; CLAUDE.md is a symlink to it, so there is
one file and it cannot drift from itself. What changes is that the link is committed
rather than left for each checkout to recreate.

Left untracked, the doc was conditional on a step outside the repo: a fresh clone had
no CLAUDE.md, nothing loaded LLM.md, and an agent worked here having never read what
this repo already explains. Most of the estate already tracks theirs; this is the rest
agreeing with them.
2026-08-20 16:38:10 -07:00
zeekayandHanzo Dev 02988f1b80 a key share is protected by a secret, or by nothing at all
Without client credentials the master key was derived from the project id — both
the salt and the password — so anyone holding that public identifier could
compute it and open every share it protected. It logged a warning and carried on;
a warning is not a control, and encryption under a value the world can read is
not encryption.

There is no client now rather than a weak one. Real credentials build exactly as
before, so this is about having something secret to derive from and not about the
constructor being shut.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-20 09:50:05 -07:00
zeekayandHanzo Dev d7e2170c27 mpcd: state where the dashboard keeps its data
Base falls back to a data directory beside the binary when none is given,
which is the container root. That is writable for a root process and not for
any other, so the same image served the dashboard or silently did not
depending on which uid it ran as -- the server reported ready, bound nothing,
and the node answered on every port except that one.

The directory now comes from the node's own dataDir, which is the one place
the process is guaranteed to own, so the answer no longer depends on where it
happened to be started.

The failure it produced also said nothing: the reason was passed beside the
message and rendered as an empty field, leaving one line that could not
distinguish a dashboard that failed to bind from one nobody had asked for.
The reason is in the message now, with the address and directory alongside.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-16 13:19:42 -07:00
zeekayandHanzo Dev 808b1ce3af mpcd's ZAP surface: fix the frame, and actually listen on it
Two independent faults, either of which alone made the surface unusable.

THE FRAME. A ZAP field offset is a BYTE offset and a bytes field is EIGHT of
them — SetBytes writes a uint32 pointer at the offset and a uint32 length at
offset+4. The request layout spaced five fields ONE byte apart (orgID 8,
walletID 9, payload 10, intentID 11, network 12), so every write destroyed the
one before it. Measured: the handler read back org_id "" with wallet_id intact,
and since zapKeygen refuses an empty org_id, that layout rejected every request
any client could physically send. The reply was the same shape — status 8,
data 9 — putting data's length word one byte past what StartObject(16)
reserved, so an 85-byte JSON body came back with '{' replaced by 0x00.

There was therefore no wire to preserve, only one to choose. This is now
byte-for-byte hanzoai/commerce's util/zapwire — status at 0, one bytes field at
8, object 16 — which is what the only client of this surface has always spoken.
Arguments travel as JSON in that one payload rather than as parallel text
fields, so the next argument is a struct field instead of an offset nobody can
see is wrong.

THE LISTENER. StartZAP had zero callers. The handlers existed in the binary and
answered on no port, so keygen/sign/status/wallets/intent were reachable over
HTTP only and commerce's ZAP transport dialled a listener that was never there.
It is now started from main beside StartScheduler, behind --api-zap-listen /
MPC_API_ZAP_LISTEN — an address, empty disables, exactly the shape
--kms-zap-listen already uses. StartZAP takes an address for the same reason:
mpcd's other listeners are addresses, and one shape for "where do I listen" is
worth more than saving a parse.

Tests reproduce commerce's frame rather than importing it (private module, and
this is the contract): both arguments survive a round trip, the reply parses as
JSON through the client's offsets, an error rides the same field as a success
behind the status byte, and a non-JSON body is refused as a wire error instead
of being reported as a missing argument. Mutation-checked — restoring the old
offsets fails three of them.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-08 14:25:43 -07:00
zeekayandHanzo Dev 8634be7b15 release: upload-artifact v3 — v4 is refused by this forge
Both matrix legs cross-compiled mpcd and then discarded it at the final step,
so two successful builds were recorded as two failures. 20 of those in the
last seven days, the most of any repo on the forge.

    ::error::@actions/artifact v2.0.0+, upload-artifact@v4+ and
    download-artifact@v4+ are not currently supported on GHES.

git.hanzo.ai presents as GHES and the v4 action refuses outright. Measured
across every upload-artifact step on the forge over 30 days:

    v3   132 ok,  1 fail
    v4     0 ok, 43 fail

v4 has never once succeeded here. luxfi/corona is the controlled case: on
v4 at 08-02 08:31 it failed, and at 08:59 on v3 all five matrix legs passed,
after a one-line commit.

Pinning rather than marking the step continue-on-error: that would keep the
job green while silently throwing the artifact away, which is the failure
this is meant to end.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 16:10:19 -07:00
zeekay bc08c7363d TON and XRP deposit addresses, derived the way the spender derives them
TON's wallet version is not a preference, it is FORCED. The account_id is a hash
of the wallet contract's StateInit, so the same Ed25519 key yields a different
address under v3R2, v4R2 and W5 — and luxfi/bridge's TON assembler is the only
code in the estate that can SPEND TON. It builds v4R2 with subwallet 698983191.
Emitting any other version would produce addresses that fund normally and that
our own signer can never deploy a contract for: money in, unspendable. So this
makes the identical call into the identical library rather than hand-rolling
cell serialization.

Non-bounceable (UQ…) because a bounceable message to a not-yet-deployed contract
is returned to sender, and a deposit address is undeployed by definition until
its first spend. Bridge makes the same choice.

⚠ SECOND LIVE WRONG-ADDRESS BUG, same shape as the TON/Solana one removed last
week: handlers_wallets served addresses["xrp"] = *wallet.EVMAddress — a 20-byte
hex EVM address labelled XRP. Both xrp and ton now derive from the stored public
keys through pkg/address. Derived on READ rather than adding columns, so every
wallet minted before today gets a correct address with no migration.

Vectors are external on purpose. XRP matches three published XRPL fixtures
(xrpl.js secp256k1 + ed25519, and the dev-portal address-encoding example). TON
uses MAINNET as the oracle — library fixtures would be self-referential — by
harvesting accounts whose code_hash is the v4R2 code hash and asking each
contract for its own public key; one row is corroborated independently by
@ton/ton's published spec address. The code cell is pinned to mainnet's hash and
the data layout to seqno‖subwallet‖pubkey.

Curve confusion fails closed both ways: TON refuses a secp256k1 key, XRP refuses
an Ed25519 one, and TON refuses every torsion-order point Solana refuses.

Known trap left alone deliberately: pubKeyToEVMAddress and pubKeyToBtcAddress
still assume even-y for a 32-byte input, which is wrong for half of keys. Not
reachable today because keygen serializes the 33-byte compressed point, but it
is one refactor from being live. address.XRP refuses that input instead. Fixing
the EVM/BTC paths is a higher-risk edit that deserves its own review.
2026-08-06 17:50:11 -07:00
zeekay 8273105926 The signing curve is derived from the network, not stated by the caller
Solana addresses were correct and receive-only: ConsensusKeyInfoStore.Save was
the only RegisterKey caller and hardcoded "secp256k1", and TriggerSign read the
curve from that field, so no product entry point could reach Ed25519 signing.

The hardcode is not fixed, it is UNREPRESENTABLE. transport.KeyInfo.KeyType is
deleted — the record carries one public key per curve and a PublicKeyFor(curve)
— and RegisterKey lost its keyType parameter, so there is no longer a place to
state a curve wrongly. A scalar KeyType was the wrong shape anyway: one keygen
ceremony produces BOTH a secp256k1 and an Ed25519 key, so "what curve is this
wallet" has no single answer.

The request carries the network instead, and ONE table maps it to a curve, with
NO default arm — KeyTypeForNetwork returns ErrUnknownNetwork rather than
guessing. That is the point: the old default returned secp256k1 for anything
unrecognized, which is how a Solana request would have been signed with the
wrong key.

/sign's 32-byte clamp becomes the SCHEME's rule rather than the endpoint's:
secp256k1 signs a digest and requires exactly 32 bytes, Ed25519 signs the
message at any length. /sign no longer accepts key_type at all — two ways to say
the curve is the misuse being removed — and Network is bound into the
idempotency fingerprint, so the same bytes for SOL and ETH conflict instead of
sharing one cached signature from a different key.

⚠ The trap this nearly shipped with: every pre-existing KeyInfo record has EMPTY
public keys, because the old Save wrote "", "". A naive "does this wallet have
this curve" gate would have refused every existing wallet on every curve. The
check is gated on RecordsCurves() — a record naming no curve denies none, and
the missing share stays the thing that fails closed.

Verified end to end, not by mirroring: a Solana request through
ConsensusMPCBackend.TriggerSign with a real 2-of-3 FROST ring produces a
signature crypto/ed25519.Verify accepts, over a 63-byte message so any
reintroduced clamp or pre-hash fails it; ETH/EVM/BTC/LUX still route to
secp256k1 and must RECOVER to the wallet's key. Four mutations tried, all
caught.

Behavioral risk worth naming: an unrecognized chain string now fails its
transaction closed rather than signing with a default. That is correct, and the
real fix is validating chain at POST /v1/transactions.
2026-08-06 15:59:30 -07:00
zeekay cb77fab979 Ed25519 keygen is real: sol_address comes from KeygenEd25519 and nothing else
luxfi/threshold v1.12.7 added FROST(Ed25519, SHA-512), so Solana and TON finally
have a signable curve. The sessions named "EdDSA" were calling KeygenTaproot —
BIP-340 over secp256k1 — whose key is also 32 bytes, sails through the
len(EDDSAPubKey)==32 gate, and base58-encodes into a real-looking Solana address
the ring can never sign for.

⚠ A LIVE WRONG-ADDRESS BUG went with it: handlers_wallets served
addresses["ton"] = *wallet.SolAddress. A TON address is not a base58 pubkey — it
is workchain plus a hash of the wallet contract's StateInit, base64url with a
CRC16 — so the API was handing out an address that decodes to nothing. Deleted
rather than guessed; TON returns when its encoding is real.

The keygen tests used to call frost.KeygenEd25519 directly, which validated the
LIBRARY and not the daemon: reintroducing KeygenTaproot left every package
green. They now go through the production session, and so does a test that
constructs the real signing session and proves the caller's bytes reach the
protocol unhashed. Both original bugs reintroduced together fail 11 tests.

secp256k1 keygen changes from fail-open to fail-closed, and that is the one
behavioural change to the existing EVM/BTC path. An empty pubkey previously
meant any of nil config, marshal failure, kvstore.Put failure or keyinfo-save
failure — and was returned as SUCCESS, announcing addresses while fewer than
t+1 nodes hold a share. Applying fail-closed to Solana while leaving the
higher-value leg fail-open is incoherent.

Also: the KMS share classifier still keyed off the retired "frost:" prefix; the
CGGMP21 session published its own local failure onto the same first-wins subject
as the ring's verdict, so one node's failure could beat the ring's success; and
one 120s deadline shared across two sequential ceremonies starved the cheap leg.

NOT YET SPENDABLE. ConsensusKeyInfoStore.Save is the only RegisterKey caller and
hardcodes "secp256k1", and TriggerSign reads the curve from that field, so no
product entry point can reach Ed25519 signing. The address is correct; signing
it needs a wire-contract change shared with lux/wallet.
2026-08-06 13:23:18 -07:00
zeekay 843df3d79a the build stops fetching modules it never links
The image lane failed at the private-module fetch, and the module it could not
reach is one the binary does not contain:

  fatal: Authentication failed for 'https://github.com/hanzoai/replicate/'

  $ go mod why -m github.com/hanzoai/replicate
  (main module does not need module github.com/hanzoai/replicate)

It is in go.sum and in no require. luxfi/hsm is the same shape — imported only
by cmd/mpcd/airgap_command_test.go, so it never appears in
`go list -deps ./cmd/mpcd`. Both were being fetched because the step said
`go mod download all`, and `all` means the entire module graph rather than the
build.

So the release was gated on reaching private repositories that contribute
nothing to the artifact, and the three failures before this one — an empty
UNIVERSE_PAT, a token inside a URL, a base64 header — were all attempts to make
that unnecessary fetch succeed.

Bare `download` takes the main module's own requirements, which is what the two
builds compile; GOFLAGS=-mod=mod records any sum still missing. The credential
stays for the private modules that ARE linked.
2026-08-05 19:09:52 -07:00
zeekay 0dc3fb632b hand git the credential directly instead of encoding it
Two shapes failed here in turn, and each failed by a different mechanism:

  in a URL, any byte the grammar ends on truncates it — git then read the
  credential prefix as a hostname ('Could not resolve host: x-access-token');

  in an Authorization header, the value is base64, and when that produces
  nothing git offers NO credential at all and asks for a username
  ('could not read Username for https://github.com: terminal prompts disabled').

A credential helper hands over username and password as themselves. Nothing is
parsed, nothing is encoded, so neither failure has a way in.
2026-08-05 18:51:11 -07:00
zeekay 8986a3f8d1 the git credential travels as a header, not inside a URL
The private-module fetch put the token in the URL, so any byte the URL grammar
ends on truncated it and git read the credential prefix as a hostname:

  fatal: unable to access 'https://x-access-token:***@github.com/…':
  Could not resolve host: x-access-token

Both existing defences were already in place — the workflow strips \r\n from the
secret, and command substitution eats a trailing newline — and it broke anyway.
That is the argument against the shape rather than against the value: a URL is
the wrong container for bytes that are not ours to control. luxfi/node hit this
same failure.

http.extraheader carries the credential as a header, so nothing the token
contains can change how the address parses. The value is base64 of
'x-access-token:<token>', which is what actions/checkout itself sends.
2026-08-05 18:40:34 -07:00
zeekay cbbecd063d a peer is named by its address, so every node agrees who is in the room
Keygen reached round 2 and every node refused every message, including from
legitimate peers, so each attempt sat until the 300s timeout and every crypto
deposit answered 503 "the custody service is not accepting new addresses".

The handler admits a message only from an id in its OWN party set, and the
fleet had built three sets that disagreed:

  partyIDs=["mpc-node-0:keygen:1","mpc-node-1:keygen:1","peer-2:keygen:1"]

while node-2 called itself mpc-node-2:keygen:1. Nobody answers to "peer-2", so
its share never arrived and the ceremony could not complete.

The synthetic name is what an unprefixed --peer produces: the deployment passes
addresses only, self is dropped by address, and everything left took a
POSITIONAL id. The comment above that line already said such an id "cannot
match a real node id by construction" — it was describing this bug without
following it through.

So a peer is now named the same way self is recognised: the first DNS label,
which IS the node id under StatefulSet naming
(mpc-node-2.mpc-node-headless.ns.svc.cluster.local -> mpc-node-2). Deriving both
sides by one rule is what makes the sets agree.

A bare IP has no label to read and keeps the positional id — the caller must
then use nodeID@host:port, which states the name outright. The test asserts the
invariant that was violated rather than the helper's return value: the id a node
derives for a peer must equal the id that peer uses for itself.
2026-08-05 16:36:04 -07:00
zeekay 1183d565d2 kms.lux.cloud is the brand host; kms.lux.network is retired
The .network host was retired 2026-07-27 and no longer resolves at all, so every
reference to it named an address that cannot answer. The brand host is .cloud,
matching kms.zoo.cloud and the rest of the white-label convention.

Left alone deliberately, because rewriting them would invert what they say: the
LLM.md line that RECORDS the retirement, the e2e spec that ASSERTS
kms.lux.network must not resolve, and the recorded applies + cluster backups,
which are faithful accounts of what was applied and are not config.
2026-08-05 07:51:13 -07:00
zeekayandHanzo Dev cea85725c7 docker: a workspace file must not decide what a release contains
go.work names ../threshold, a sibling checkout. Correct for a developer,
impossible in a container — the build context is this repo alone, so the
path resolves to /threshold and `go mod download all` dies before anything
compiles. Every containerized build has failed that way since the entry
landed: v1.17.16 and v1.17.17 are tags with NO image, and the fleet still
runs v1.17.15, so two releases' worth of fixes never shipped and nothing
said so.

GOWORK=off rather than deleting the entry — the workspace is a local
convenience; the build resolves the published graph, which go.mod already
pins at github.com/luxfi/threshold v1.12.5.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 16:45:35 -07:00
zeekayandHanzo Dev 5eaf0304b0 mpcd: a node is not its own peer, and too many ready is not too few
KEYGEN has been dead on the hanzo-mpc fleet — every request answered
503 "peers not ready" while /healthz reported `signing_quorum: true`
beside `ready: false`, and the counters said `ready=5 expected=4` in a
THREE node fleet. Both halves of that arithmetic were wrong.

SELF WAS COUNTED TWICE. A StatefulSet renders one command for every
replica, so the natural spelling hands each node the whole ensemble —
including itself. `peerMap[nodeID] = listenAddr` adds self by id, and the
matching `--peer` then adds it AGAIN under a synthetic `peer-N` id (an
unprefixed address gets a positional id by construction, so it can never
match a real node id, and filterSelf — which compares ids — could never
see it). Three nodes therefore reported four expected members. mpcd now
drops a --peer that names this node, matched by address: equal to
--listen, or a host whose FIRST DNS LABEL equals the node id (compared
whole, so mpc-node-10 is never mpc-node-1).

AND THE GATE WAS AN EQUALITY. `readyCount == len(peers)+1` reads an
OVERSHOOT as not-ready, so once the count passed the expected total
nothing could bring it back — permanently, across restarts. Signing
survived only because it already used >=. A readiness gate must fail
closed on too few, never on too many; both sites are >= now, so the
double-count could not have wedged keygen even before the fix above.

Verified by exercising isSelfAddr over the fleet's real addresses: the
live 3-node template on mpc-node-0 keeps 2 peers, expected total 3.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 16:39:45 -07:00
zeekayandHanzo Dev ceaac298fd build: complete go.sum for the module graph vendor resolves
The build job dies at `go mod vendor`:

    cloud.google.com/go/kms/apiv1: missing go.sum entry for module providing
    package cloud.google.com/go/kms/apiv1 (imported by github.com/luxfi/mpc/pkg/hsm)

pkg/hsm has aws/azure/gcp files behind build tags. `go build ./...` skips them,
so the tree looks fine, but `go mod vendor` resolves EVERY tag and needs hashes
for all of them — kms, secretmanager, pkcs11, kmip-go and their transitives.

`go mod download all` adds them. go.mod is UNCHANGED and the diff is 514
additions, 0 removals: no version moved, only hashes that were always required
got recorded.

⚠️ This does not turn the job green on its own. One blocker remains and it is the
one the repo already documents in cc/attest/alias.go — "mpc cannot bump [threshold]
yet because mpc/pkg/mpc still imports threshold/protocols/tfhe, which that same
threshold branch removed":

    github.com/luxfi/threshold/protocols/tfhe: no required module provides package

Measured: [email protected].5, the version go.mod PINS, does not contain it either.
So `go build -tags experimental_tfhe ./pkg/mpc/` already fails — the experimental
TFHE path cannot compile under any configuration, not merely under vendor. Three
files import it, all behind //go:build experimental_tfhe:

    pkg/mpc/tfhe_session.go
    pkg/policy/fhe_threshold_decryptor.go
    pkg/policy/fhe_threshold_decryptor_test.go

Removing an experimental feature is an owner decision, so they are left alone.

    go build ./...        OK
    go test ./pkg/wallet/ ok

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 11:00:22 -07:00
zeekayandHanzo Dev 69d2d257fa style: goimports with -local, the setting golangci actually uses
The previous pass ran plain goimports and left three files that golangci still
rejects:

    pkg/api/zap_kms_server_test.go:16:1: File is not properly formatted (goimports)

.golangci.yml configures

    goimports:
      local-prefixes: github.com/luxfi/mpc

which puts github.com/luxfi/mpc imports in their own group after third-party.
Plain goimports does not do that and reported the tree clean, so the lint kept
failing on files a local run called fine. Re-run with -local.

    goimports -l                              0
    goimports -local github.com/luxfi/mpc -l  3 -> 0
    go build ./...                            OK
    go test ./pkg/api/                        ok

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 10:23:23 -07:00
zeekayandHanzo Dev e9d5915722 style: goimports all 16 unformatted files, not just the one CI named
golangci-lint failed the lint job on

    pkg/wallet/usage.go:53:1: File is not properly formatted (goimports)

goimports over pkg/, cmd/ and internal/ finds SIXTEEN such files. The linter
reports the first and stops, so fixing only the named one buys exactly one more
run before it names pkg/api/server.go, and so on for sixteen round trips.

All of them are alignment-only: gofmt aligns a var block's `=` to its longest
name, and these blocks had been edited without re-aligning. No import was added,
removed or reordered.

The dashboard/ work in this tree is untouched; all sixteen were clean.

    goimports -l ./pkg ./cmd ./internal   16 -> 0
    go build ./...                        OK
    go vet ./pkg/... ./cmd/...            OK
    go test wallet/policy/registry/transport/zap   all ok

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 10:14:42 -07:00
zeekayandHanzo Dev 7025aff29b fix(docs): generate .source at build time, and load it the way the runtime expects
With the lockfile resynced the docs job got past install and failed at build:

    Module not found: Can't resolve './.source'   (from lib/source.ts)

.source is GENERATED and gitignored, and this package had no step that produced
it — `build` was a bare `next build` and there was no postinstall. It only ever
worked where a stale .source happened to be lying around, which is why a laptop
built it and a fresh checkout never could.

Three things, each found by making the previous one fail differently:

1. Nothing generated .source. Now `hanzo-mdx source.config.ts .source` runs in
   both build and postinstall, the shape luxfi/consensus already uses.

2. `import { docs } from "@/.source"` is a DIRECTORY import and the generator
   emits no index — it writes browser.ts / dynamic.ts / server.ts. consensus
   imports "@/.source/server"; so does this now.

3. `docs.toFumadocsSource()` did not exist, and neither did `toSource()` — the
   generated code targets `@hanzo/docs/mdx/runtime/server`, while lib/source.ts
   pulled its loader from `fumadocs-core`, mixing two stacks. It resolved
   @hanzo/docs 16.2.7 where consensus runs 16.4.x and works. Loader now comes
   from `@hanzo/docs/core/source` with `toSource()`, and the range is pinned
   ^16.4.1 to match consensus rather than left to drift.

Verified as a fresh checkout would run it — node_modules and .source deleted
first:

    pnpm install --frozen-lockfile   OK
    .source after install            generated by postinstall
    pnpm build                       OK

Only docs/ is touched; the dashboard/ work in this tree is left alone.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 06:22:34 -07:00
zeekayandHanzo Dev 760f491735 fix(docs): resync pnpm-lock.yaml after geist was added
The docs workflow died at install:

    ERR_PNPM_OUTDATED_LOCKFILE … specifiers in the lockfile don't match
    specifiers in package.json:
    * 1 dependencies were added: geist@^1.3.0

geist went into docs/package.json without the lockfile being regenerated, and
`pnpm install --frozen-lockfile` — the default in CI — refuses that.

Only docs/pnpm-lock.yaml is touched; the dashboard/ work in this tree is left
alone.

    pnpm install --frozen-lockfile   exit 0  (was ERR_PNPM_OUTDATED_LOCKFILE)
    pnpm build                       exit 0

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 06:01:37 -07:00
zeekayandHanzo Dev 2a1b92821d build: bump Go builder images to 1.26.5, set GOTOOLCHAIN=auto
Currency bump: golang builder images -> 1.26.5 (latest stable).

Adds ENV GOTOOLCHAIN=auto to each Go builder stage. The official golang
images set GOTOOLCHAIN=local, so a builder image older than the module's
go directive is a hard mid-build failure. The pin stays the hermetic fast
path; auto is the safety net for the next go.mod bump.

No go.mod go directive was changed. Builder images move; directives stay.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 16:27:21 -07:00
zeekayandHanzo Dev 0498934650 ci: one CI path — .hanzo/workflows
Every job in all seven workflows named `runs-on: lux-build-amd64`, except
build.yml's arm64 leg, which named `["self-hosted","linux","arm64"]`. No runner
on this forge carries any of those labels — the pools are
`lux-build-linux-<arch>` — and a job naming a label nothing carries does not
fail: it waits. ci.yml additionally fired on `branches: ["*"]`, so every push to
every branch queued the whole file, CodeQL and SBOM included.

What survives: test, lint, govulncheck and build move to
.hanzo/workflows/ci.yml, scoped to main and pull requests.

docker.yml and release.yml merge into .hanzo/workflows/release.yml. Both fired
on the same `v*` tag with no shared concurrency group, which is how a tag ends
up half-published; they are now one workflow — per-arch image, manifest, Go
binaries, GitHub release, notify — under one group that does not cancel. The
manifest step also proves the composed tag is pullable before the job goes
green, so a pin can never point at a tag that resolves to nothing.

Two auth paths had to change or the release could not work off GitHub:

  * ghcr login used `github.actor` plus the instance GITHUB_TOKEN, which here is
    a Hanzo Git token with no rights at ghcr.io. It now uses GHCR_USER and
    GHCR_TOKEN, the org Actions secrets sourced from KMS.

  * the luxfi/hsm module token is written to RUNNER_TEMP with its trailing
    whitespace stripped before BuildKit mounts it. Dockerfile:50 interpolates it
    into a URL, so one trailing newline truncates the URL there and git resolves
    the credential prefix as the host ("Could not resolve host: x-access-token").
    luxfi/node hit exactly this.

  * the universe notification used peter-evans/repository-dispatch, which
    resolves GITHUB_API_URL — on this forge that is the forge's own API, where a
    github.com PAT does not authenticate and luxfi/universe's dispatch endpoint
    does not exist. It now calls api.github.com explicitly.

Deleted rather than migrated:

  * e2e-tests.yml and benchmarks.yml. Both trigger on `push: branches: [master]`
    while the default branch is main, so neither has fired. Both also build
    `./cmd/lux-mpc`, which is not in this repo (cmd/ holds mpc and mpcd), and
    drive `e2e/docker-compose.test.yaml`, which does not exist either (the file
    is e2e/compose.test.yml). Three independent reasons each; they have never
    run and could not have passed.

    The e2e SUITE is real and now has no CI. It needs repair at the source
    before it can have any: e2e/*.go shells out to `lux-mpc` and `lux-mpc-cli`,
    which no longer exist, and to `docker-compose.test.yaml`, which is named
    compose.test.yml; e2e/Makefile expects `mpc` and `mpc-cli` on PATH and
    mpc-cli is not built either. That is a repair, not a migration, so it is not
    smuggled in here.

  * build.yml. It pushed a floating `:latest` and branch-named tags on every
    push to main/test/dev, against the semver-only policy the release workflow
    holds to, and its arm64 half named a pool that does not exist.

  * the codeql-analysis, sbom and security-scan jobs. All three exist to upload
    SARIF to the GitHub Security tab via github/codeql-action/upload-sarif.
    There is no Security tab on this forge, so they compute results with nowhere
    to put them. govulncheck stays in CI, reporting into the job log.

docs.yml's build survives as .hanzo/workflows/docs.yml; its publish does not.
configure-pages, upload-pages-artifact and deploy-pages speak to the GitHub
Pages API, which this forge does not have. The docs still fail the build that
breaks them; they are no longer published from CI until the site is served from
the PaaS.

All seven 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:25:50 -07:00
zeekayandHanzo Dev 985964981c build: drop the luxfi exceptions — those repos are public now
The GOPRIVATE list carried twelve named luxfi modules because they were private
and 404 on proxy.golang.org: bridge, broker, compliance, erc20-go, fasthttp,
graph, hsm, indexer, mpc, ordering, password, pool. All twelve are open source
now, all twelve serve from the proxy, so the exceptions have nothing left to do.

GOPRIVATE disables the module proxy AND checksum verification together, so every
name on that list was a module Go fetched from git and trusted unverified. The
list is now exactly the orgs that are actually private, and every luxfi module —
all ~150 of them — verifies against sum.golang.org.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 17:01:19 -07:00
zeekayandHanzo Dev 8f119ac17a 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 is written down here 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
a verified module graph with no version change. Lines that agree are untouched;
this is not a regenerated go.sum.

The failure followed the machine rather than the repository — the same commit
built wherever the module 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 09:38:54 -07:00
zeekayandHanzo Dev 8b6850e316 fix(dashboard): monochrome — the landing page was the fleet's loudest exception
Measured on the rendered pixels, not the source: the MPC dashboard's home page
carried 1.05% of every frame in hue at mean chroma 1.279, against explore's
0.004 next door. It came from four places, all literals no hostname could
retheme:

  app/page.tsx      violet→blue gradient wordmark (from-violet-500 to-blue-500)
                    feature headings text-violet-400 / text-blue-400 /
                    text-emerald-400, and ten chain chips on border-violet-500/20
                    bg-violet-500/10 text-violet-300 — the #ece7f7 wash that was
                    10,776 pixels of one screenshot
  app/docs/page.tsx the same violet, plus border-violet/blue/emerald/purple-500/30

and 15 more files reaching for Tailwind's chromatic ramps directly. Those now
resolve through the tokens @luxfi/ui owns: green/emerald → --color-status-good,
red/rose → --color-status-bad, yellow/amber → --color-status-warn (achromatic —
attention is the loudest neutral in Lux, not a hue), everything else onto
foreground / muted / border.

After: warm 0%, tinted-neutral 0%, mean chroma 0.000 at 1440x900 and 390x844,
in light and dark. Distinction is carried by the LABEL, which already read
CGGMP21 / FROST / Bridge.

@luxfi/ui 7.4.5 -> 7.4.9.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 19:01:45 -07:00
zeekayandHanzo Dev 2cb1b8d42c chore(dashboard): lockfile follows @luxfi/ui 7.4.7
A package.json bump without the lock is a --frozen-lockfile CI failure.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 18:28:08 -07:00
zeekayandHanzo Dev 0e1f4b70a5 refactor(dashboard): take the sign-in flow from @luxfi/ui/auth
Three files here restated the same OIDC wiring that lux/market restated too, and
that lux/explore restated a fourth, PKCE-less way. `@luxfi/ui/[email protected]` is
that flow, once — `AuthProvider` (identity read + PKCE login, bound to the host)
and `AuthCallback` (state check, verifier, code→token).

What is left here is the only part that is MPC's: the middleware session flag,
and the MPC API's own JWT minted from the IAM token. That exchange stays
DOWNSTREAM — the user is signed in whether or not the MPC API answers.

Behaviour is unchanged: same `/auth/callback` path, same `lux-mpc` client
resolved from the host, same landing on /dashboard. `tsc --noEmit` reports only
the pre-existing `globals.css` side-effect-import error.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 18:26:29 -07:00
zeekayandHanzo Dev 6c0aed6825 fix(dashboard): one icon set — the two hand-drawn checks are Lucide now
The custody dashboard drew its own tick twice: `M5 13l4 4L19 7` inline in the
wallet-created panel and again in the Safe deploy wizard's step rail. That path
is Heroicons' outline check, a third set's geometry sitting on a screen whose
every other glyph comes from @luxfi/ui. Same 24x24 box, different drawing —
different corner radius on the vertex, different stroke terminals, no round cap.

Both are now `Check` from `@luxfi/ui/icons`, which is Lucide, the set @hanzo/gui
standardises on. The size classes are unchanged (h-3.5 and h-4), the glyph
inherits `currentColor` from the emerald-tinted disc it sits in exactly as the
inline SVG did, and `next build` is clean.

Chain badges in components/common/chain-icon.tsx keep their hue: they are
third-party identity, not Lux chrome, per lux/brand DESIGN.md 1.2.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 14:49:53 -07:00
zeekay a648eaec80 refactor(dashboard): one chrome, from @luxfi/ui
The dashboard had its own header, its own user menu (components/layout/user-menu.tsx)
and its own branding table (lib/branding.ts) that mapped a hostname to a brand a
second time — a copy of what @luxfi/ui/white-label already answers, and one that
drifted (it had no bootno.de row and pointed zoo at the wrong issuer).

Both are deleted. The nav is now `AppNav` from @luxfi/ui: brand, org switcher,
links, settings and the user menu, with the links collapsing into the same gui
Sheet the switchers use instead of the old `hidden md:flex` that simply vanished
on a phone with nothing to replace it.

Identity comes from IdentityProvider (IAM: get-account / get-organizations /
get-organization-projects). Credentials stay with @hanzo/iam — one PKCE flow and
one token store per surface; the chrome borrows only signIn and signOut.

`next build` passes: 60 routes.
2026-07-27 14:42:56 -07:00
zeekay fd8eb49dc7 fix(mpc): unreachable custody backend + invented smart wallets
Two defects found by capturing the console against production.

1. Every custody write path was dark because a VALID IAM token was
   rejected. fetchUserInfo built <issuer>/oauth/userinfo, which the SPA
   shell answers 200 text/html; the JSON decode then failed on '<' and
   surfaced as 401 "failed to decode userinfo: invalid character '<'".
   The issuer serves userinfo at /v1/iam/oauth/userinfo (401 JSON when
   unauthenticated, as it should). The provider allowlist keys on the
   issuer ORIGIN, so the /v1/iam prefix cannot be supplied by the caller
   and belongs in this function.

   Verified by hand against lux.id: authorization_code + S256 completes,
   token endpoint 200, and userinfo at the /v1/iam path returns
   {"email":"[email protected]","isAdmin":true,"owner":"lux"}.

2. /wallets/[id]/smart-wallets rendered two invented wallets: a "2-of-3"
   Safe at 0x1234...abcd on Ethereum and an ERC-4337 account at
   0x5678...efgh on 96369, both green and marked deployed, hardcoded
   under a "TODO: fetch from API". They rendered for ANY key id.

   A custody screen claiming a 2-of-3 multisig that exists on no chain is
   not a placeholder, it is a false statement about where funds are — and
   it contradicted lux.cloud's own honest "1 of 1 today, no ring
   deployed". The list is now empty until the API is read.

The login page needs no change: it already uses @hanzo/iam startLogin
with PKCE. Production is serving an older build than main, which is why
it still requested the implicit response_type=token that this repo
removed.

Still required and NOT done here: register
https://mpc.lux.cloud/auth/callback on the lux-mpc IAM client, and
deploy. Neither is a code change.
2026-07-27 13:51:48 -07:00
zeekayandHanzo Dev b30bb58a22 test(mpcd): point the dispatcher round-trip at a scheme thresholdd still serves
TestEmbeddedDispatcher_BLSRoundTrip and _AuthGate drove "bls". thresholdd
stopped serving that scheme — trusted-dealer threshold-BLS custody was ripped
for security (H-2), and luxfi/threshold v1.12.5's dispatcher exposes exactly
cggmp21, frost, pulsar, corona, magnetar and doerner (pkg/thresholdd/zap_schema.go;
there is no bls.go). So both tests had been failing on every run:

    bls.keygen: zapclient: unknown procedure "bls.keygen"
    wrong-id: error zapclient: unknown procedure "bls.keygen" does not name unauthorized

The second line is the damaging one. _AuthGate asserts a wrong NodeID is refused
with "unauthorized"; it was instead getting "unknown procedure" back from a
scheme lookup that failed before auth was ever consulted. The auth gate has not
actually been exercised for as long as the scheme has been gone.

FROST is a live production scheme on the same ZAP wire and keygens as fast, so
the calls move to it and every assertion is kept verbatim — pubkey non-empty,
3 shares, non-empty signature, verify true, forgery rejected, wrong NodeID
refused with "unauthorized". Nothing is skipped, softened or dropped; the
round-trip now lands on a surface that exists. _UnknownScheme still drives
"bogus" and still proves the unknown-procedure contract.

Renamed to TestEmbeddedDispatcher_FROSTRoundTrip so the name states what it runs.

go test ./... is green across the module.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 12:25:56 -07:00
zeekayandHanzo Dev 7be332b97d fix(infra): route SQLiteMeta through hanzoai/sqlite — one "sqlite" registrar
pkg/infra blank-imported modernc.org/sqlite, so any binary linking this store
next to another Hanzo store (base/core, orm/db, tasks' shard store — all of
which reach SQLite through github.com/hanzoai/sqlite) registered the "sqlite"
database/sql driver name twice and panicked during init:

    panic: sql: Register called twice for driver sqlite
    modernc.org/sqlite.init.0()  modernc.org/[email protected].0/sqlite.go:57

hanzoai/mpc's mpcd is exactly that binary; `go test ./...` there could not even
start. hanzoai/sqlite is the ecosystem's single registrar of that name (pure-Go
modernc under !cgo, hanzoai/csqlite+SQLCipher under cgo), so this store must go
through it — and so must base and orm, hence base v0.39.1 -> v1.5.8 and orm
v0.5.2 -> v0.6.16, the versions that route through it. mpcd's package graph now
has exactly one importer of a SQLite driver.

The hand-rolled DSN went with it. `?_journal_mode=WAL&_busy_timeout=5000&...`
is mattn syntax and the pure-Go backend this module actually linked dropped it
silently — measured journal_mode=delete, busy_timeout=0, foreign_keys=0, i.e.
no WAL, immediate SQLITE_BUSY under concurrent writers and no FK enforcement.
sqlite.PragmaDSN encodes the pragma set in the ACTIVE backend's syntax, so all
three apply under both backends.

Tests: sqlite_driver_test.go links base/core and orm/db alongside this store, so
a regression to a direct modernc import blows the test binary up in init;
TestSQLiteMeta_PragmasApplied reads the pragmas back off a file-backed WAL
store. Both pass under CGO_ENABLED=0 and CGO_ENABLED=1.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 10:52:57 -07:00
zeekay 96e90c229d chore: sync working tree
Commits 1 outstanding change(s) that were sitting uncommitted.
No build artifacts and no secrets in the changeset (both checked).
2026-07-26 10:14:07 -07:00
zeekay e68a52807e 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 36 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:05 -07:00
zeekayandHanzo Dev 01db2a1edf api: doc comments said /api/v1 for routes that are /v1
Every route in pkg/api is mounted under r.Route("/v1", ...) at server.go:272,
so /auth/oidc is /v1/auth/oidc and trade/submit is /v1/.../trade/submit. Nine
doc comments claimed an /api/v1 prefix that no route has ever carried:

  handlers_trade_approval.go  submit / approve / reject / pending
  handlers_oidc.go            auth/oidc
  handlers_bridge.go          generate_mpc_sig / complete
  server.go                   the health-check note and the trade-endpoint note

handlers_approval.go already states the rule in-tree — "One canonical surface —
no `/api/` prefix, no v2" — so these were drift, not a second convention.

Comments that misdescribe a route are the same failure as a config that
misdescribes a threshold: they are what an audit reads. This is documentation
only; no route, handler or behaviour changes.

Remaining /api/ in this repo is all foreign and stays: pkg/hsm/zymbit.go (Zymbit
HSM device), pkg/hsm/providers.go (AWS IMDS), pkg/risk/chainalysis.go
(Chainalysis vendor). pkg/hsm/kms.go is a separate matter — it is an Infisical
client (/api/v3/secrets/raw with workspaceId/secretPath/type:shared), so moving
it to our native /v1/kms/orgs/{org}/secrets is a port, not a path swap.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-25 23:23:53 -07:00
zeekayandHanzo Dev 72a2ae1371 mpcd: report the threshold and party set keygen actually produced
api.KeygenResult carried only wallet_id, pubkeys and addresses — no threshold,
no participants. Keygen accepts no per-request threshold (the ring's own
--threshold governs), so a client had no way to learn what it actually got.

That is not theoretical. luxfi/kms pkg/keys/manager.go validates req.Threshold
(>=2, parties >= threshold), never sends it, then stores
`Threshold: blsResult.Threshold, Parties: len(blsResult.Participants)`. Its
client struct already declares both json fields, so with mpcd never emitting
them every validator key set on record reads Threshold:0 Parties:0 while the
caller had asked for 3-of-5 and passed validation. Ask for 3-of-5, silently get
the ring's config, and record zero.

Populate both from what the node is actually configured for: b.threshold, and a
new ConfiguredParticipants() (peers + self, sorted). Configured — not
GetReadyPeersIncludeSelf — because readiness answers "who is up right now",
while a key's party set is a property of the key and must not change because a
pod restarted; sorted so every node reports an identical list for the same key.

Additive and backward compatible: both fields are omitempty, and the KMS client
already decodes them. Clients can now compare against what they requested and
fail closed.

Tests: ConfiguredParticipants covers ring sizes, order normalization,
cross-node determinism, and a peerIDs aliasing guard.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-25 16:38:17 -07:00
zeekayandHanzo Dev 3f22f50c1b threshold: stop registering the nonce-leaking adapters at import
pkg/threshold's init() pushed CGGMP21Scheme and FROSTScheme into the
process-global crypto/threshold registry as a side effect of import. Two
concrete hazards:

 1. Shadowing. These adapters' signer keeps the secret nonce scalar k inside
    NonceState (k||R). Anything resolving GetScheme(SchemeCMP) would silently
    receive that signer; a NonceState crossing a party boundary yields the
    private key as x = (s*k - H(m))/r. Opting into that must be deliberate.

 2. Startup panic. RegisterScheme panics on a duplicate ID ([email protected].2
    threshold/registry.go:24-26), so the day crypto/threshold ships its own
    CMP/FROST scheme, merely importing this package kills the process at start.

Replace init() with an exported Register() documenting both hazards. Nothing
regresses: the daemon never reached this code (`go list -deps ./cmd/mpcd` does
not contain pkg/threshold) and the only importers, examples/, use
NewUnifiedThresholdAPI and never touch the registry.

register_test.go pins the invariant: TestImportDoesNotRegister fails if an
ambient init() comes back (verified red/green), TestRegisterIsExplicit proves
explicit opt-in still works.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-25 16:33:00 -07:00
zeekayandHanzo Dev 3685ebea4c mpc: make signature verification real, and stop trusting a caller's Y
Both protocol adapters shipped a Verify that returned false unconditionally —
cggmp21 with "For now, return false", frost with "doesn't support ECDSA". That
is not a safe default dressed up as one. A verifier that always fails is
indistinguishable from a verifier that works and was handed a bad signature, so
a caller using it as a signing-path health check sees a permanent, unexplained
failure, and a caller treating "unsupported" as skippable gets no verification
at all.

Both now verify. FROST is Schnorr rather than ECDSA, but its group key is still
a secp256k1 point and *ecdsa.PublicKey is only how this package's interface
carries one, so the point is recovered and handed to the FROST verifier.

pkg/protocol/curvekey.go holds the conversion both adapters need, replacing two
hand-rolled decompressions (a Tonelli-Shanks square root in cggmp21, a separate
helper in frost). CurvePoint goes the other way, and it validates rather than
assumes: an (X, Y) that is not on the curve is REJECTED, not silently corrected.
Compression keeps only the parity of Y, so a conversion that trusts X and
parity alone will happily turn an inconsistent key into a valid point and then
verify signatures against a key the caller never named. X=1 is a real X
coordinate on secp256k1, which is how the test that found this was written.

X is left-padded with FillBytes; big.Int drops leading zeros, so about one key
in 256 would otherwise encode short and fail to convert at all.

Bumps luxfi/threshold to v1.12.5 for pkg/quorum.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-25 11:45:23 -07:00
zeekayandHanzo Dev 3ff4b4e9d9 docs(mpc): correct BLS/Corona/PQ mislabels (truthful naming)
mpcd implements only CGGMP21 (secp256k1 ECDSA), FROST (ed25519/ristretto), and
LSS signing sessions — there is no bls12-381 threshold signing session. The KMS
validator "bls" slot is secp256k1/CGGMP21; the "corona" slot is ed25519/FROST.
Neither is bls12-381 aggregation nor post-quantum. A "post-quantum" claim over
classical ed25519 is materially misleading.

- deployments/customer-3of5/topology.yaml: the `bls` entry asserted
  curve=bls12-381, protocol=BLS-Threshold for "Lux consensus (validator
  signing)". Corrected to secp256k1/CGGMP21 (what is actually custodied) with a
  note that mpcd cannot threshold-sign bls12-381 and that Lux consensus BLS is a
  native per-validator key, never custodied here. (Customer manifest — highest
  priority.)
- pkg/api/handlers_kms.go: the ValidatorKeySet doc called the custodied key a
  "Corona PQ-threshold key" — it is ed25519/FROST, not PQ and not the Corona
  R-LWE lattice protocol. Corrected.
- README.md: added a "Validator key slots vs. engine protocols" note so the
  secp256k1/ed25519 custody slots are not read as the bls12-381/lattice engine
  protocols of the same name.

Labeling only — no crypto change. The live 2-of-3 mpcd ECDSA is sound.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-21 08:39:25 -07:00
zeekayandHanzo Dev b19863787b fix(mpcd): canonical EVM-ready threshold-ECDSA signatures (low-S + recovery id)
The signing sessions emitted a raw, possibly high-S S with a recovery byte that
was then dropped at every serialization boundary. luxfi/evm ecrecover rejects a
high-S signature (EIP-2 "invalid sender"), and without a propagated recovery id
a caller must guess v (wrong guess => wrong sender). This is what broke the KMS
/sign output.

- signing_session.go + signing_session_lss.go: produce the signature through
  the shared threshold helper s.signature.SigEthereum() (EIP-2 low-S + recovery
  id in ONE place), then self-verify the recovery id against the wallet's own
  public key via CalculateRecoveryByte and FAIL CLOSED on mismatch — never emit
  a signature that binds to the wrong address.
- pkg/api SignResult: carry V (recovery id "0"/"1").
- cmd/mpcd TriggerSign: propagate V from the signing session's signature_recovery
  and assemble the canonical 65-byte r‖s‖v so KMS /sign never returns an empty
  signature.
- handlers_kms REST: include v in the response.

Requires the non-mutating, tested SigEthereum in threshold v1.12.4; bumps
luxfi/threshold v1.12.0 -> v1.12.4 (MVS pulls its transitive updates). Builds
GOWORK=off; pkg/api + pkg/mpc tests green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-21 08:32:35 -07:00
zeekayandHanzo Dev 99a1dab019 fix(deps): refresh re-tagged luxfi module checksums (age/mpc/pq)
The luxfi/{age,mpc,pq} tags were force-updated upstream, leaving the
recorded go.sum hashes pointing at pre-retag content. Refresh the
checksums at the SAME pinned versions (no version bumps) so the root
module and the e2e module build. All are GOPRIVATE-covered first-party
modules, so no sumdb authority is bypassed.

Unblocks: e2e/live_http_sign_test.go builds mpcd for the live 3-node
consensus ceremony.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-20 13:30:34 -07:00
zeekayandHanzo Dev fecb5117c2 docs(LLM): v1.17.12 threshold-degree fix status + CRITICAL bridge-oracle 1-of-n finding + remediation
Co-authored-by: Hanzo Dev <[email protected]>
2026-07-14 18:04:57 -07:00
zeekayandHanzo Dev 1f227bfe62 fix(ci): compose multi-arch tag with buildx imagetools, not docker manifest
docker buildx build --push emits a per-arch OCI index; docker manifest
create rejects those ('ghcr.io/luxfi/mpc:vX-amd64 is a manifest list').
Both arch images for v1.17.12 built+pushed fine — only the merge step
failed. Switch to 'docker buildx imagetools create' (same as build.yml)
which composes indices correctly.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-14 15:59:07 -07:00
zeekayandHanzo Dev 0279a70b6a fix(docker): restore private-module (hsm) authed fetch — regressed by a 'public modules' simplification
A post-v1.17.9 commit stripped the BuildKit gh_token secret + GOPRIVATE=hsm
+ GOSUMDB=off + in-build go.sum regen from the Dockerfile, and the
--secret id=gh_token,env=GH_TOKEN (UNIVERSE_PAT) from docker.yml, on the
false premise that all luxfi/* modules are public. luxfi/hsm is PRIVATE
and absent from proxy.golang.org (all versions 404), so the alpine
builder's plain 'go mod download' fell back to unauthenticated direct git
-> exit 128. Every tag since (v1.17.10/11) failed here, independent of the
threshold + dep fixes.

Restore the v1.17.9 Dockerfile verbatim (authed direct git for private
first-party modules, proxy for public, regenerate-not-bypass go.sum) and
re-add the secret to docker.yml, preserving the ARC lux-build-amd64
routing. Combined with the coherent dep graph (geth v1.17.12), this is
the full green-build set for the CGGMP21 threshold fix.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-14 15:51:37 -07:00
zeekayandHanzo Dev 386da12845 fix(deps): migrate to post-re-tag coherent luxfi set (geth v1.17.12)
The luxfi module ecosystem was re-tagged: luxfi/geth wiped its entire
v1.16.x line (v1.16.77, v1.16.98 gone) leaving only v1.17.12, and
luxfi/pq v1.0.3 was re-published. This broke `go mod download` for every
dependent pinning the old graph — including this repo, so v1.17.10 (the
threshold fix) failed to build even though its go.mod was untouched.

Move onto the coherent post-re-tag set: crypto v1.19.27 -> precompile
v0.5.60 -> geth v1.17.12 (all existing tags), regenerating go.sum. mpc
imports 0 geth and 0 precompile packages directly, so this is a pure
transitive-graph repair; threshold stays v1.12.0 (default build needs it;
the experimental_tfhe path is build-tag gated and never compiled in the
Docker image). Verified: go mod download clean, readonly build of
./cmd/mpc + ./cmd/mpcd compiles with no missing go.sum, threshold unit
test green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-14 15:40:45 -07:00
zeekayandHanzo Dev 6cf38d2e48 fix(mpcd): wire --threshold into CGGMP21 keygen degree (was silently 1-of-n)
SECURITY: every wallet keyed by these rings was a 1-of-n key — any single
node's share could produce a valid signature, with no threshold protection
against single-node compromise. This affects real value: the bridge-mpc
`bridge-oracle` (LUX↔ZOO corridor signer), zoo-treasury-v1, and the
hanzo-dao wallet were all keyed at CGGMP21 polynomial degree 0.

Root cause: two disconnected "threshold" values. The --threshold flag
(c.Int("threshold")) drove only the HasSigningQuorum health gate and status
display. The keygen DEGREE was read separately from viper key
"mpc_threshold" (pkg/eventconsumer → CreateKeyGenSession → cmp.Keygen), which
nothing ever set — and mpcd never calls config.InitViperConfig, so
viper.AutomaticEnv was off and even an MPC_THRESHOLD env would not have been
read. viper.GetInt("mpc_threshold") was therefore always 0 → degree 0 →
1-of-n.

Fix: bridge the flag to the keygen degree at consensus start via
viper.Set("mpc_threshold", keygenDegreeForThreshold(threshold)). --threshold
is the number of signers required (matches HasSigningQuorum); cmp.Keygen
needs degree+1 signers, so degree = threshold-1. --threshold=3 → degree 2 →
3-of-5; --threshold=2 → degree 1 → 2-of-3. keygenDegreeForThreshold is a
named, unit-tested function so the security-critical off-by-one is explicit.

Takes effect for NEW keygens only; existing 1-of-n wallets must be re-keyed
to gain threshold security (the live bridge-oracle needs a coordinated
migration — do not re-key in place).

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-14 15:15:28 -07:00
zeekay fa1678ef00 ci: run linux jobs on lux-build-amd64 ARC scale set (no GitHub-hosted builders) 2026-07-11 00:15:04 -07:00
zeekay c35ca4ac83 ci: route linux/amd64 jobs to lux-build-amd64 ARC scale set 2026-07-10 21:53:58 -07:00
91cca33dba fix(address): left-pad compressed-pubkey coords in pubKeyToEVMAddress (case 33) (#20)
Follow-up to #19 (emit 33-byte compressed keygen pubkey). pubKeyToEVMAddress's
case-33 assembled the EVM preimage as append(x.Bytes(), y.Bytes()...). big.Int.
Bytes() drops leading zero bytes, so a coordinate < 2^248 (X or Y with a high
zero byte, ~1/256 of keys) produced xyBytes<64 → the len!=64 guard returned an
EMPTY address for an otherwise-valid key. Now FillBytes zero-pads each coordinate
into a fixed 32-byte slice (case 32 already did this; case 33 did not — it was
latent until #19 made keygen actually emit 33-byte keys).

Verified: pubkey 03·005056e0… (X has a leading 00 byte) → old path xyBytes=63 →
empty; fixed path xyBytes=64 → 0x7242EFb11FB1A669486F10649C9B73739731a5e6, byte-
identical to luxfi/crypto DecompressPubkey+PubkeyToAddress.

Co-authored-by: hanzo-dev <[email protected]>
2026-07-07 21:54:53 -07:00
d625ef533a fix(keygen): emit full compressed ECDSA pubkey (33B), not bare X — fixes ~50% wrong addresses (#19)
The CGGMP21 keygen session serialized the group public key as the bare 32-byte
X-coordinate (PublicPoint().XScalar()), dropping the Y parity. pubKeyToEVMAddress
and pubKeyToBtcAddress then GUESS even-Y (0x02 prefix) when handed a 32-byte key,
so for every ODD-Y key (~50%) they derive the WRONG address — the sibling curve
point — which does NOT match the key the ring actually signs with. A threshold
signature over a digest then recovers to a different address than keygen reported,
making the wallet unusable (funds sent to the reported address are unspendable).

Fix: serialize PublicPoint().MarshalBinary() (33-byte compressed, 0x02/0x03 || X)
— exactly what signing_session_lss.go already uses for recovery — so keygen's
address == the signing key's address. pubKeyToEVMAddress (case 33),
pubKeyToBtcAddress (case 33), and CalculateRecoveryByte all already accept the
33-byte compressed form; the 32-byte path stays only as a legacy fallback.

Root-caused live: direct ring keygen returned evm_address 0x84e0…f894 for
ecdsa_pub_key X=5b0e9ad0…; a direct threshold sign recovered pubkey 03·5b0e9ad0…
(odd Y) → real address 0x10B8…15A9. The +1 parity bit is the whole bug.

Co-authored-by: hanzo-dev <[email protected]>
2026-07-07 21:35:20 -07:00
zeekayandGitHub acdd6b6dfd Merge pull request #18 from luxfi/fix/dockerfile-go-1.26.4
build(mpc): bump builder image golang 1.26.3→1.26.4-alpine
2026-07-07 17:53:35 -07:00
zeekay bb671ac173 build(mpc): bump builder image golang:1.26.3→1.26.4-alpine
go.mod requires go 1.26.4 (and cc v0.3.0 requires >= 1.26.4). The alpine
golang image pins GOTOOLCHAIN=local, so 1.26.3-alpine refuses to build:
'go.mod requires go >= 1.26.4 (running go 1.26.3; GOTOOLCHAIN=local)'.
Match the base image to the go directive — hermetic, no toolchain download.
2026-07-07 17:53:29 -07:00
zeekayandGitHub 28b515ebb0 Merge pull request #17 from luxfi/fix/cc-published-leaf-v0.3.0
build(mpc): pin luxfi/cc v0.3.0 (published leaf), drop local ../cc replace
2026-07-07 17:50:34 -07:00
zeekay 3bee998a73 build(mpc): image-buildable main — pin luxfi/cc v0.3.0, fix poisoned go.sum
Two fixes that make the clean-proxy Docker image build of main succeed:

1. luxfi/cc is now a PUBLISHED public module (immutable proxy+sumdb).
   Pin v0.3.0 (converged 6-vendor attestation verifier) and drop the local
   '=> ../cc' replace so the Docker 'COPY . .' context (no ../cc) resolves
   cc from the proxy. Also unblocks luxfi/dkg (dealerless threshold dep),
   now public.

2. go.sum had two poisoned zip hashes (luxfi/age v1.4.0, luxfi/pq v1.0.3)
   recorded from re-tagged 'direct' GitHub instead of the immutable
   proxy+sumdb — the clean-proxy Dockerfile rejected them (SECURITY ERROR).
   Restored the authentic sum.golang.org values.

Verified (fresh modcache, immutable proxy+sumdb, GOWORK=off, default tags):
  go build ./... = 0, go test ./cmd/... ./pkg/... = 33 ok.
  (2 BLS-dispatcher + 1 CAS-race failures pre-exist on v1.17.0 from the
   threshold v1.12.0 dealerless bump; unrelated to this dep change —
   confirmed identical failure with cc reverted to ../cc.)
2026-07-07 17:50:25 -07:00
zeekayandHanzo Dev bfb347d69b fix: gate experimental_tfhe + fail-closed stub; bump threshold v1.12.0 (dealerless daemon) + pulsar v1.9.0
Co-authored-by: Hanzo Dev <[email protected]>
2026-06-30 16:41:50 -07:00
zeekayandHanzo Dev 0e2bdf443e bump: latest dealerless PQ (corona v0.10.3, pulsar v1.8.0)
Co-authored-by: Hanzo Dev <[email protected]>
2026-06-30 13:26:15 -07:00
zeekay a6f69bef5d docs(brand): add hero banner 2026-06-28 20:35:25 -07:00
zeekay 4abc85fc53 chore(brand): dynamic hero banner 2026-06-28 20:35:25 -07:00
zeekay 9030a79d4a bump: dealerless pulsar/corona/dkg/mlwe (no trusted dealer, no-reconstruct)
Move to the dealerless threshold-signature stack: corona v0.10.2 (Module-LWE
Ringtail, dealerless Pedersen-VSS) + pulsar v1.7.1 (dealerless Mithril RSS keygen
+ no-reconstruct signing, trusted dealer ripped) + dkg v0.3.5 + mlwe v0.2.1.
2026-06-28 19:41:12 -07:00
zeekay 3d8d9c98d6 build: drop GOPRIVATE/GONOSUMDB + resolve poisoned deps via immutable proxy+sumdb
luxfi/* and hanzoai/* are PUBLIC Go modules. The GOPRIVATE / GONOSUMDB /
GOPROXY=direct env in Dockerfile + release.yml routed fetches around
proxy.golang.org + sum.golang.org, "working" only by skipping checksum
verification — which poisons go.sum with force-republished git bytes that no
longer match the immutable checksum db. Drop the bypass; resolve every
luxfi/hanzoai checksum from the public proxy + sumdb instead.

Poisoned deps resolved (strip luxfi/hanzoai go.sum lines, then go mod tidy):
- github.com/luxfi/edwards25519 v0.1.0 -> v0.1.1 (the agl/ed25519 replace
  target, in both go.mod and e2e/go.mod): v0.1.0 was force-republished, its zip
  hash diverges from the sum.golang.org pin and no copy verifies; v0.1.1 is the
  clean patch the proxy + sumdb agree on.
- github.com/luxfi/age v1.4.0, github.com/luxfi/zap v0.8.1: version healthy, but
  go.sum carried poisoned zip hashes — corrected to the sumdb canonical values.
- e2e/go.sum: also completed to the full canonical checksum set (hack-era builds
  with GOPROXY=direct skip go.sum completion).

Verified with GOWORK=off (matching the .dockerignore'd workspace + CI):
- go mod verify -> all modules verified (main + e2e)
- CGO_ENABLED=0 go build ./... clean (main + e2e)
- go test ./... -> 35 ok / 0 FAIL; signing, threshold, keygen, attestation, kms,
  ristretto255 curve, sr25519, and policy-gated sign-flow tests all pass. No
  crypto regression from the edwards25519 curve-lib bump.
2026-06-28 15:14:15 -07:00
zeekay dd8c3631da Revert "build: drop GOPRIVATE/GONOSUMDB — public modules use immutable proxy+sumdb"
This reverts commit 4e2432f633dcd0a6fff8b9ac258c8c7a4b310d76.
2026-06-28 14:53:44 -07:00
zeekay a291a1778f build: drop GOPRIVATE/GONOSUMDB — public modules use immutable proxy+sumdb
luxfi/* and hanzoai/* Go modules are public; GONOSUMDB/GOPRIVATE routed them
direct and bypassed sum.golang.org, so a force-republished tag could poison the
build. Resolve via the public proxy+sumdb (canonical immutable hashes).
2026-06-28 14:51:39 -07:00
zeekay acd726682a examples: port bitcoin/ethereum/lux/solana/tfhe MPC signing demos from hanzoai/mpc
Consolidate the hanzoai/mpc repo into luxfi/mpc (the canonical, deployed MPC —
v1.16.0, running as ghcr.io/luxfi/mpc across all clusters). hanzoai/mpc (legacy
v1.0.6) carried 5 chain-specific threshold-signing examples luxfi/mpc lacked;
they already import github.com/luxfi/mpc/pkg/threshold, so this is a clean port.
hanzoai/mpc is being archived — nothing imports it and the deployments already
run luxfi/mpc.
2026-06-28 14:49:40 -07:00
zeekay 5e04d411e2 refactor(attest): consume the github.com/luxfi/cc leaf; KindNRAS→KindNVTrust
The canonical attestation verifier (cc/attest + pkg/attestation/nvidia) was
extracted into the orthogonal leaf module github.com/luxfi/cc so mpc, the TEE
custody extension, and the confidential-AI system can share ONE verifier
coupled to none of their domains.

- pkg/attestation/composite.go, pkg/kms/release.go: import the leaf
  github.com/luxfi/cc/attest directly; map the NVIDIA issuer to the real
  KindNVTrust (was the KindNRAS stub).
- pkg/attestation/nvidia + cc/attest/{sev,tdx,verifier,...}.go: moved to the
  leaf (clean stdlib/go-sev-guest leaves, zero mpc-internal imports).
- cc/attest now a thin TRANSITION ADAPTER: a zero-logic re-export of the leaf
  (type aliases ⇒ identical types, so the kms.CompositeAttestation release
  gate is satisfied from either import path) plus the deprecated KindNRAS
  symbol that pinned github.com/luxfi/threshold v1.9.9 still references. To be
  removed once threshold republishes against the leaf (its main already has)
  and mpc's threshold/protocols/tfhe dep is resolved.

go.mod: require github.com/luxfi/cc (local replace ../cc until tagged);
threshold stays v1.9.9 (cannot bump: main drops protocols/tfhe that pkg/mpc
needs). go vet ./... clean; attestation/kms/scheduler tests green.
2026-06-27 12:33:40 -07:00
zeekay 4508cb3f2a security(mpcd): wire fail-closed strict-PQ chain-profile gate — Red M-2
The embedded threshold dispatcher's Sign_Ctx_Profile consults a
thresholdd.ChainProfileResolver before allowing the single-party dealer
shortcut on ctx-bound signing (pulsar/magnetar sign_ctx). mpcd built the
ZapServer with NO resolver, so the gate fell to its documented fail-OPEN
branch — every chainID, strict-PQ chains included, slipped the gate.

Wire the node's chain-profile posture into the dispatcher BEFORE Start:
  - --chain-profile (MPC_CHAIN_PROFILE), default strict-pq → fail-closed.
    Any non-empty chainID not allow-listed resolves to ProfileStrictPQ and
    the dealer shortcut is REFUSED.
  - --legacy-chains (MPC_LEGACY_CHAINS): explicit allow-list of chainIDs
    permitted the shortcut (dev / non-PQ chains co-served by a strict node).

Resolver is the canonical thresholdd.NewStaticChainProfileResolver — no
bespoke type, no luxfi/consensus/config dependency.

Residual: thresholdd.RefuseUnderStrictPQ short-circuits empty chainID to
PASS before consulting the resolver; that short-circuit lives inside the
threshold gate (threshold owns the listener, read-only here). The resolver
wired here is already fail-closed for "" (ResolveChainProfile("") ==
ProfileStrictPQ), so the residual closes the instant the threshold gate
routes "" through the resolver — a one-line change in
threshold/pkg/thresholdd/profile.go (drop `|| chainID == ""`).

Tests: TestStrictPQGate_FailClosed / _LegacyDefaultPermissive drive the
real gate proving refuse-by-default + allow-list opt-out + empty/unknown
fail-closed at the resolver boundary.

Also drops the stale "bls" scheme from the dispatcher start log (BLS
custody was ripped in H-2).
2026-06-26 21:38:59 -07:00
zeekay 8c59d024b1 chore(mpc): commit go.sum/go.mod checksum churn from H-2 BLS-custody rip
Dep-checksum tail left uncommitted after 5c4d5cb removed bls_keygen_session.go.
- main go.sum: luxfi/zap v0.8.1 + luxfi/age v1.4.0 h1 refresh (upstream same-version
  tag re-cut; go mod verify = all modules verified, build green).
- e2e/go.mod + e2e/go.sum: go mod tidy churn (nats.go v1.50.0 -> v1.52.0 within v1,
  added indirects) for the e2e test module.
- go.work.sum: workspace checksum sync.

GOWORK=off go build ./... exit 0; go mod verify clean.
2026-06-26 21:18:02 -07:00
zeekay 567f950fa0 security(mpc): rip trusted-dealer threshold-BLS custody — H-2
BLS is consensus-only. The mpcd validator-key custody path generated a
threshold-BLS group key via a TRUSTED DEALER (blsThreshold.TrustedDealer:
one node, the lowest sorted party ID, generated the entire group secret
key and all shares, then distributed them) — a single point of full-key
compromise, with no DKG and no rogue-key PoP. That violates the
public/leaderless/permissionless invariant "no single node can sign or
extract a key".

Orphan-safe: this path was incomplete (no proof/sign-off doc unlike
cmp/frost; no deployment manifest provisions validator-BLS; BLS keygen
had no dispatch caller) and NOT live custody. A validator's real BLS
consensus key is its own NATIVE per-validator crypto/bls key (PoP +
aggregation, the consensus fast-path), held at the node — never
threshold-custodied here. Corona PQ-threshold custody (dealerless DKG)
stays. So a validator keyset = {native BLS (consensus, no custody) +
corona PQ-threshold (custodied)}.

Delete: bls_keygen_session.go, bls_signing_session.go, bls_config_marshal.go,
bls_session_test.go, signing_handler_bls.go. Remove KeyTypeBLS (mpc + types),
SessionTypeBLS, CreateBLS{KeyGen,Sign}Session, the BLS signing/keytype
dispatch cases, the keygen-result BLSPubKey field, and the bls: key-share
prefix. handlers_kms.go: drop the BLS-custody half of ValidatorKeySet and
the generate/sign/rotate BLS branches; KMS custodies corona only.

Verified: GOWORK=off go build ./... + go vet green; pkg/mpc, pkg/api,
pkg/types tests pass. Zero residual refs to deleted BLS symbols.
2026-06-26 21:18:02 -07:00
zeekay 255faab1cd fix(transport): propagate peer Ed25519 keys to consensus identity store
Consensus mode learned only its OWN Ed25519 public key: peer keys arrive in
ReadySignal.PublicKey during the transport identity handshake (sendIdentity) but
were discarded — never fed to the ConsensusIdentityStore that VerifyWireMessage
consults. Result: every inbound CGGMP21/FROST/signing wire message from a peer
was dropped ("public key not found for node …") and keygen/signing stalled to a
60s timeout. The HTTP /keygen and /sign endpoints could not work in any real
multi-node ensemble.

Fix (orthogonal, no import cycle): transport.Config.OnPeerIdentity observer is
invoked (notifyPeerIdentity, with self/empty/length guards + defensive copy)
whenever a peer key is learned — from the MsgMPCReady handshake in handleMessage
and handleIncoming. The acceptor now also replies with its own identity so the
key exchange is symmetric on the single tie-broken connection. main.go wires the
observer to ConsensusIdentityStore.AddPeerPublicKey. This is the consensus-mode
analog of the legacy NATS path's peers.json + *_identity.json seeding.

Proof: e2e/live_http_sign_test.go (//go:build integration) builds mpcd from the
repo root, starts a real 3-node 2-of-3 consensus ensemble, and over the live
HTTP API proves: distributed CGGMP21 keygen (real evm address), a real threshold
signature, idempotency cached-match, anti-oracle 409 on key reuse with new
content, 400 on empty key, and no plaintext key in any response or node log.
Unit coverage for the observer guards in pkg/transport/peer_identity_test.go.

Also fixes a pre-existing standalone_test.go bug: it set only ZAPDB_PASSWORD but
the env HSM provider reads MPC_PASSWORD — set both so the node starts.
2026-06-26 21:18:02 -07:00
zeekay 133131d943 feat(mpcd): HTTP POST /sign endpoint (idempotent, t-of-n quorum-gated) 2026-06-26 21:18:02 -07:00
zeekay b4be842240 chore: complete corona rename — strip residual corona identifiers (wire bytes preserved)
Bump e2e submodule's luxfi/threshold pin (v1.5.5 → v1.9.9) to drop the only
remaining corona puller in this workspace. Removes the residual
'github.com/luxfi/corona v0.2.0/go.mod' hash line from go.work.sum.
Align go.work go directive to 1.26.4 to match both workspace siblings
(../threshold and ./e2e). No source code in this repo imported corona;
this was workspace-graph hygiene only. R-LWE threshold primitive is now
referenced exclusively via luxfi/corona upstream.
2026-06-15 00:45:13 -07:00
zeekay 184193bae6 scrub branding + corona from docs/comments/tests
- Genericize all -brand references in comments, LLM.md, and
  FIREBLOCKS_PARITY.md: ' MPC API'→'MPC API', drop
  ~/work/liquidity/openapi/mpc.yaml paths, 'Liquid operator'→'the operator',
  'liquid-mpc'→'the mpc cluster'.
- Genericize tenant fixtures/examples: liquidity→acme, mlc→globex across
  treasury_test.go + db model comments (cross-tenant isolation tests keep
  their semantics with neutral org names).
- Rename corona→corona in docs (active Go code had none).
lux OSS names no white-label tenant. Build + api/webauthn tests green.
2026-06-10 22:55:37 -07:00
zeekay 51821ad233 deps: align threshold/corona/zap so cmd/mpcd builds
The Corona-rename WIP bumped cmd/mpcd to the luxfi/threshold thresholdd
NewZapServer/ZapServerConfig API but left go.mod pinned at the predating
v1.8.2, leaving the module unbuildable. Forward the dep set within v1.x:

  luxfi/threshold v1.8.2 → v1.9.9  (thresholdd.NewZapServer surface)
  luxfi/corona    v0.4.1 → v0.7.6  (pulled by threshold)
  luxfi/zap       v0.2.0 → v0.8.1  (threshold v1.9.9 uses zap.NodeConfig.TLS)
  luxfi/lattice/v7 v7.1.0 → v7.1.4

go build ./... is green again; pkg/webauthn tests pass.
2026-06-10 22:37:48 -07:00
zeekay ec0fb82eaa webauthn: vendor-neutral pluggable liveness Verifier; drop
lux OSS names no PAD-2 / liveness vendor. Decomplect the verifier into a
pluggable extension point so a white-label deployment (e.g. liquidity)
registers its own provider without forking:

- pkg/webauthn: add Verifier interface + Binding (vendor-neutral per-call
  input) + Ed25519EnvelopeVerifier (OSS default, delegates to the audited
  VerifyLiveness) + RegisterVerifier/LookupVerifier registry. Tests cover
  the default verifier, the registry, and a custom non-Ed25519 scheme.
- pkg/api: resolve the active Verifier at boot from MPC_LIVENESS_PROVIDER_ID
  (registered custom wins; else Ed25519 from MPC_LIVENESS_PUBKEY_ED25519;
  else nil → enroll fails closed 503). handleBiometricEnroll calls
  s.livenessVerifier.Verify(...) — no vendor code in lux.
- Rename MPC__* env vars → MPC_LIVENESS_*; drop every 
  identifier/comment across code + LLM.md. Zero /onyxd in lux/mpc.

Also finish the half-done decomplect from a326cc6 'rip zapauth gate' that
left pkg/api/zap_kms_server.go calling a removed checkAuth and missing the
encoding/json import — restore the import, drop the orphaned guard.

(Pre-existing, unrelated: cmd/mpcd still references thresholdd.NewZapServer
from the same in-flight WIP — not touched here.)
2026-06-10 21:42:27 -07:00
zeekay 7bbbe94236 ci: route to canonical native arcd labels [self-hosted, linux, <arch>]
Replaces non-canonical scale-set / org-prefixed labels with the
existing labels every arcd host registers with. Matches evo for
amd64 and spark for arm64. No new labels added.
2026-06-10 20:23:33 -07:00
zeekay 96a9a06b6f go: 1.26.3 → 1.26.4 (security: crypto/x509, mime, net/textproto) 2026-06-06 22:11:17 -07:00