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]>
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]>
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]>
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.
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.
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.
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.
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]>
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]>
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.
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.
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.
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.
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.
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.
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.
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.
`mpcd seal` shipped without the one thing it needs: nothing outside a node
could read a committee's group key, so the command could not be used at all.
reveal.PublicKey has always known it; it was simply unreachable.
/group answers it, org and key named explicitly. /keys cannot: a KeyInfo record
carries no org, so it cannot name the share to read, and an empty eddsa_key
there is silence about the curve rather than a denial of it — 36 records on the
live ring report no Ed25519 key and that tells you nothing about whether the
shares exist.
The value is public in the strict sense. Handing it out lets anyone seal a
secret to the committee and lets nobody open one, which is the asymmetry the
whole protocol rests on. The answer carries the quorum beside it, because a key
that seals but cannot be opened is worse than no 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]>
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.
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.
Two halves of opening a secret were missing, and both showed up the first time
this ran against the real ring rather than an in-process one.
The request reached no party. Parties subscribed to "mpc.reveal_request.*" and
the requester published to "mpc.reveal_request.<session>". This transport
delivers by exact map lookup on the topic — it has no wildcards — so the
subscription matched nothing, every node stayed silent, and the opening failed
as "0 of 2 answered" with all three shares present and nobody having been asked.
That reads like a quorum fault and is a routing one. Both sides name one literal
now; the session rides in the body, where a reader wants it anyway. The pattern
came from the NATS-era topics next door, where wildcards do work.
And nothing could seal. mpcrek requires a sealed root to bring and no tool in
the estate produced one, so the protocol had no beginning. reveal.Seal takes the
published group key and a secret — no share, no node, no round, because writing
to the committee needs none of that — and `mpcd seal` is that from a shell, with
the secret on stdin so it never becomes an argument other processes can read.
Sealing to the identity is refused. It decodes as a valid point, so the decoder
lets it through, and every answer verifies against it: a secret sealed there is
readable by anyone. A test asked for that and the first version did not do it.
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.
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.
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.
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.
serveReveal returning an error aborted startup, so a new capability
could stop a ring from doing the thing it already existed to do.
Signing was working before openings were possible and should keep
working when one is not.
Nothing is hidden by carrying on. The failure is logged here, and an
opening that goes short already reports how many answered out of how
many it needed — which is the number that matters and the place someone
is looking when it does.
The upload was moved to v3 because this forge presents as GHES and v4
refuses it outright — the comment two lines above records the count, 0
of 72 against 132 of 133. The download was left on v4.
So every release built both binaries, published both images, cut the
manifest, and then failed at the last step. A tag whose image is already
live reads as a failed release, which is the worst shape the signal can
take: the thing everyone checks says no while the thing everyone needs
says yes.
Same version on both sides, for the same reason, which is also the only
arrangement that can work — a v4 download cannot read a v3 artifact even
where it is allowed to run.
pkg/reveal could open a secret; nothing could ask it to. This is the
asking: an opcode the KMS client has always sent, a backend that fans the
request out and waits for a quorum, and every node answering from its own
share.
Every node serves the answering half. A ring where only some answer is a
ring with a smaller quorum than its threshold claims, and the shortfall
would show up as a timeout rather than as the misconfiguration it is.
A node that holds no share for the key publishes that fact. Silence and
refusal look identical to a caller counting answers and they are not: one
means wait longer, the other means never — so a failed opening says how
many answered and names the nodes that would not.
The wait ends at a quorum rather than at the deadline. This runs at boot,
where a process that already has what it needs must not sit for twenty
seconds proving it, and one that will never get it must say so rather
than hang.
Answers are counted per party under a lock. A redelivery or a duplicated
subscription must not fill two seats — Open refuses such a set anyway,
but the wait should be honest as well as the result.
The two halves take a bus, a store and a name rather than reaching into
the backend, so the whole path is exercised over a bus a test controls:
the real Ed25519 keygen, each share in a store of its own, a root that
travels the ring and comes back, and a quorum that never forms saying
exactly why.
The interface gained a method, so two test mocks did too. That is the
compiler naming every implementation, which is the reason to put it
there.
Signing was the only thing the ring could do with its shares. So a
secret that needed t-of-n to read had nowhere to live, and the roots
that protect everything else went on living in one place, readable by
whoever reached that place.
Opening is not signing. A party's answer depends on its own share and
the ciphertext and nothing else, so there is no session to build, no
round to sequence, and no party that must wait to speak — a fan-out and
a fan-in with nothing in between. Its own topics for that reason: a
request that needs no session should not be routed through the machinery
that keeps them.
This daemon holds shares and answers with them. It does NOT hold the
things those shares open — a caller that wants something opened brings
it. So there is no store of sealed material here to guard, back up or
leak, and a sealed root can sit in a config map, because the only thing
that opens it is a quorum of these nodes.
The share read is the Ed25519 one, by name. A keygen writes two shares
per wallet and the bare id reaches the secp256k1 one, which is the
default a reader falls into and whose scalar acts on no point in this
ciphertext.
The quorum comes from the share set, never from the request: the
threshold and the verification shares are read here from the same record
every party read. An opening whose quorum size arrived with the request
would be an opening whose quorum size the requester chose. A malformed
answer costs its own seat and nothing else, so one bad party cannot deny
an opening the rest could complete.
The tests run the real Ed25519 keygen through the ceremony driver, write
each share through the production codec at the production key into a
store of its own, and open from three of them — while two, and one from
the node that also combines, open nothing.
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]>
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]>
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]>
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]>
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.
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.
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.
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.
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.
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.
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.
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.
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]>
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]>
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]>
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]>