Prove your DeFi position is solvent without revealing it.
A lender, an OTC desk or a counterparty wants to know your loan is not about to be liquidated. Today the only way to show them is to hand over your position: collateral, debt, the whole book. Freeboard replaces that with a zero-knowledge proof on Midnight: the circuit computes your Aave-style health factor privately and discloses exactly one bit (SAFE or AT_RISK) against a threshold the verifier chooses.
The numbers never leave your machine. The verdict is all that goes on-chain.
📄 Technical Proof of Work: architecture, contract design, and security model
📊 Pitch Deck: product vision and market positioning
A circuit over self-reported numbers proves only that you can do arithmetic. It says nothing about whether the position is real, so it is worth nothing to a counterparty.
So the position must arrive signed by an attester, and Freeboard verifies that signature inside the circuit, before the health-factor math runs. The verdict is therefore bound to a position an oracle actually observed. The attester's public key is fixed at deployment and there is deliberately no circuit to change it: an unauthenticated rotate would let a prover install their own key and attest to their own numbers, which is the exact hole this closes.
Signature scheme is Schnorr over JubJub, straight from the Compact standard
library. The signed payload is [collateral, debt, liquidationThresholdBps, asOf]; asOf sits inside the signature, so an attestation cannot be
re-stamped and replayed forward.
Aave's health factor is HF = (collateral × liquidationThreshold) / debt.
Compact is integer-only and division is to be avoided, so checking HF ≥ T
becomes a cross-multiplication. With both sides in basis points the 10000s
cancel:
collateral × liquidationThresholdBps ≥ minHealthFactorBps × debt
No division, no precision loss. debt == 0 is an infinite health factor, so
trivially safe. The verdict enum is ordered { at_risk, safe } so that the
default ledger value of 0 means at_risk: you are at risk until a proof
says otherwise.
Working end to end on a local ledger-9 devnet, as of 2026-08-29.
- ✅ The contract works.
contracts/freeboard.compactcompiles against Compact 0.34.0 with proving and verifier keys emitted, including the in-circuit Schnorr check. - ✅ Deploy, CLI and the e2e check are ported and verified on-chain. A signed position produces a SAFE verdict, an under-collateralised one produces AT_RISK, and a position tampered with after signing is rejected in-circuit with no verdict written.
⚠️ The attester is a mock oracle. Its signing key lives on the same machine as the prover, so the check proves the mechanism, not that any position is real. Replacing it with an independent attester needs no contract change: that is why the check is in-circuit already. Seesrc/attester.ts.- 🚧 Not deployed to a public testnet, and no web UI yet.
npx freeboard-cli --help # no install
npm i -g freeboard-cli # then: freeboard --helpThe package is freeboard-cli; the command it installs is freeboard. (The
plain freeboard name has belonged to an unrelated IOT dashboard since 2016.)
What you get, and what you do not. The package ships the CLI, the contract
source and its compiled prover keys, so --help and the whole interface work
immediately. It does not ship a chain. Freeboard proves against a deployed
contract, and deploying one needs the Compact compiler and a running devnet,
neither of which fits in an npm package. So on a bare install:
freeboard --help # works
freeboard --read # "No deploy on file", and it says whyTo get an actual verdict, clone the repo and follow Try it below. The CLI tells you this rather than failing obscurely.
git clone https://github.com/Sammy949/freeboard && cd freeboard
npm install
npm run devnet:start # ledger-9 devnet, waits for healthy
npm run compile # -> contracts/managed/freeboard/
npm run deploy -- --network undeployed-l9
npm run cli # interactive menuOr non-interactively (-- passes the flags through npm to the CLI):
npm run cli -- --read # the verifier's view: verdict only
npm run cli -- --check --read # prove a position, then read the verdict
npm run cli -- --check --collateral 1000000 --debt 900000 --threshold 8500 --min-hf 15000 --read
npm run cli -- --check --tamper # watch the in-circuit check reject it
npm run cli -- --help # every flag, with the circuit's field bounds
npm run test:e2e # asserts the public state leaks no positionBare npx tsx src/cli.ts no longer works on its own: the CLI needs a loader hook
registered before the Midnight SDK is imported (it redirects cross-fetch, see
bin/cross-fetch-shim.cjs for why). The npm scripts pass it; so does the published
freeboard binary.
A real run against the devnet:
Position (PRIVATE): collateral=1000000 debt=400000 threshold=8500bps
Verifier threshold (PUBLIC): 15000bps
Health factor (local, display only): 2.1250
✅ Accepted. Verdict: ✅ SAFE
─── Public ledger state (all a verifier can see) ───
Verdict: ✅ SAFE
Attestation asOf: 1787957676
Checks performed: 1
↳ note what is NOT here: no collateral, no debt, no threshold.
And the tampered case (collateral inflated ×1000 after signing):
⚠ TAMPERING: inflating collateral ×1000 after signing.
signed collateral = 1000000
submitted collateral = 1000000000
Local signature check: INVALID
🛑 REJECTED IN-CIRCUIT: position is not signed by the registered attester.
No verdict was written.
There is a version story behind the toolchain, and it is not a small one: every
installable Compact compiler emits runtime 0.19.x, while the current stable SDK
hard-pins 0.16.0. Freeboard therefore runs the 5.0.0-beta SDK deliberately, on a
devnet assembled from pre-release images that upstream publishes no matrix for.
notes/03-midnight-toolchain.md has the full investigation, including two
failures worth knowing about if you rebuild this stack.
freeboard/
├── contracts/
│ └── freeboard.compact # the contract: this is the product
├── notes/ # design + research, written as we go
│ ├── 01-concept-and-pitch.md
│ ├── 02-architecture.md
│ ├── 03-midnight-toolchain.md # version skew, the beta-SDK decision
│ └── 04-roadmap-and-open-questions.md
├── src/
│ ├── attester.ts # the mock oracle: signs positions
│ ├── witnesses.ts # how a position reaches the circuit
│ ├── deploy.ts # deploys, fixing the attester key
│ ├── cli.ts # run checks, read verdicts
│ └── network.ts, wallet.ts, … # network config + wallet glue
├── patches/cross-fetch/ # shim; see the file for why
├── docker-compose.yml # ledger-8 devnet (known good, but too old)
└── docker-compose.ledger9.yml # ledger-9 devnet (what 0.34.0 needs)
Built for Midnight Wave 1. The contract is the product; the CLI comes with it; a web dashboard is the demo skin.
Everything below is inherited create-mn-app documentation. Most of it still
applies (networks, wallets, env overrides); the parts describing a
hello-world contract do not.
Requirements: Node 22, Docker (with Compose v2), and the Compact compiler at the version pinned in .compact-version at the create-mn-app repo root (the version this project was scaffolded against).
npm install
npm run setup
npm run test:e2enpm run setup runs end-to-end with no prompts:
docker compose -f <network's compose file> up -d --waitstarts a local Midnight devnet (node, indexer, proof-server) and blocks until all three pass their healthchecks.npm run compilecompilescontracts/freeboard.compacttocontracts/managed/freeboard/.npm run deployderives the genesis-seed wallet (NIGHT pre-minted), registers UTXOs for DUST generation, loads or generates the attester key, deploys the contract with that key as its constructor argument, writes the deploy record to.midnight-state.jsonin the state home (see Where state lives).
npm run test:e2e reconnects to the deployed contract, reads its ledger state, and asserts the public state carries a verdict and no position data. Exits 0 on success.
Two stacks ship with the project. Ports differ so both can run at once.
| Service | ledger-9 (docker-compose.ledger9.yml) |
ledger-8 (docker-compose.yml) |
|---|---|---|
node |
19944 | 9944 |
indexer |
18088 | 8088 |
proof-server |
16300 | 6300 |
The ledger-9 stack is the one Freeboard needs. Shortcuts:
npm run devnet:start # ledger-9 stack, waits for healthy
npm run devnet:stop
npm run devnet:clean # also drops volumesState lives in container-managed volumes. devnet:clean (or docker compose -f <file> down -v) removes all containers, networks, and volumes, so the next
npm run setup starts from a clean slate.
The deploy script uses a well-known genesis seed (0000…0001) so the
pre-minted NIGHT in the dev chain preset is immediately available. Do
not use this seed against Preprod, mainnet, or any environment that
handles real value: anyone running this devnet has full access to
funds at this seed.
Four networks. Note there are two local devnets: they run different ledger versions, so which one you want depends on which compiler built your contract.
| Network | When to use | Default? |
|---|---|---|
undeployed-l9 |
Ledger-9 devnet (docker-compose.ledger9.yml, ports +10000). What compiler 0.34.0 targets, so this is where Freeboard deploys. Genesis seed, no funding needed. |
yes |
undeployed |
Ledger-8 devnet (docker-compose.yml). The combination upstream actually tests, but it cannot run a 0.34.0-compiled contract. Kept as the known-good fallback. |
|
preview |
Public preview testnet. Faucet at https://midnight-tmnight-preview.nethermind.dev. |
|
preprod |
Public preprod testnet. Faucet at https://midnight-tmnight-preprod.nethermind.dev. |
The two devnets are separate networks rather than one with a switch, because
they are genuinely different chains: each keeps its own deploy record and wallet
sync cache, and a contract address from one means nothing on the other. Both
speak the undeployed protocol network id on the wire.
The active network is sticky: whichever network you last interacted
with stays active until you switch. Any command run with --network <name>
also sets that network active for subsequent commands. The default on a
fresh project is undeployed-l9.
npm run setup -- --network preview # runs on preview AND makes it active
npm run cli # still uses preview
npm run check-balance # still uses previewYou can also switch without running anything else:
npm run network preview # active network is now preview
npm run network # prints current active network
npm run network undeployed-l9 # switch back to the ledger-9 devnetEverything Freeboard remembers sits in one per-user directory, not in the project:
$FREEBOARD_HOME if set: the escape hatch, and how to run two
identities on one machine on purpose
$XDG_CONFIG_HOME/freeboard if XDG_CONFIG_HOME is set to an absolute path
~/.config/freeboard otherwise
Inside it (directory 0700, secrets 0600):
.midnight-state.json |
wallet seed + recovery phrase, and every deploy record |
.midnight-attester.json |
the attester signing key |
recovery-phrase.<network>.txt |
a freshly generated phrase, written once |
.midnight-wallet-state/ |
wallet sync cache, per network |
private-state-db/ |
the LevelDB private-state store |
.midnight-results.json |
proved-once scenario records |
These used to default to process.cwd(), which meant freeboard in ~/work
and freeboard in ~/tmp were different wallets: each silently created,
each printing its own recovery phrase. Fund one, run from the other, and the
balance reads zero with no explanation. Per-user state fixes that and keeps the
signing key out of whatever directory you happened to be in.
npm run clean removes build artifacts only. Deleting state is
FREEBOARD_CLEAN_CONFIRM=1 npm run clean:state, and the confirmation is
required because the phrase and the attester key are unrecoverable: losing the
latter means every existing deployment is dead, since the contract bakes the
verifying key into its constructor and has no rotation circuit.
- Both devnets use a hardcoded genesis seed, pre-funded by the
devpreset. previewandpreprodgenerate a fresh wallet on first use: a 24-word BIP-39 recovery phrase plus its derived seed, stored in the state home described above. The wallet survives switching networks: switch back later and your funded wallet returns.- The phrase is never printed to stdout. It is written to
recovery-phrase.<network>.txt(mode0600) and the CLI prints the path. A mnemonic on stdout is a wallet-controlling secret in every log, pipe and screenshare that captures it. The file is created with an exclusive flag, so a second run can never overwrite one. - Back up your recovery phrase if you fund a public-network wallet you care about, then delete the file. Anyone holding the phrase controls the wallet.
- Wallets created before mnemonic support keep working from their stored
seed; they just have no phrase to import into Lace.
Seeds are derived with the standard BIP-39 mnemonicToSeed step (the same
convention Lace uses) so identity is portable in both directions:
-
Bring your Lace wallet here: pass your recovery phrase via the
MIDNIGHT_WALLET_MNEMONICenv var: the derived addresses match Lace. To keep the phrase out of your shell history, enter it with a hidden prompt instead of typing it inline:read -s MIDNIGHT_WALLET_MNEMONIC && export MIDNIGHT_WALLET_MNEMONIC npm run deploy
-
Take a scaffold wallet to Lace: restore Lace from the 24-word phrase in
recovery-phrase.<network>.txt, or fromwallets.<network>.mnemonicin.midnight-state.json.
On the first run with --network preview (or preprod):
setupwill print your wallet address and the faucet URL.- Open the faucet URL, paste the address, request tNIGHT.
setuppolls the wallet balance every 10 s and continues automatically once funds arrive.- The default poll budget is 10 minutes. Override with
MIDNIGHT_FAUCET_TIMEOUT_MS=1800000(30 min) for unattended runs.
If the faucet is slow or the script times out, your seed is preserved.
Re-run npm run setup -- --network preview once the funds land.
These env vars override the active network's config (no per-network suffix; they apply to whichever network is active for the run):
| Variable | Effect |
|---|---|
MIDNIGHT_WALLET_SEED |
Use this hex seed (32-128 hex chars; a Lace-compatible BIP-39 seed is 128) instead of generating/persisting one. Useful for CI with a pre-funded wallet. |
MIDNIGHT_WALLET_MNEMONIC |
Use this BIP-39 recovery phrase instead of generating a wallet (e.g. your Lace phrase, for the same addresses as Lace). Not persisted. Set only one of seed/mnemonic. |
MIDNIGHT_INDEXER_URL |
Override the indexer GraphQL URL. |
MIDNIGHT_INDEXER_WS_URL |
Override the indexer WS URL. |
MIDNIGHT_NODE_URL |
Override the node RPC URL. |
MIDNIGHT_FAUCET_URL |
Override the faucet URL printed during setup. |
MIDNIGHT_PROOF_SERVER_URL |
Override the proof server URL: set to a public proof server (e.g. https://lace-proof-pub.preview.midnight.network) to skip running one locally. |
MIDNIGHT_FAUCET_TIMEOUT_MS |
Faucet poll budget in milliseconds (default 600000 = 10 min). |
FREEBOARD_HOME |
Override the state directory outright (see Where state lives). The way to keep two isolated identities on one machine, and what the tests use. |
XDG_CONFIG_HOME |
If absolute and FREEBOARD_HOME is unset, state goes in $XDG_CONFIG_HOME/freeboard. |
PRIVATE_STATE_PASSWORD |
Password for the encrypted private-state store. Defaults to a public placeholder that is fine for a local devnet and not for anything else. |
By default all networks use the local proof server. Public proof servers exist (see the env override above) but the local default keeps your witness data on your machine and avoids depending on a remote service for the deploy hot path.
npm run network undeployed-l9 # or: npm run setup -- --network undeployed-l9Your preview/preprod wallet seeds and deploy addresses stay in
.midnight-state.json in the state home. Switch back later, and they're still
there.
After each deploy, cli, or check-balance run, the scripts serialize the
wallet's synced state to .midnight-wallet-state/<network>/ inside the state
home (see Where state lives).
The next run on the same network restores from that snapshot and only catches
up to the latest block instead of replaying from genesis: meaningful on
preview / preprod where a from-seed sync takes minutes.
If the cache is stale or corrupt (e.g. after an SDK upgrade with an
incompatible state format) the wallet falls back to a fresh from-seed sync
with a one-line warning. npm run clean:state removes it along with the rest
of the per-user state.
| Script | Description |
|---|---|
npm run setup |
One-shot: start devnet, compile, deploy. |
npm run compile |
Compile the Compact contract. |
npm run deploy |
Deploy the compiled contract (requires devnet up + compiled). |
npm run cli |
Run solvency checks / read verdicts. Interactive, or --check / --read / --tamper for one-shot. |
npm run serve |
Local HTTP service over the same client, for the web demo. Loopback only, no auth: it holds the attester signing key. One synced wallet per process; checks are serialized. |
npm run check-balance |
Print the genesis-seed wallet's NIGHT and DUST balances. |
npm run test:cache |
Pure check on wallet-cache chain binding. No devnet needed. |
npm run test:e2e |
Read-back check: contract is live, and its public state leaks no position. |
npm run typecheck |
Typecheck src/ and scripts/ (the latter is not compiled into dist/). |
npm run build |
Compile src/ to dist/. Runs automatically on npm pack/publish. |
npm run clean |
Remove contracts/managed/ and dist/. Build artifacts only. |
npm run clean:state |
Delete the wallet, recovery phrase, attester key and caches from the state home. Lists what it would remove and refuses without FREEBOARD_CLEAN_CONFIRM=1. |
npm run devnet:start / :stop / :clean |
Ledger-9 devnet lifecycle (:clean also drops volumes). |
npm run devnet:ps |
Show every freeboard container, both stacks, running or not. |
npm run devnet:stop-all |
Bring down both stacks. Use this if you are unsure what is up. |
npm run devnet8:start / :stop |
Ledger-8 fallback stack. Never run alongside the ledger-9 stack: two nodes, two indexers and two proof servers will exhaust a typical dev machine. Was named proof-server:start, which implied it started only a proof server; it starts all three services. |
(See Repo layout near the top for the current structure.)
.compact-version at the create-mn-app repo root pinned the compiler
version this project was scaffolded against. To upgrade your local
compiler to that version:
compact update <version>
compact use <version>