`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.
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.
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.
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.
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.
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]>
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.
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.
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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).
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.
Trust at the network boundary (NetworkPolicy + ZAP wire), not in the
application envelope. Companion to luxfi/keys v1.2.0 (LoadMnemonic
identity-free) and luxfi/kms v1.12.0 (iamclient gone).
Killed:
- pkg/zapauth/ (JWKS verifier + AuthHelloFrame parser)
- pkg/api/zap_kms_server.go: KMSZapConfig, auth+authRequired fields,
handleAuthHello, respondAuthHello, checkAuth, StartKMSZAPWith
- cmd/mpcd: ZAP_JWKS_URL, ZAP_EXPECTED_ISS, ZAP_EXPECTED_AUDIENCES,
ZAP_AUTH_REQUIRED — all gone.
Note: mpc still has a pre-existing build block from upstream luxfi/geth
v1.16.79 → precompile pseudo-version (unrelated to this change).
* build(mpc): GOEXPERIMENT=jsonv2 alongside runtimesecret per SCALE_STANDARD
Per SCALE_STANDARD.md §2 (~/work/hanzo/hips/docs/SCALE_STANDARD.md) —
every Go production Dockerfile that emits JSON to a client builds with
GOEXPERIMENT=jsonv2. Verified on hanzoai/zip's json_bench_test.go:
- Edge POST roundtrip: -12% time, -23% allocs
- Marshal-only: -22% time
- Unmarshal-only: -19% time, -25% allocs
GOEXPERIMENT is comma-separated, so jsonv2 composes cleanly with the
pre-existing runtimesecret experiment used by the MPC data plane.
* ci: switch workflows to lux-build (luxfi-scoped ARC pool)
Cross-org runs-on dispatch failure: hanzo-build-linux-amd64 scale set is
registered to github.com/hanzoai and silently rejects luxfi/* jobs.
Switch to lux-build (githubConfigUrl=github.com/luxfi, max 20).
* test/zapauth: deterministic invalid-signature test (was flaky)
TestZapAuthInvalidSignature flipped one base64 char of the signature
segment. ECDSA accepts a wrong sig with probability ≈ 1/2^6 per
single-char flip (alphabet is 64), making the test ~1.5% flake rate
in CI.
Fix: replace the entire signature segment with all-A's. Still
decodable as base64url but cryptographically guaranteed-invalid
bytes — Verify rejection is now deterministic.
Verified: ran -count=10 ./pkg/zapauth/ — all pass.
* deps: bump accel/crypto/corona/threshold/fhe/lens/math to current tags
* api/db: decomplect — KeccakAddress on Wallet / TreasuryWallet / KeygenResult
Wave 5 of the workspace-wide eth* naming purge.
pkg/db/models.go — Wallet + TreasuryWallet:
- New canonical KeccakAddress *string field with json `keccakAddress`
- EthAddress kept with json `ethAddress` as `// Deprecated:` alias
- NormalizeAddresses() method on both structs mirrors the two fields
via the package-private mirrorAddrPtrPair helper
pkg/api/server.go — KeygenResult (backend response):
- New canonical KeccakAddress string field with json `keccak_address`
- EthAddress kept with json `eth_address` as `// Deprecated:` alias
- NormalizeAddresses() method mirrors both fields
pkg/api/handlers_wallets.go, handlers_treasury.go, handlers_bridge.go,
handlers_mpc.go — all sites that constructed wallet rows from MPC
backend results now:
1. Call result.NormalizeAddresses() on the backend response so both
KeccakAddress / EthAddress carry the same value before copy
2. Copy both fields onto the persisted Wallet / TreasuryWallet row
3. Call wal.NormalizeAddresses() to ensure DB row has both
4. Read from the canonical KeccakAddress at response-construction
sites (toWalletResponse, treasury response, bridge response)
The "ethereum"/"lux"/"xrp" labels in addresses map (handleGetWalletAddresses)
are per-chain DISPLAY labels keyed by chain name a client knows;
the values are the Keccak-derived 20-byte address. Map keys NOT
decomplected here — they're per-chain UI labels, not internal naming.
Effect on the wire:
- API consumers reading wallet.ethAddress see the same value as
wallet.keccakAddress
- New consumers can switch to keccakAddress today
- ORM persistence stores both fields (eventually consistent via
NormalizeAddresses calls at handler boundaries)
Tests: pkg/api unit tests pass (1.8s); pkg/db has no test files.
Deprecation alias retention means downstream KMS / dashboards
keep working at every step of the migration.
Per CLAUDE.md x.x.x+1.
* strip KeccakAddress + EthAddress dual-field bridge (no aliases)
Forward-only completion of the workspace strip-aliases cascade. The
canonical JSON field is evmAddress, the Go field is EVMAddress. No
NormalizeAddresses helpers, no mirrorAddrPtrPair, no deprecated
KeccakAddress, no deprecated EthAddress.
Touched:
pkg/api/server.go KeygenResult drops KeccakAddress +
EthAddress + NormalizeAddresses
pkg/db/models.go Wallet drops KeccakAddress + EthAddress
TreasuryWallet drops KeccakAddress +
EthAddress
Drops NormalizeAddresses + mirrorAddrPtrPair
pkg/api/handlers_wallets.go rip mirror calls; only EVMAddress
populated on Wallet rows
pkg/api/handlers_treasury.go rip mirror calls on TreasuryWallet
pkg/api/handlers_mpc.go rip NormalizeAddresses calls; rename
filter("ethAddress=") → ("evmAddress=")
pkg/api/handlers_bridge.go local var keccakAddress → evmAddr
pkg/api/handlers_smart_wallets.go wallet.EthAddress → wallet.EVMAddress
pkg/api/treasury_test.go mock KeygenResult EthAddress → EVMAddress
pkg/api/zap_kms_server_test.go same
cmd/mpcd/main.go pubKeyToEthAddress → pubKeyToEVMAddress,
\"eth_address\" → \"evm_address\",
local var ethAddr → evmAddr, KeygenResult
EthAddress → EVMAddress field
Deps: bump luxfi/metric v1.5.1 → v1.5.7 to pick up ExponentialBuckets
(via transitive luxfi/threshold dep).
The value IS \"EVM-runtime account address\" — that's the data model.
The Keccak256(secp256k1 pubkey) derivation is HOW.
The two MPC files that still carried Corona names:
cmd/mpcd/main.go — comment ref to luxfi/corona
pkg/api/handlers_kms.go — ValidatorKeySet wire schema
handlers_kms.go renames:
ValidatorKeySet.CoronaWalletID → CoronaWalletID
ValidatorKeySet.CoronaPublicKey → CoronaPublicKey
JSON tags coronaWalletId/PubKey → coronaWalletId/PubKey
local `coronaWalletID` and the per-validator suffix
"validator-<id>-corona" → "validator-<id>-corona"
router switch `case "corona":` → `case "corona":`
cmd/mpcd/main.go: comment "luxfi/corona and luxfi/corona" was
collapsed to just "luxfi/corona" — the dispatcher's not-yet-impl
stub now references only the live R-LWE library.
Wire break, no compat layer (forwards-perfection policy):
KMS callers must re-issue requests with the new field names.
Build: go build ./... PASS.
Tests: pkg/api PASS 1.374s.
Red round 5 finding B1. The embedded dispatcher on --threshold-listen
was an anonymous local signing oracle:
1. Any process on the host could POST `{"method":"frost.sign",...}`
and obtain signatures with no auth gate (only the API on :9800
enforced MPC_INTERNAL_API_KEY).
2. An operator typo `MPC_THRESHOLD_LISTEN=0.0.0.0:7300` exposed it
cluster-wide.
3. Any SSRF inside mpcd pivoted into the dispatcher.
Fix mirrors the existing internal-API auth pattern (cmd/mpcd/main.go
line ~456):
- Auth token: read MPC_THRESHOLD_AUTH_TOKEN; if unset, derive a
deterministic per-cluster token from the node Ed25519 seed
(SHA-256(seed || "mpc-threshold-rpc") hex). All nodes share the
same key without extra config; log warns when derivation is used.
Server.SetAuthToken installs the gate.
- Loopback bind: refuse non-loopback addr unless
MPC_THRESHOLD_ALLOW_REMOTE=1 — operators must explicitly
acknowledge the exposure. Bare `:port` (= 0.0.0.0) is NOT loopback.
Also picks up luxfi/threshold v1.6.10 which removes the pulsar/corona
in-memory-token surface (Red HIGH B2) — pulsar.*/corona.* now return a
typed "not yet implemented" error.
Tests:
- TestIsThresholdLoopback locks the loopback predicate (8 cases:
127.0.0.1, [::1], localhost OK; 0.0.0.0, :port, RFC1918 refused).
- TestEmbeddedDispatcher_AuthGate proves 401 on missing/wrong token,
200 on valid token through the dispatcher mpcd installs.
4 tests pass in cmd/mpcd (was 2, +2).
mpcd is the production threshold daemon. With this commit it embeds
the JSON-RPC dispatcher from luxfi/threshold/pkg/thresholdd on a new
--threshold-listen flag (default 127.0.0.1:7300, MPC_THRESHOLD_LISTEN
env var). The dispatcher exposes every supported scheme:
cggmp21 (secp256k1 ECDSA, CGGMP21 fork)
frost (secp256k1 Schnorr, RFC 9591)
bls (BLS12-381 Shamir/Lagrange)
sr25519 (Ristretto255, via mpc node's own session — separate path)
pulsar (M-LWE / FIPS 204 ML-DSA-65 threshold, via luxfi/corona/threshold)
corona (R-LWE / Corona, via luxfi/corona/threshold)
doerner (reserved; upstream protocol non-functional, returns explicit error)
Wire format matches teleport/mpc/src/signers/rpc.ts byte-for-byte:
POST / {"jsonrpc":"2.0","id":N,"method":"<scheme>.<op>","params":{...}}
Methods per scheme: keygen / sign / verify. Public-key handles are
returned by keygen and bound on sign/verify.
Decomplecting note: the dispatcher carries zero policy. Profile
gating, auth, audit, and rate-limiting live on mpcd's existing API
surface (and on the cluster ingress in front of it). The threshold
listener is loopback by default; production exposure happens via the
KMS-facing ZAP server or the gateway.
There is no separate thresholdd daemon to deploy. The luxfi/threshold
repo's cmd/thresholdd remains as a developer / CI binary that imports
the same package — one wire, one implementation, two startup paths.
Tests: TestEmbeddedDispatcher_BLSRoundTrip (full keygen → sign →
verify → forgery rejection) and TestEmbeddedDispatcher_UnknownScheme
(-32601 method-not-found contract) both pass. README updated with the
new protocol table; full surface documented in
luxfi/threshold/LLM.md "thresholdd — unified JSON-RPC dispatcher".
The Liquid operator already provisions a Service exposing port 9653
on every MPC StatefulSet (spec.zapPort=9653). Match that default so
no operator/CR change is required for v1.13.0 rollout.
Adds threshold-signing transport so KMS can use mpcd as MPC backend.
Reuses ML-KEM-768 hybrid handshake from luxfi/kms/pkg/zap (#55) by
vendoring the same construction into mpc/pkg/zap — wire constants
(opcodes 0x00F0/0x00F1, HKDF info "kms/zap/v1/session", direction
tags) stay in lockstep with KMS.
New surface:
- pkg/zap: vendored handshake + session (forward-only convergent)
- pkg/api/zap_kms_server.go: KMS-facing ZAP node (opcodes
0x0001/0x0010/0x0011/0x0012/0x0020) wired onto MPCBackend
- cmd/mpcd: --kms-zap-listen flag (default :9970), enabled out of the
box; default-disable by passing empty string
Existing pkg/api/zap_server.go (dashboard event bus, opcodes 0x0060+)
is unchanged — different consumer, different port.
Closes the secrets-only mode gap on mainnet (#70).
Document mpcd's two topology modes explicitly in cmd/mpcd/README.md:
standalone (1-of-1 self-loop) for smoke tests / dev sandboxes, and
networked (t-of-n) for production. The same binary handles both — only
flags differ.
Add cmd/mpcd/standalone_test.go behind the integration build tag. It
builds mpcd, runs it with --threshold=1 and no peers, polls /healthz
until signing_quorum=true, and asserts the response shape (mode,
threshold, version). Keygen is intentionally NOT exercised — CGGMP21
and FROST cannot produce shares with a single party.
Run: go test -tags=integration ./cmd/mpcd/... -count=1 -timeout 90s
Honest scope-limit: mpc trunk has a pre-existing build break in
pkg/policy (undefined fhethr.* symbols) and pkg/api (handlers_approval
references s.approval that's not on the Server struct). The smoke test
itself is correct and passes type-check against cmd/mpcd; it will go
green once the upstream policy/api regression is resolved.
Commit 3bb399c ("deployment-mode flag + audit dispatcher") referenced
buildHSMSignerConfig at the HSM signer wiring site but the helper file
was never staged — same pattern as the missing airgap files repaired
under #208. The current main.go works around the gap by passing nil to
hsm.NewSigner, which loses the operator-supplied --hsm-signer-key-id and
prevents per-provider config promotion.
cmd/mpcd/hsm_signer_config.go:
- validates provider name against the luxfi/hsm v1.1.3 factory list
(aws/gcp/azure, zymbit, yubihsm, nitrokey, pkcs11, kmip, coldcard,
foundation, keystone, ngrave, gridplus/lattice, ledger, trezor,
tr31, mldsa, local) and the documented aliases.
- promotes --hsm-signer-key-id into the canonical config key for each
cloud KMS provider (aws.key_id, gcp.crypto_key, azure.key_name).
- leaves vendor-specific surface (slot/pin/library, vault URL,
transport) to the factory's MPC_HSM_<P>_* env-var fallback so the
CLI flag surface stays minimal.
cmd/mpcd/main.go: route --hsm-signer through the helper and surface
config errors before reaching hsm.NewSigner.
cmd/mpcd/hsm_signer_config_test.go: covers cloud-KMS key-id promotion,
hardware-wallet empty-map case, alias acceptance, case-insensitive
provider names, and rejection of empty + unknown providers. Helper
+ tests pass under `go test -race -count=1` in isolation.
go.sum: refresh github.com/luxfi/edwards25519 v0.1.0 hashes to match
proxy.golang.org. Pre-existing checksum drift blocked all `go build`
attempts on this branch and on main.
Note: cmd/mpcd as a whole still does not compile on this branch — the
same 3bb399c commit also dropped pkg/api/handlers_approval.go without
adding the Server.approval field, and pkg/policy/fhe_threshold_decryptor.go
references symbols that never landed. Both are out of scope for #227 and
will be repaired under follow-up tickets in the same #208-style cleanup.
ZAP is the binary protocol — like HTTP=80 and HTTPS=443, ZAP=9999
across the Lux ecosystem. The DNS name (e.g. zap.kms.svc, zap.mpc.svc,
mpc-node-headless.lux-mpc.svc) disambiguates the service; the port is
always the same.
Changes:
- mpcd --listen default :9651 → :9999
- pkg/transport/{transport,factory}.go: comments and examples
- k8s/mpc-statefulset.yaml: ports + peer template
- k8s/network-policy.yaml: ingress/egress allow rules
- cloudbuild.yaml: EXPOSE
- deployments/customer-3of5/{compose.yml,k8s/*,topology.yaml}: full
template sweep — every peer hostname is distinct, port is uniform 9999
- README.md / LLM.md: example commands
luxd validator P2P (9631 mainnet, 9641 testnet, 9651 devnet) is a
DIFFERENT protocol on a DIFFERENT pod and is unchanged.
Follow-up to e08dba1. The CLI/CRD `--threshold` flag surfaces the
OPERATOR-facing "minimum signers required" count (e.g. threshold=2 for
a 2-of-3 ensemble, threshold=3 for 3-of-5). The CGGMP21 signing API
internally stores the polynomial degree (one below signers-required)
in keyInfo.Threshold — so the sign code continues to compare
`len(signerPeerIDs) >= keyInfo.Threshold + 1`. Those two surfaces are
off-by-one by protocol library convention.
HasSigningQuorum is operator-facing (used by /healthz) so it uses the
CRD/CLI semantics directly: quorum holds when `readyCount >= threshold`.
Scientist's repro: 3-node 2-of-3 ensemble (CRD threshold=2) with one
peer down — readyCount=2, threshold=2 → quorum holds, /healthz returns
200. With the previous commit the arithmetic was readyCount>=threshold+1
which would have required all 3 nodes up.
Tests updated accordingly (Test_HasSigningQuorum_3Node_2of3 now uses
threshold=2 matching the deployed CRD, Test_HasSigningQuorum_5Node_3of5
uses threshold=3, boundary test covers the new arithmetic).
Survivor nodes in a t-of-n ensemble now report healthy so long as they
still have enough ready peers (including self) to form a signing quorum
for their configured threshold. Previously, ArePeersReady() was the gate
and any single peer loss collapsed availability on the remaining nodes.
Repro (Scientist, 2026-04-20): scale liquid-mpc-mpc-node StatefulSet
from 3 → 2 replicas in the liquidity devnet; surviving pods returned HTTP
503 with log "Peers not ready expected=3 ready=2" even though 2 nodes
are sufficient for the 2-of-3 (threshold=1) signing quorum.
Fix:
- pkg/transport/registry.go: add HasSigningQuorum(threshold) which returns
true when readyCount >= threshold+1 (the CGGMP21/FROST signers-required
count used everywhere in pkg/mpc/node.go).
- pkg/mpc/registry.go: matching method on the legacy Consul-backed
registry plus interface addition to PeerRegistry.
- cmd/mpcd/main.go: /healthz handler switched to HasSigningQuorum(threshold)
for the 200/503 gate. ArePeersReady() is still reported in the JSON
body (as `ready`) for observability. `status` becomes
"healthy" / "healthy-reduced" / "degraded" so operators can see the
in-between state.
- cmd/mpcd/main.go: ConsensusPeerRegistry wrapper delegates the new
method to the underlying transport registry.
Keygen sessions deliberately continue to require ArePeersReady() — a
fresh wallet still needs every participant present. Only the /healthz
gate is relaxed, which matches the availability story for signing.
Tests: pkg/transport/registry_test.go covers
- Test_HasSigningQuorum_3Node_2of3 (the Scientist repro — survivors
still quorum-ok after one peer loss)
- Test_HasSigningQuorum_5Node_3of5 (larger ensemble, f=2 tolerance)
- Test_HasSigningQuorum_ArePeersReady_Decoupled (the two APIs do not
gate each other)
- Test_HasSigningQuorum_BoundaryCheck (pins arithmetic: required =
threshold+1, not threshold)
All 11 test cases pass; transport + mpc package suites clean.
Vite + React SPA with 9 pages (Dashboard, Wallets, Vaults, Policies,
Settlements, Bridge, Payments, Audit, Settings) served at /_/mpc/ via
go:embed. Replaces the separate Next.js dashboard pod — the UI now ships
inside the mpcd binary. Uses @tanstack/react-query, wouter hash router,
dark theme, inline styles. Build output: 272KB.
- Add bearer token auth to internal MPC API (port 9800): /keygen, /keys,
/backup now require Authorization header. Token from MPC_INTERNAL_API_KEY
env (KMS-synced) or derived from Ed25519 node identity.
- Replace DualModeListener with TLSOnlyListener: plaintext peer connections
are rejected. TLS 1.3 with PQ key exchange is mandatory.
- Add dedicated 20 RPM rate limiter on bridge signing and intent signing
endpoints (separate from global 100 RPM).
- Add body size limit (1MB), read/write timeouts, and 10 RPM rate limiter
to internal MPC API server.
- Restrict K8s NetworkPolicy egress for MPC nodes to peers, postgres,
valkey, DNS, and HTTPS only (was unrestricted).
- Wire cluster-api-key in api-deployment for authenticated proxy to
internal MPC API. Forward as Authorization: Bearer header.
- Remove hardcoded dev JWT_SECRET default from compose.yml (now required).
- Add internal-api-key to KMSSecret CRD and StatefulSet env.