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 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.
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.
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.
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.
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]>
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]>
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]>
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.)
* 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 threshold-FHE wallet keygen path (pkg/mpc/tfhe_session.go:154,165
production caller via pkg/mpc/node.go:583) was wired to
luxfi/threshold/protocols/tfhe.NewKeyGenerator + GenerateKeys. Per #197,
that primitive is a fail-loud placeholder: every party stores the full
master key, PartialDecrypt returns an HMAC tag, and CombineShares ignores
partials and runs single-party decryption. Upstream guards every entry
point with guardUnsafe() that panics unless
LUX_ALLOW_FAKE_TFHE_FOR_TESTING_ONLY=1 is set — by design.
Default production builds must not be able to reach the placeholder
primitive even with the env var set. Real-threshold wiring (luxfi/lattice
Shamir/LWE) is tracked as a multi-week separate task per LP-137 §2.6.
Changes:
- pkg/mpc/tfhe_session.go: //go:build experimental_tfhe — entire
tfheKeygenSession + tfheComputeSession + TFHESession interface gated
behind the tag.
- pkg/mpc/tfhe_node.go (new, experimental_tfhe): Node.CreateTFHEKeyGenSession
+ CreateTFHEComputeSession constructing the experimental sessions.
- pkg/mpc/tfhe_node_stub.go (new, !experimental_tfhe): stub Node methods
that return ErrTFHENotImplemented from every entry point. This is what
the production binary ships with.
- pkg/mpc/tfhe_errors.go (new, unconditional): ErrTFHENotImplemented
symbol with a description pointing at LP-137 §2.6.
- pkg/mpc/node.go: removed the unconditional CreateTFHEKeyGenSession +
CreateTFHEComputeSession definitions and the unused luxfi/fhe import.
Test pass:
go test ./pkg/mpc/... ./pkg/policy/... -short -count=1 -race -timeout 60s
ok github.com/luxfi/mpc/pkg/mpc 2.016s
ok github.com/luxfi/mpc/pkg/policy 1.477s
91 PASS, 0 FAIL (default build, no env var set)
go build -o /tmp/mpcd_final ./cmd/mpcd/
/tmp/mpcd_final version # mpcd version 0.3.3 — no panic, no UNSAFE TFHE reach
go build -tags experimental_tfhe ./pkg/mpc/... # opt-in build OK
Note: pkg/policy/main_test.go sets LUX_ALLOW_FAKE_TFHE_FOR_TESTING_ONLY=1
because RealThresholdDecryptor's dispatcher tests exercise the placeholder
PartialDecrypter — same TestMain pattern threshold/protocols/tfhe uses.
These are orchestration-shape tests; they verify no threshold security
property because the underlying scheme has none.
Companion bumps required for the branch to compile:
- luxfi/threshold v1.6.0 -> v1.6.4 (FHECiphertext / FHEThresholdShare /
ShareAggregator exports landed in v1.6.x)
- google/go-sev-guest -> go.mod (cc/attest/sev.go direct import)
- pkg/api/server.go: missing approval + tieredWallets fields added to
satisfy in-tree handler references
A stolen attestation (within its 2-minute validity window)
could be replayed against a different WebAuthn enrollment because the
envelope only attested (userID, score, timestamp) — it said nothing
about WHICH public key the user was actually enrolling.
Fix: extend LivenessAttestation with two optional binding fields:
credentialHash = sha256(publicKey) [base64-std]
challengeId = the WebAuthn challenge being enrolled [base64url]
handleBiometricEnroll computes both values from the real enrollment
data (consumed challenge bytes + submitted public key) and passes them
as ExpectedCredentialHash / ExpectedChallengeID to VerifyLiveness.
Mismatches are always fatal. A missing binding is fatal in STRICT
mode (the default) and warns-and-accepts in LAX.
BindingMode is server-configured via MPC_LIVENESS_BINDING=strict|lax.
Production mainnet runs STRICT — must ship the extended
envelope as a deployment blocker. LAX exists solely for the rollout
window; every permissive accept emits an `mpc.biometric.binding_warn`
log line so operators can see rollout progress.
Tests added in pkg/webauthn/liveness_test.go:
- R38_AcceptsMatchingCredentialHash
- R38_RejectsMismatchedCredentialHash
- R38_RejectsMissingBindingStrict
- R38_LaxWarnsOnMissing
- R38_AcceptsMatchingChallengeID
- R38_RejectsMismatchedChallengeID
Full ./pkg/api/ suite green; the existing pre-R3-8 LAX-style liveness
tests still pass (they supply no Expected* values → the binding path
is a no-op for them).
Every /v1 mutating route that previously accepted role=="api" without
permission checks now uses requireRoleOrAPIPermission — JWT human roles OR
API keys that carry the concrete permission. R2-3 regression surface:
- /v1/transactions → mpc:transactions:create
- /v1/subscriptions/* → mpc:subscriptions:write
- /v1/payment-requests/* → mpc:payment-requests:write
- /v1/smart-wallets/* → mpc:smart-wallets:write
- /v1/trade/* → mpc:trade:write
- /v1/intents/* (sign/co-sign) → mpc:sign (same perm as /v1/mpc/sign)
- /v1/mpc/operations/approve,reject → mpc:operations:approve
Bare role=="api" is no longer sufficient anywhere — an API key with empty
permissions hits 403. Human roles (owner/admin/signer) still pass through
on JWT auth. Also adds the canonical requireRoleOrAPIPermission helper to
middleware.go.
- F1: /v1/mpc/sign now requires sessionId (403 without). Same for
settlement/sign. No silent auto-session selection in handlers.
- F15: /v1/mpc/sign now gated by requireRole("owner","admin","signer","api")
to match /v1/mpc/settlement/sign.
- F6: decodeMessage is strict — returns error when the declared encoding
does not parse. Removed the raw-string fallback that made the endpoint
an arbitrary-bytes signing oracle.
- F4: /v1/mpc/balances/{address} now resolves the address against the
caller's org (ETH/BTC/SOL columns). Unknown or cross-tenant address
returns 404 — no more "empty balances" leak.
- F5: biometric enroll requires a server-issued challenge, signed
attestation (P-256 ECDSA verify), and a liveness score
above 0.8 (PAD-2). Raw templates are no longer accepted. Added
consumeBiometricChallenge + verifyBiometricAttestation helpers.
- F14: added Wallet.DefaultForUserID column; handleMpcSetDefaultWallet
now writes to it instead of stuffing a "default:<userID>" sentinel
into the user-facing Name field. Previous default (if any) is cleared
atomically per (orgId, userId).
- F22: findDefaultWalletID reads the new is_default column (via
defaultForUserId) — no more "most recently created wallet" fallback
that produced non-deterministic behavior.
Implement the canonical MPC surface frozen in
openapi/mpc.yaml. All endpoints mount under /v1/mpc/* — one and only
one path per operation, no aliases, no backwards compatibility.
New:
- handlers_mpc.go — wallets, balances, default wallet, sign,
settlement/sign, webauthn/biometric spec shapes
- handlers_sessions.go — time-bounded signing grants with operation
and value limits, enforced on every /sign via consumeSessionForSign
- handlers_operations.go — unified Operation view over db.Transaction
with kind ∈ {sign,send,mint,burn,transfer,contract_call}
Changed:
- server.go: mount r.Route("/mpc", …); remove top-level /v1/policies
and /v1/audit (moved to /v1/mpc/policies, /v1/mpc/audit); retain
/v1/transactions POST as internal wiring
- handlers_policy.go: accept both {id} (internal) and {policyId} (spec)
- pkg/db/models.go: add Session model (registered as "mpc-session")
- ws.go: EventBus pubsub + /v1/ws upgrade handler (stdlib, no deps)
- zap_server.go: StartZAP(port) with opcodes 0x0060-0x0064
keygen, sign, status, wallets, intents over ZAP binary protocol
- Events field on Server for cross-transport event broadcasting
- Each consensus node serves full API (not separate mpc-api sidecar)
- 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.
MPC is generic infrastructure — no hardcoded tenant URLs.
- Remove pars.network, zoo.network from OIDC issuers, ingress, TLS
- Remove .com, from CORS, WebAuthn, tests
- CORS/WebAuthn origins now env-driven: MPC_CORS_ORIGINS, MPC_WEBAUTHN_ORIGINS
- Ingress: mpc.lux.network only — tenants route through their own ingress
- Tests updated to match infrastructure-only origin lists
KMS routes now live inside MPC API server — one binary for both:
- POST /api/v1/keys/generate — create validator BLS + Corona keys
- GET /api/v1/keys — list all validator key sets
- GET /api/v1/keys/{id} — get validator key set
- POST /api/v1/keys/{id}/sign — sign with validator key
- POST /api/v1/keys/{id}/rotate — reshare validator keys
Storage: ORM (same postgres/ZapDB as MPC wallets).
No separate KMS binary needed — mpcd serves both.
- Remove pkg/bridge/ (btc_watcher + orchestrator) — bridge/opnet-watcher
is the single source of truth for chain watching per CTO directive
- Add Handler() to API server for mounting on Hanzo Base
- MPC is signing-only; bridge is the control plane
Add MPC_WEBAUTHN_RP_ID and MPC_WEBAUTHN_ORIGINS env vars to make
WebAuthn relying party and origin allowlist configurable. Adds
exchange origins (exchange.{dev,test,main}..com,
) to WebAuthn origin checks, CORS, and security tests.
- Run goimports -w on all 17 Go files with formatting issues
- Set CGO_ENABLED=0 on all CI steps (test, lint, build, CodeQL, SBOM)
- Remove -race flag from tests (requires CGO which pulls in blst.h)
- All 17 test packages pass with CGO_ENABLED=0
Backend:
- Reorg detection in tx tracker (re-verify block hash, detect receipt disappearance)
- Post-finalization safety margin (6 extra blocks before stopping tracking)
- Server-side HSM co-signing (HSMProvider interface, server calls HSM directly)
- Background intent expiry reaper (5-min sweep for stuck intents)
- Domain-separated intent hashing (lux-mpc-intent:v1 prefix)
- Backup rate limiting (max 3 active per wallet)
- Default backup changed to 2-of-3 (iCloud + HSM + offline) for fault tolerance
- All status transitions now use RecordTransition with proper actors
Frontend:
- Intents list page with status filter
- Intent detail page with lifecycle progress bar, sign/co-sign actions
- Settlements list page with status filter
- Settlement detail page with lifecycle, HSM signatures, transfer agency
- Enhanced transaction detail with on-chain confirmation, revert reason, status history
- Wallet backup UI with Shamir shard visualization
- Updated StatusBadge with all new status colors
- Nav updated with Intents + Settlements links
- Co-sign UI updated for server-side HSM flow (key_id only, no client signature)
Add HTML landing page at / route for mpc.lux.network showing:
- CGGMP21 and FROST protocol info
- Supported chains (BTC, ETH, LUX, SOL, XRPL, TON, etc.)
- Links to health, bridge dashboard, and API docs
Also update go.work to Go 1.26.1.
- Add WebAuthn/FIDO2 registration and verification endpoints
POST /webauthn/register/begin, /register/complete, /verify
- Biometric approval: Face ID/Touch ID/YubiKey approve transactions
- Challenge bound to transaction ID (SHA256) preventing replay
- Enforce ApproverRoles from policy engine during transaction approval
- Add WebAuthnCredential DB model (pending_registration, active, revoked)
- Policy decisions now carry ApproverRoles for role-gated approvals
- Callback page now checks both hash fragment and query string for
access_token (Casdoor returns it in query string, not hash)
- Login redirect uses /oauth/authorize instead of /login/oauth/authorize
- Add hanzo.id to default OIDC issuers in MPC API
- Update E2E tests to match corrected authorize URL
- Add OIDC token exchange endpoint (POST /api/v1/auth/oidc) that
validates Lux ID access tokens and issues local MPC API JWTs
- Update dashboard auth callback to do 2-step exchange: get Lux ID
token, then exchange for MPC API JWT via OIDC endpoint
- Fix IngressRoute to use path-based routing (/api/v1 → API, / → dashboard)
- Fix deploy.yml to target lux-k8s cluster (not hanzo-k8s)
- Fix wallet creation page to use api client instead of raw fetch
- Add /api and /healthz to dashboard middleware public paths
- validateAPIKey returns *db.APIKey so middleware has access to permissions
- API key permissions propagated into request context via ctxPermissions
- requirePermission middleware checks actual key permissions (not just role)
- PasswordHash/MFASecret fields marked api:"-" to prevent serialization
- Remove stale generated files (CHANGELOG, CI_STATUS, DOCKER_BUILD etc.)
- Update LLM.md with production deployment state and architecture decisions
- Replace github.com/dgraph-io/badger/v4 with github.com/luxfi/zapdb/v4 v4.9.2
throughout pkg/kvstore/, pkg/transport/, cmd/mpcd/, e2e/, scripts/
- Rename BadgerPath/BadgerPassword → ZapDBPath/ZapDBPassword in FactoryConfig
- Remove local replace directives from go.mod:
- hanzoai/orm: now uses published v0.3.2 (tagged on GitHub)
- hanzoai/kv-go/v9: now uses published v9.17.2-hanzo.1
- Only remaining replace: agl/ed25519 → luxfi/edwards25519
- Add vendor/ directory (GOWORK=off go mod vendor) for reproducible builds
- Update Dockerfile to golang:1.26-alpine + -mod=vendor build flag
- Update CI/CD to use self-hosted linux/amd64 runners (no cross-compilation)
- Add k8s/valkey.yaml for session KV store
- Update k8s manifests: api-kv support, ZapDB password env, fresh secrets
- Dashboard API and MPC nodes all tested running on cluster (3/3 nodes healthy)
- TriggerSign now looks up key type from keyInfoStore (was empty, causing
"CGGMP21 only supports Secp256k1" errors)
- Wire keyInfoStore into ConsensusMPCBackend
- Increase HTTP middleware timeout from 30s to 120s for MPC signing
- POST /api/v1/generate_mpc_sig — MPC signature for bridge transactions
- POST /api/v1/complete — mark bridge swap as confirmed
- Add bridge.lux.network to CORS origins
- Bridge wallet: 0xe591c587889dea548b6f886cea60049e4e358805
- Update Dockerfile from golang:1.23-alpine to golang:1.25-alpine
matching go.mod requirement (go 1.25.5)
- Add public /healthz endpoint for K8s liveness/readiness probes
- Update api-deployment.yaml probes to use /healthz instead of
JWT-protected /api/v1/status
- Run go mod tidy to fix missing go.sum entries