Midnight Buildathon · Wave 2 · Compact + MidnightJS
DeFi lenders cannot assess a borrower without seeing their finances, so every
borrower posts the same over-collateral, typically 150%. Kymider lets a borrower
prove in zero knowledge that their committed balance, debts and income clear the
lender's bar. The Loan contract then accepts only one collateral figure from
the lender: the tier's, which is 110% for a verified borrower. That figure is
an offer. The loan becomes binding only when the borrower accepts it. The
verdict, the collateral and the repayment schedule settle on Midnight's public
ledger. The figures behind the verdict stay in the borrower's private state and
never enter a transaction. A borrower who has repaid two earlier Kymider loans
can prove that on the new loan without naming them. Every figure then drops a
step: 105% verified, 130% without a proof.
A lender can only offer the tier's figure (110% for a verified borrower), and only the borrower can make it binding: 10,000 borrowed against 11,000 collateral, not 15,000.
| Live console | Video | Deck | On-chain proof | Tests | What is simulated |
|---|---|---|---|---|---|
| turnless.github.io/kymider | Narrated video: being recorded, link added before Oct 17 | Slides · PDF | Devnet: 37 tx, 54/54 read-back checks on commit 93cc853 (DEVNET-PROOF.md), re-run in CI on every push. Preprod: 37 tx, 54/54 checks (PROOF.md, details) |
366 offline, 48 browser, 19 devnet (below) | Money: no token moves. Console: contracts run in-browser, no proofs (details) |
Five minutes, no keys, nothing to install. Every refusal you see below is the
contract's own assert message, raised by the compiled Loan and
LoanDirectory contracts running in your browser.
- Open https://turnless.github.io/kymider/ and click Open the console. You start as the borrower. Private facts holds your balance, debts and income; only their salted commitment is on the ledger.
- Apply. Borrower → Loans. In the form, set principal 10,000,
interest 10% (the form starts at 8), 3 installments. Beside it:
collateral $11,000 with a solvency proof (110%), $15,000 without
(150%). Keep the lender as Harbor Bank: the lender console acts as
Harbor Bank unless you pick another lender under Acting as in the lender
rail, and it lists only the applications made to that lender. Click
Apply to Harbor Bank: this deploys a
Loaninstance bound to your facts commitment and lists it in theLoanDirectory. - Quote. Switch to Lender (role switch: bottom-left on desktop,
top-right on a phone) → Applications → the loan → Send quote
(net worth ≥ $500,000, DTI ≤ 40%, valid 72 h). The page shows "Quote 1 of 3".
Optional: click Offer 150% before they prove · 15,000. The contract
refuses with
the borrower can prove until the quote lapses; a quote stands until the borrower proves, waives or lets it lapse. - Prove the tier. Switch to Borrower → Loans → the loan →
Prove tier. The
proveTiercircuit checks your facts against the on-ledger commitment and the quote, and writes one value: here Verified · 110%. - Watch the contract refuse 150%. As the lender, open the loan again. The
desk shows 11,000 (110%); under "What you can see, and what you cannot",
balance, debts and income are listed as never leaving the borrower. Click
Ask for 150% anyway · 15,000: the contract refuses with
collateral does not match the tier. Click Replace quote to try to clear the tier: refused too, witha verified tier is live until it lapses. - Offer, then accept. Offer at 110% · 11,000 records the lender's
offer; the desk reads "Waiting for the borrower to accept" and nothing binds
yet. Switch to Borrower → the loan. Under "Your step · Accept or
decline", "Harbor Bank offers $11,000 collateral (110%)": click Accept
(the
acceptcircuit); Decline would rundeclineOfferand reopen the application. The loan is now Active. Switch back to Lender → Disburse: 11,000 owed in installments of 3,667, 3,667 and 3,666. - Repay, once late. As the borrower, on the loan: +31 days (the demo block clock), then Repay $3,667. It is recorded late, by block time, not by the borrower. Repay $3,667, +30 days, Repay $3,666: Repaid in full, 3 payments, 1 late. The payment log sits next to the public record; both show the same amounts, because each amount is a transaction input.
- Record, then prove two repaid loans. As the lender: Record repayment in the directory. As the borrower, apply for a second loan; on it, under Prove two repaid loans, tick two of your repaid loans and click Prove two repaid loans. The card now reads Every collateral figure on this loan is a step lower: 105% ($21,000) verified, 130% ($26,000) standard. As the lender, the loan's public record shows 2 repaid loans proven on the Loan; root is in the directory, and the only offer it can make before a proof is Offer at 130% · 26,000. The proof's inputs stay private, but your repaid listings are public under your key: with only two on record, anyone can tell which loans they are (limits). The track record buys a lower figure; it is not a secret.
- Auditor check (Wave 3 preview). On the repaid loan: Open history to an auditor → Copy JSON. Switch to Auditor → paste → Verify against the chain: Verified, "3 payments, 1 late, $11,000 repaid". Change one amount in the JSON and verify again: Rejected. This is an integrity check, not a disclosure of secrets (why).
- On chain. Live chain shows the Preprod deployment: all 6 contracts and 37 transactions with their blocks, from the run's committed record. Preprod's indexer is served by Blockfrost, which needs a private key, so the page does not re-read the chain from your browser; CI re-read every claim and hash from Preprod (PROOF.md, on-chain evidence).
This exact path runs in CI as a Playwright test at desktop and phone width
(frontend/e2e/fast-path.spec.ts).
Before resubmitting we had the wave2 branch reviewed the way a judge would:
clone, build, follow this README literally, then try to break the headline
claim. Two rounds of review produced eight findings that changed the code.
Each one is listed with what was measured and what changed.
First review. Four findings.
| Finding | What was measured | Change | Status |
|---|---|---|---|
| The 150% bypass. The contract refused 150% for a verified borrower, but the lender could clear the tier first | In the console as it then shipped: after Verified · 110%, the lender clicked Replace quote, then Underwrite at 150% · 15,000, and the contract accepted it. quoteTerms reset the tier to NONE. Two more paths, confirmed against the compiled Loan: a quote expiring 2 s later let the tier lapse, and the lender could underwrite at 150% before the borrower proved anything. The loan went ACTIVE with no borrower action |
underwrite now records an offer (status OFFERED) at exactly the tier's collateral. The borrower's accept makes it ACTIVE; declineOffer returns it to APPLIED. quoteTerms is refused while a VERIFIED tier is live (a verified tier is live until it lapses) and needs a quote of at least 30 minutes (quote must hold at least 30 minutes). Tests: Loan — a lender cannot impose 150% on a verified borrower and Loan — borrower consent in loan.contract.test.ts |
Done |
| Unsalted facts commitment | persistentHash([balance, debts, income]) was public. A dictionary search recovered the demo facts after 3,973 hashes in 0.15 s (multiples of 100,000 up to 2,000,000), 193,826 hashes in 4.28 s (multiples of 50,000 up to 5,000,000) and 3,999,730 hashes in 84 s (multiples of 10,000 up to 2,000,000), on one core |
persistentHash([pad(32, "kymider:facts:v2"), H(balance, debts, income), salt]), with a 32-byte salt that stays in private state |
Done (privacy hardening) |
| Seed prefix in a log line | The wallet logged the first 8 characters of the master seed. Actions logs on a public repository are public | The line no longer logs any part of the seed (2bee4e6). The Preprod job had not run yet, so only devnet wallets had reached a log |
Done |
| Forgeable listings | LoanDirectory.updateStatus let either party set any status, so a borrower could mark their own listing REPAID. recordRepaid did not check the listing's status |
REPAID only through the lender's recordRepaid on an ACTIVE listing (only an active listing can be recorded as repaid); updateStatus refuses REPAID (a repayment is recorded with recordRepaid, not set); the borrower can only withdraw an OPEN listing. Tests in loanDirectory.contract.test.ts: nobody sets REPAID by hand: not the borrower, not the lender, the borrower may withdraw an open listing, and do nothing else, refuses a record for a listing that is not ACTIVE: never activated, or closed |
Done |
Second review (of the contracts after the first round's fixes). Four
findings changed the code, all in commit bce4745.
| Finding | What was measured | Change | Status |
|---|---|---|---|
| N1. Proof starvation. The lender could not impose 150% on a verified borrower, but could keep the borrower from ever becoming verified | Against the compiled Loan: underwrite at 150% was accepted while the quote was live and unanswered, and a proveTier is refused while an offer stands. The lender could also re-quote while the tier was NONE (each re-quote resets the proof), and offer with no quote at all. A borrower could decline every 150% offer, but not prove |
A quote stands until the borrower answers it or it lapses. underwrite refuses 150% in that window and quoteTerms refuses a re-quote (the borrower can prove until the quote lapses); underwrite refuses an offer before any quote (quote first). New borrower circuit waiveProof answers the quote without a proof (only the borrower may waive a proof), so a borrower who wants 150% gets the offer at once. Tests in Loan — the borrower's proof window: refuses 150% while the quote is live and unanswered, to its last second, refuses a re-quote while the quote is live and unanswered, without spending the cap, the judge's starvation sequence (E) now ends in 110%: neither a front-run nor a re-quote lands; Loan — waiving the proof (the 150% route, at once) |
Done |
| N2. Listing squat. A listing was keyed by the loan address alone | A stranger who listed someone else's Loan address first, naming a lender key they also held, took the slot: the real borrower's list was refused (loan is already listed), and the stranger's lender could mark it ACTIVE and record it repaid |
Listings are keyed by listingKey(loan, borrower) = persistentHash([pad(32, "kymider:listing:"), loan, borrower]), an exported pure helper; updateStatus and recordRepaid take that key. A stranger's listing sits in the stranger's slot. Readers (the console, LoanClient.listingMatchesLoan, verify:onchain) check each listing's borrower and lender against the Loan's own. Tests in LoanDirectory — a squatted listing (judge N2): does not block the real borrower: their listing has its own slot, the squatter's lender cannot move or record the real listing |
Done |
N3. DEFAULTED at will. The directory let the lender move any ACTIVE listing to DEFAULTED |
updateStatus allowed ACTIVE → DEFAULTED with no due date or grace period, which the directory cannot read |
updateStatus refuses DEFAULTED (a default is the Loan's own status (markDefault), not set here); the lender may only move an OPEN listing to ACTIVE or CLOSED. A default is read from the Loan, whose markDefault checks due date + 3 days; the console shows "defaulted (from the Loan)". Test: nobody marks a listing DEFAULTED: a default is the Loan's own status |
Done |
N5. Implicit consent. accept() took no argument |
The borrower's transaction did not name the figure it agreed to: it accepted whatever offer stood when it landed | accept(expectedCollateral) refuses any other figure (offer changed), so the transcript records the collateral the borrower consented to. Tests: the borrower names the figure they accept; any other is refused ("offer changed"), a consent given for one offer does not carry to the next: decline 150%, the lender re-offers, the old figure is refused |
Done |
The first review also found stale claims in this README and the submission
materials, and this revision corrects them: test counts, "the lender learns
one bit" (it is up to 3 answers per loan), an unsalted hash in the diagram, an
unbacked mutation-testing figure, and the auditor described as revealing
what the ledger cannot show when it checks integrity. The console's copy had
the same kind of error ("Only you see this list" on public listings); that
line is gone from frontend/src, and the copy was revised with the lifecycle
change.
Wave 1 shipped a solvency proof (SolvencyProof + Registry). Wave 2 turns it
into a loan with an enforced price. All of the following was built between
Sep 27 and Oct 17, 2026, on the wave2 branch, now merged to main.
| Wave 1 | Wave 2 | |
|---|---|---|
| Contracts | 2 (SolvencyProof, Registry), 9 circuits |
4: + Loan (11 circuits) and LoanDirectory (4 circuits); 24 circuits in total, plus 13 exported pure helpers |
| What a proof buys | A PASS/FAIL attestation | A collateral ratio: 110% vs 150%, enforced in-circuit with an exact-floor check, binding only on the borrower's acceptance. Two repaid loans proven on the loan lower both a step: 105% / 130% |
| Lifecycle | Claim → verdict | Apply → quote → prove tier (or waive the proof) → lender offers → borrower accepts the named figure → disburse → repay / default |
| Time | None | Block time: quote expiry, a minimum quote life, due dates, late flags, a 3-day grace period before default |
| History | None | Payment-history hash chain per loan; two-repaid-loans proof over a HistoricMerkleTree, in the directory and on the Loan itself (proveRepaidHistory), where it lowers the collateral |
| Offline tests | 54 in 3 files (main at Wave 1, fde02de) |
366 in 13 files |
| Browser tests | None | 48 Playwright tests (24 at 1440 px, 24 at 390 px), in CI |
| Devnet simulation | 11 cases, 2 wallets | 19 cases (+8 Wave 2 lifecycle), run in CI on every push |
| Client | KymiderClient |
+ LoanClient, npm run loan:deploy, npm run loan:demo, prove:onchain / verify:onchain |
| Console | Solvency screens | + loan screens for borrower and lender, portfolio, auditor preview, Live chain view |
| Chain | Local devnet only | Midnight Preprod (public testnet): the full flow, 37 transactions, every claim re-read (54/54) (PROOF.md); the same flow on a local devnet in CI on every push |
| AKINDO record | Repo not connected, product private, tagged "Base" | Pending, owner only: connect the repo, make the product public, tag Midnight |
What Wave 1 judges asked entries for, and where it is now (analysis): say exactly which parts need privacy (see the dual-ledger table); add audio to the video (the Wave 2 video is narrated); show a public-testnet transaction (37 on Preprod: PROOF.md).
Over-collateral is the price DeFi lending pays for knowing nothing about the borrower. A protocol cannot ask for bank statements, so it treats every borrower as the worst case and asks for 150% or more. A borrower with a clean balance sheet pays the same as one without.
The obvious fix, disclosing finances to the lender, is the one the setting cannot accept: it puts a full financial history on a public chain or in a counterparty's database. The lender does not even want the data. It wants one answer: does this borrower clear our bar?
Kymider gives the lender that answer, at most 3 times per loan, and fixes the price that follows from it. At 110% instead of 150%, the same collateral supports 36% more borrowing (1.5 / 1.1 = 1.36), or the same loan needs 27% less collateral (4,000 of 15,000).
sequenceDiagram
autonumber
participant B as Borrower (device)
participant SP as SolvencyProof<br/>(per borrower, Wave 1)
participant L as Loan<br/>(per loan, Wave 2)
participant D as LoanDirectory<br/>(shared)
participant K as Lender
B->>SP: updateFacts → commitment = persistentHash([pad("kymider:facts:v2"), H(balance, debts, income), salt])
B->>L: deploy(lender, terms, commitment)
B->>D: list(loan, lender, principal) → listings[listingKey(loan, borrower)]
K->>L: quoteTerms(netWorthFloor, maxDti, expiresAt)<br/>at most 3, at least 30 min; none while VERIFIED is live<br/>or while the last quote is live and unanswered
Note over B: facts and salt stay in private state
alt prove
B->>L: proveTier(facts as private inputs)
L-->>L: assert commitment matches<br/>tier = VERIFIED or STANDARD (disclosed)
else waive
B->>L: waiveProof() → quote answered, nothing revealed
end
K->>L: underwrite(collateral) = an offer<br/>(no 150% while the quote is live and unanswered)
L-->>L: assert collateral == floor(principal × 110% or 150%)
B->>L: accept(expectedCollateral) → ACTIVE (or declineOffer → APPLIED)
K->>L: disburse(now, owed, installment)
loop each installment
B->>L: repay(amount) → historyCommitment = hash(prev, amount, onTime, nonce)
end
Note over L: or markDefault after due + 3 days → DEFAULTED (on the Loan)
K->>D: recordRepaid(listingKey(loan, borrower)) on an ACTIVE listing → leaf in HistoricMerkleTree
B->>D: proveTwoRepaid(newLoan, private paths) → historyProofs[listingKey(newLoan, borrower)] = 2
B->>L: proveRepaidHistory(private paths) on the new loan → priorRepaidRoot published<br/>its ratios become 105% / 130%
Note over K,D: lender checks priorRepaidRoot with the directory's checkRoot (no cross-contract reads)
The full state machine is in contracts/loan.compact
(header comment) and docs/architecture-wave2.md.
The tier fixes the only collateral figure the lender can offer, but a figure
the lender proposes is not yet a loan. The borrower makes it binding with
accept(expectedCollateral), naming the figure; any other is refused
(offer changed).
A quote is a question to the borrower, and it stands until the borrower answers
it or it lapses (at least 30 minutes). While it is live and unanswered the
lender can neither offer 150% nor re-quote (the borrower can prove until the quote lapses), and there is no offer before the first quote (quote first).
The borrower answers with proveTier (VERIFIED or STANDARD) or with
waiveProof, which reveals nothing about the facts and opens the 150% offer at
once. While a VERIFIED tier is live the lender cannot re-quote it away, and the
only figure it can offer is 110%. Once that tier lapses with its quote, the
lender may offer 150%; that offer binds only if the borrower accepts it.
A borrower with two repaid Kymider loans proves them on the new Loan
(proveRepaidHistory): two Merkle paths to the directory's leaves
repaidLeaf(borrower, loan, lender), both leading to one root, which is the only
thing published. From then on underwrite takes 105% instead of 110% for a live
VERIFIED tier, and 130% instead of 150% otherwise.
The discount is in the contract's ratios, because the bar would not hold it. The
lender writes the bar in every quote. A rule like "history lowers the bar by
20%" would let the lender quote a bar 25% higher and cancel it. The ratios
are constants in loan.compact (verifiedHistoryRatioBps,
standardHistoryRatioBps), so once the record is proven, 110% is refused like
150% is for a verified borrower (collateral does not match the tier).
The Loan cannot read the directory, so it cannot tell a real root from a tree
the borrower built themselves. As with the facts commitment, the reader checks:
the lender runs the directory's checkRoot on the published root
(LoanClient.priorRepaidRootChecks; underwrite(loan, { directory }) refuses
to offer on a root that fails), and declines if it is not the directory's. The
proof is accepted once per loan, before it is ACTIVE. If an offer already
stands, the same call re-prices it in place to its tier's lower figure (the
borrower names that figure; any other is refused), so a lender cannot keep the
discount by offering first. Re-pricing only lowers the borrower's collateral;
the lender can still withdraw the offer, and an accepted figure never changes.
On Midnight, private state belongs to one contract instance. A shared loan
contract would have to hold every borrower's secrets, or none. One instance per
loan keeps each borrower's history seed and payment nonces scoped to that loan,
and lets every circuit check its caller against two keys stored at deploy
(borrower, lender). The shared LoanDirectory holds only public listings
and Merkle leaves.
Compact has no division operator. Collateral (principal × 1.1), amount owed
(principal × (1 + rate)) and installment (owed / n) are all quotients. The
client computes them in client/proof/loanMath.ts
and the circuit checks each one by cross-multiplication: isFloorOfBps accepts
value only if value × 10000 ≤ principal × ratio < (value + 1) × 10000, and
isCeilOfShare does the same for the ceiling. This is why a 150% ask on a
verified borrower is refused rather than tolerated: only the exact floor of
110% passes. tests/unit/loanMath.unit.test.ts
sweeps terms from a principal of 1 to 2^40 through both to prove they agree.
Each repayment folds (amount, on-time flag, nonce) into the public
historyCommitment. The nonce is hash("kymider:loan:nonce:", historySeed, previousHead) (contracts/witnesses.ts). Nothing
has to be stored per payment, a failed transaction cannot desynchronise private
state from the chain, and the borrower can rebuild every opening from one seed.
It is keyed to the chain head rather than paymentsMade because repay
increments the counter before the witness runs.
| Compact / Midnight feature | Where | What it does here |
|---|---|---|
| Witnesses | localSk, factsSalt, paymentNonce in loan.compact; implementations in witnesses.ts |
The caller's secret key, the facts salt and the derived payment nonce enter circuits from private state |
disclose() |
Every ledger write of a caller-supplied value, e.g. tier = disclose(qualified ? VERIFIED : STANDARD) |
Makes each disclosure explicit; the compiler rejects an undisclosed private value reaching the ledger |
persistentHash commitments |
commitFacts (salted), getDappPubKey, the history chain, repaidLeaf, listingKey |
Binds proofs to committed facts; derives identities; commits payment history; gives each borrower's listing its own slot |
HistoricMerkleTree + checkRoot |
LoanDirectory.repaid, proveTwoRepaid; merkleTreePathRoot in Loan.proveRepaidHistory |
Proves membership of two repaid-loan leaves against any past root, so later inserts do not invalidate a path. The Loan publishes the root its proof used; a reader checks it with checkRoot |
kernel.self() |
Loan.proveRepaidHistory |
A loan's own address cannot be one of the two records that lower its collateral |
| Block time | blockTimeLt/Lte/Gt/Gte in quoteTerms, underwrite, disburse, repay, markDefault |
Quote expiry and minimum quote life, tier expiry, on-time vs late, the grace period, a disburse start no more than 1 hour old |
Counter |
paymentsMade, latePayments, quotesIssued, listingCount |
Public counts the lender can rely on |
Map / Set |
listings, historyProofs, recordedLoans (directory); claims, authorizedLenders, openClaims (Wave 1) |
Indexes and one-shot guards (a loan is recorded once) |
| Enums and structs | LoanStatus, Tier, LoanTerms, Quote, Listing |
Typed public state |
| MidnightJS | client/loans.ts: deployContract, submitCallTx, indexer public data provider, LevelDB private-state provider |
Deploy, prove locally, submit, read state back |
compact-runtime in the browser |
frontend/src/lib/ |
The console runs the same compiled contracts client-side |
| Datum | Ledger | Who can see it |
|---|---|---|
| Balance, debts, income | Private (borrower's device); circuit inputs to proveTier only |
Borrower |
Facts commitment persistentHash([pad("kymider:facts:v2"), H(balance, debts, income), salt]) |
Public, and copied into every Loan opened with it |
Anyone; reveals nothing without the facts and the 32-byte salt (why the salt). The same value in several loans links them |
| Facts salt | Private (borrower's device) | Borrower |
| Borrower secret key, history seed, payment nonces | Private | Borrower |
| Borrower and lender public keys | Public | Anyone |
| Quote: net-worth floor, max DTI, expiry; quotes issued | Public | Anyone |
Tier (VERIFIED / STANDARD) and its expiry |
Public | Anyone. Each tier answers one yes/no question against a public bar; up to 3 per loan |
| Offer, acceptance, terms, collateral, balance owed, installment, due date | Public | Anyone. A lender must be able to enforce them |
Each repay amount, paymentsMade, latePayments, historyCommitment |
Public | Anyone. repay takes the amount as a disclosed input, and the late counter's change per transaction shows which payment was late |
| Listings: borrower key, lender key, principal, status | Public | Anyone. A key's REPAID listings can be counted |
| Which loans back a two-repaid-loans proof, their lenders and Merkle paths | Circuit inputs to proveTwoRepaid and proveRepaidHistory, never written to the ledger |
Not secret in practice: listings name the borrower's key, so with exactly two REPAID listings anyone can tell which two they are. Hidden only among a key's repaid listings when it has more than two |
The fact that an application carries a 2-loan history proof, and the directory root the Loan's proof used |
Public | Anyone. The root dates the proof to a directory state |
The privacy claim is narrow on purpose: the facts behind the tier are private. The loan itself, including every payment, is public, because a lender has to be able to enforce it, and so are a key's listings, which is why a track record is a priced credential rather than a secret (until per-loan keys, Wave 3).
Mapped to the criteria in hackathon/program.md.
- Compact contracts that compile: 4 contracts, 24 circuits plus 13
exported pure helpers, Compact language 0.23 / toolchain 0.31.1, compiled by
the
contractsjob in CI on every push; compiled modules are tracked incompiled/*/contract/. - Private-state management:
contracts/witnesses.ts(LoanPrivateState = { sk, historySeed, factsSalt }), scoped per contract address inclient/loans.ts. - Dual-ledger model: table above.
- Refusals as the product: the exact-floor collateral check, no re-quote
while a verified tier is live, no 150% offer or re-quote while the borrower
can still prove, acceptance that names the figure, block-time lateness, a
grace period enforced to the second, forged Merkle paths rejected by
checkRoot. - Repository and attribution: Apache-2.0,
midnightntwrktopic, wallet and provider wiring adapted frommidnightntwrk/example-battleship(credited in each file).
- 366 offline tests in 13 files that drive the
compiled contracts through simulators (
tests/unit/), including every refusal, time edge cases to the second, and a byte-for-byte rebuild of the history chain from the seed. - 48 Playwright tests on the production console build, 24 at 1440
and 24 at 390 px: this README's fast path end to end, each refusal, and every route
(
frontend/e2e/). - 19 devnet cases with real ZK proofs, two wallets, Midnight node + indexer
- proof server in Docker, on every push
(
tests/simulation/). First full green run: run 34, on commit5a56372, before the salted commitment and the consent step (scope).
- proof server in Docker, on every push
(
- One checkable number: 110% instead of 150%.
- Scope stated honestly: lifecycle demo, no token settlement, self-reported
facts, so a
VERIFIEDtier is free today (honest limits). - Roadmap tied to the limits: attested provenance and a lender allowlist close the two largest gaps (Wave 3).
- A console that runs the real contracts with no install, for borrower, lender and auditor.
- A Live view of the Preprod deployment (contracts and every transaction, from the committed record). Connecting Lace is built but untested on Preprod.
- Works at phone width; the Playwright suite checks every route at 390 px for horizontal scroll.
- Narrated video, about 2:50: being recorded, link added before Oct 17.
Script:
hackathon/wave2/VIDEO-SCRIPT.md. - Deck: slides on Pages and
PDF, 12 pages; source
hackathon/wave2/DECK.html.
- Who: DeFi lending protocols that want a lower-collateral tier without KYC; borrowers with balance sheets the protocol cannot currently see.
- Adoption path: one protocol adds a verified tier; the borrower generates one proof per loan; no regulatory change is needed. The tier is worth a lower price only once facts are attested (Wave 3); until then it proves the mechanism, not the borrower.
| Part | Status |
|---|---|
| Contracts, circuits, asserts | Real. Compiled by CI; the same modules run in tests, the console and the client |
| ZK proofs | Real on Midnight Preprod (37 transactions, PROOF.md) and on the local devnet in CI (simulation, prove:onchain). Not generated in the browser console: it executes circuits with compact-runtime and skips proving |
| Console chain | Simulated in-browser ledger with a controllable block clock. The Live view shows the Preprod deployment from its committed record (Blockfrost's indexer needs a private key, so it is not re-read in the browser) |
| Money | Simulated. Collateral, principal and balances are figures in contract state. No token is minted, locked or moved |
| Financial facts | Self-reported. The circuit proves arithmetic on committed figures; it cannot know they are true |
| Lace wallet | Live view only, untested on Preprod; the console's simulated mode needs no wallet |
-
Midnight Preprod (public testnet): run 38886842306 (commit
eb1a189) ran the full flow on Preprod in 37 transactions (blocks 3,005,897 to 3,006,086) andverify:onchainre-read every claim and every hash from Preprod: 54 of 54 checks PASS. Loan A was offered and accepted at 110% (1,100 on 1,000); Loan B, proof waived, at 150%; both repaid and recorded; Loan C proved those two repaid loans and was accepted at 105% (5,250 on 5,000), for example4308996e5c6343979c12c7cb9057a9ec3dd19e150b74afd7e1328c4f6763351b, the 105% offer, at block 3,006,081. The three refusals were refused on Preprod too. Every address and hash: PROOF.md; the same record for the console:preprod.json. Midnight's own Preprod indexer was shut down on 2026-10-09, so the run reads and writes through Blockfrost (a project key in the CI secretBLOCKFROST_PROJECT_ID, never written to the record). -
Every CI run (workflow, job "Devnet simulation") starts a local Midnight devnet in Docker, runs the 19 simulation cases with real ZK proofs, then
npm run prove:onchain(the full flow, every transaction recorded) andnpm run verify:onchain(re-reads each claim and transaction through the indexer). The record is uploaded as theproof-localartifact of that run. -
Recorded run: run 56 (commit
93cc853: round 5, a proven track record lowers the collateral, on the round 4 contracts with the proof window,waiveProof,accept(expectedCollateral)and listing keys) proved the full flow in 37 transactions andverify:onchainpassed 54 of 54 checks: Loan A offered and accepted at 110% (1,100 on 1,000), Loan B's proof waived and offered at 150% (3,000 on 2,000), both repaid and recorded. Loan C proved those two repaid loans in the directory and on the Loan itself (root checked against the directory), proved VERIFIED, and was offered and accepted at 105% (5,250 on 5,000). Three attempts were refused by the circuit before submission: on verified Loan A a raw 150% offer (collateral does not match the tier) and a re-quote (a verified tier is live until it lapses); on Loan C a 110% offer that ignored the track record (collateral does not match the tier). The read-back shows they left no trace. Every address and transaction hash is in DEVNET-PROOF.md, labelled as a local devnet. -
Reproduce it:
preprod.yml(Actions → "Preprod proof") runs the same proof with a funded wallet secret and a Blockfrost key, and commitsPROOF.mdand the deployments file. A fresh wallet spends about 1½ hours syncing Preprod's DUST history first.
| Attack | Defence | Evidence |
|---|---|---|
| Lender asks a verified borrower for 150% | underwrite accepts only floor(principal × ratio) for the live tier |
Loan — underwriting tests; e2e fast-path.spec.ts |
Lender re-quotes to wipe a VERIFIED tier, then asks 150% |
quoteTerms is refused while a VERIFIED tier is live: a verified tier is live until it lapses |
a live VERIFIED tier cannot be re-quoted away, up to its last second; re-quoting after a VERIFIED proof is refused, so the tier stands and 150% is refused |
| Lender quotes with a 2-second expiry so the tier lapses before underwriting | A quote must stand for at least 30 minutes: quote must hold at least 30 minutes |
refuses a quote that holds less than 30 minutes, to the second; a quote short enough to lapse before underwriting is refused |
| Lender offers 150% before the borrower can prove (front-running the proof) | While the quote is live and unanswered, underwrite refuses 150%: the borrower can prove until the quote lapses; before any quote, quote first |
refuses 150% while the quote is live and unanswered, to its last second; a 150% offer before the borrower can prove is refused: the proof lands, and 110% is the only offer; refuses any offer before a quote ("quote first"); e2e the lender cannot offer 150% during the borrower's proof window |
| Lender re-quotes while the borrower has not answered, to keep them from ever proving | quoteTerms is refused in the same window, and the refused call spends no quote |
refuses a re-quote while the quote is live and unanswered, without spending the cap; the judge's starvation sequence (E) now ends in 110%: neither a front-run nor a re-quote lands |
| Someone else waives the borrower's proof to open the 150% offer | waiveProof checks the caller against borrower: only the borrower may waive a proof; once per quote, and a waived quote cannot then be proven |
is the borrower's alone; needs a quote, once per quote, and only while the application is open; a waived quote cannot then be proven: one answer per quote |
| Borrower's acceptance applied to a figure they did not see | accept(expectedCollateral) refuses any figure but the standing offer: offer changed; the loan is ACTIVE only after it, and declineOffer refuses the offer |
the borrower names the figure they accept; any other is refused ("offer changed"); a consent given for one offer does not carry to the next: decline 150%, the lender re-offers, the old figure is refused; refuses a disbursement before the borrower accepts |
| Borrower proves with figures other than the committed ones | proveTier recomputes the salted commitment in-circuit |
refuses facts that do not match the commitment |
| Dictionary attack on the public facts commitment | 32-byte salt in private state (details) | privacy.contract.test.ts |
| Lender narrows net worth by re-quoting | At most 3 quotes per loan, one proof per quote | Loan — re-quote cap tests |
| Borrower reuses a tier after the quote lapses | Tier expires with the quote; the lender's only valid offer is then 150%, which the borrower may decline | a lapsed tier falls back to 150% |
| Borrower claims a late payment was on time | Lateness is blockTimeLte(nextDueAt), not an input |
a payment after its due date counts as late |
| Lender calls a default early | blockTimeGt(nextDueAt + 3 days) |
refuses a default within the grace period, up to its last second |
| Lender back- or forward-dates disbursement | Start must satisfy now ≤ block time < now + 1 h |
refuses a start time in the future or more than an hour old |
| Anyone acts in another's role | Every circuit checks getDappPubKey(localSk()) against borrower or lender |
auth-negative tests in every describe block |
| Leaking the facts through control flow | proveTier reads the quote before the private predicate (a ledger read inside a short-circuit && would leak) |
loan.compact comment; caught by the compiler |
Borrower marks their own listing REPAID |
REPAID only through the lender's recordRepaid on an ACTIVE listing; the borrower can only withdraw an OPEN listing |
nobody sets REPAID by hand: not the borrower, not the lender; the borrower may withdraw an open listing, and do nothing else; refuses a record for a listing that is not ACTIVE: never activated, or closed |
A stranger lists your Loan address first (listing squat) |
Listings are keyed by listingKey(loan, borrower): the stranger's listing sits under the stranger's key, and neither blocks nor touches yours. Readers check each listing's borrower and lender against the Loan's own |
LoanDirectory — a squatted listing (judge N2): does not block the real borrower: their listing has its own slot, the squatter's record neither occupies nor marks the real listing, the squatter's lender cannot move or record the real listing |
Lender marks a current borrower's listing DEFAULTED |
updateStatus refuses DEFAULTED: a default is the Loan's own status (markDefault), not set here. Readers take a default from the Loan, where markDefault needs due date + 3 days |
nobody marks a listing DEFAULTED: a default is the Loan's own status; console: "defaulted (from the Loan)" |
| Forged or borrowed repayment record | Leaf includes the borrower's key; checkRoot against the directory's root history; distinct loans; not the application itself |
LoanDirectory tests (forged path, someone else's record, same loan twice, self-vouching) |
Facts commitment from a different SolvencyProof |
No cross-contract reads on Midnight; the lender checks factsMatchSolvencyProof before quoting |
client/loans.ts |
| Wallet seed in public CI logs | No part of a seed is logged | commit 2bee4e6 |
Not defended (see honest limits): false facts,
self-lending to mint repayment records (including a stranger who lists your
loan address with a lender key they hold: the leaf names the stranger), a
lender marking a listing ACTIVE and recording a loan it never disbursed,
linkability of a borrower's key across listings, and public payment amounts.
Two leaks in the first version, both closed in the contracts:
| Leak | Before | After | Evidence |
|---|---|---|---|
| Dictionary attack on the public facts commitment | persistentHash([balance, debts, income]), unsalted. Trying round figures recovered the demo facts 1,000,000 / 300,000 / 1,000,000 on one core, in Node with the Compact runtime's own persistentHash: multiples of 100,000 up to 2,000,000 after 3,973 hashes in 0.15 s; multiples of 50,000 up to 5,000,000 after 193,826 hashes in 4.28 s; multiples of 10,000 up to 2,000,000 after 3,999,730 hashes in 84 s (about 45,000 hashes/s) |
persistentHash([pad(32, "kymider:facts:v2"), hash(facts), salt]) with a 32-byte random salt per commitment, held in private state and read through the factsSalt witness, never a transaction input. The same search now also has to guess 2^256 salts |
privacy.contract.test.ts: a grid search finds the facts without the salt and not with it; a wrong salt is refused (facts do not match committed facts); an all-zero salt is refused (facts salt must be set) |
| Re-quote binary search | A lender could re-quote a loan without limit; each quote plus tier proof answers one yes/no question about net worth against a bar the lender picks | At most 3 quotes per loan (quotesIssued, quote limit reached) and one proveTier per quote (tierProven, already proven against this quote): at most 3 answers, counted on the public ledger. Bisecting net worth over [0, 2^20) stops with 131,072 values still possible |
Loan — re-quote cap tests; the console shows "Quote n of 3" |
updateFacts draws a fresh salt every time, so a new commitment cannot be
linked to the old one by equality even when the figures are unchanged. A
Loan keeps the salt of the commitment it was opened with, so the lender's
binding check (factsMatchSolvencyProof) still compares two public hashes.
Each new loan opened against the same commitment allows up to 3 more answers.
The console shows the borrower whether the facts clear a bar before proving
("Preview on this device"), so the borrower can decline to prove; a failed
proof publishes STANDARD, and waiveProof answers the quote without
publishing any answer about the facts.
Not capped: Wave 1 SolvencyProof claims. A lender can re-request after each
decision, and every round needs a new proof from the borrower; the Wave 1
screens do not yet show a refusal for a cap, so it is left for Wave 3.
The Wave 3 preview lets a borrower export a loan's payment log (amounts,
on-time flags, nonces) and an auditor recompute the hash chain against the
loan's on-chain historyCommitment. A match proves the log is complete and
unaltered: nothing added, dropped, reordered or edited.
It does not reveal anything secret. Each repay amount is a disclosed input of a
public repay transaction, and the change in latePayments per transaction,
with block times, shows which payment was late. What the export adds is the
nonces, so the auditor can check the whole history against one hash without
running an indexer query per transaction. Private payment amounts would make it
a real selective disclosure; that needs token settlement and is not built.
Totals are from npm run test:unit and cd frontend && npm run e2e on this
branch: 366 offline tests in 13 files, and
48 browser tests (24 per width). Per-file counts: npx vitest run tests/unit --reporter=verbose.
| Suite | Drives |
|---|---|
loan.contract.test.ts |
Compiled Loan, own block clock: quotes, the proof window, tiers, waivers, offers, acceptance, repayment, default |
privacy.contract.test.ts |
Salted commitment, re-quote cap, the dictionary search with and without the salt |
loanDirectory.contract.test.ts |
Compiled LoanDirectory: listing keys, status rules, a squatted listing, records, proveTwoRepaid |
loanHistory.contract.test.ts |
Directory paths run through the compiled Loan: 105% / 130% once two repaid loans are proven, the lender cannot quote it away, no proof while an offer stands; one loan twice, a loan vouching for itself, another borrower's record, mixed roots, and a self-built tree that only the lender's root check catches |
loanMath.unit.test.ts |
Client arithmetic vs compiled Loan, principal 1 to 2^40 |
audit.unit.test.ts |
The auditor's off-chain chain rebuild, honest and tampered |
simulatedLoanDesk.unit.test.ts |
The console's loan desk on the real contracts |
chainReader.unit.test.ts |
The Live view's indexer decoding, fed state from the compiled contracts |
proof.unit.test.ts |
prove:onchain / verify:onchain: receipts, deployments file, PROOF.md rendering |
solvencyProof.contract.test.ts |
Compiled SolvencyProof (Wave 1) |
registry.contract.test.ts |
Compiled Registry (Wave 1) |
solvencyProof.unit.test.ts |
Reference circuit math (Wave 1) |
frontend/e2e/ |
Playwright: fast path, refusals, every route, Wave 1 screens; desktop 1440 and phone 390 |
wave1.simulation.test.ts |
Devnet, real proofs (CI), 11 cases |
wave2.simulation.test.ts |
Devnet, real proofs (CI), 8 cases |
CI (ci.yml) compiles the contracts, typechecks
and runs the unit tests, builds the console, runs the Playwright suite,
publishes the console to Pages (from main), and runs the Docker devnet
simulation followed by prove:onchain and verify:onchain against that
devnet. preprod.yml runs the same proof on
Preprod when dispatched with a funded wallet secret.
| Command | What it does |
|---|---|
npm run test:unit |
366 offline tests, no Docker |
npm run typecheck |
tsc --noEmit |
cd frontend && npm run e2e |
48 Playwright tests on the production build |
npm run build:contracts |
Compile all 4 contracts (Linux-only compiler) |
npm run compile:fast |
Same, skipping ZK key generation |
npm run env:up / env:down |
Start / stop the local devnet (node, indexer, proof server) |
npm run wait:dust |
Wait until the genesis wallet has DUST to pay for proofs |
npm run test:simulation |
Wave 1 + Wave 2 devnet simulation |
npm run deploy / npm run demo |
Wave 1: deploy + register; end-to-end solvency demo |
npm run loan:deploy |
Deploy the shared LoanDirectory, write .midnight-loans.json |
npm run loan:demo |
Open, quote, prove, offer, accept, disburse and repay one loan; from the third run, also prove two repaid loans |
npm run check-balance |
Print a wallet's balances |
npm run prove:onchain / npm run verify:onchain |
Run the flow on the configured network and record every transaction (devnet in CI, Preprod via preprod.yml, written to PROOF.md); re-read the record through the indexer. verify:preprod checks the Preprod record |
Console, locally (no Docker, no wallet):
git clone https://github.com/Turnless/kymider && cd kymider/frontend
npm install && npm run dev # http://localhost:3000Offline tests (Node 22+):
npm ci
npm run test:unitFull ZK flow (Docker and the Linux-only compact compiler):
npm run env:up # node, indexer, proof server
npm run build:contracts
npm run wait:dust
npm run test:simulation # Wave 1 + Wave 2, two wallets, real proofs
npm run loan:deploy && npm run loan:demoNetwork presets (local, preview, preprod) are in
client/config.ts; copy .env.example
to .env. Setup details and troubleshooting: docs/scaffold.md.
On Windows use npm.cmd. If you have neither Docker nor the compiler, CI runs
all of it on every push.
- Unaudited. No external review of the contracts or the client.
- Self-reported facts, so
VERIFIEDis free. Zero knowledge proves the arithmetic on committed figures, not that the figures are true. Anyone can commit a balance that clears any bar and get 110%. Until facts are attested, a lender has no economic reason to trust the tier; the demo shows the mechanism. Attested provenance (a data provider co-signs the facts) is the Wave 3 fix. - No token settlement in Wave 2. Collateral and repayments are figures in contract state. Real escrow is a post-Buildathon milestone.
- Payment amounts are public.
repaydiscloses each amount, and the late counter shows which payment was late. Only the facts are private. - The history proof hides little. Listings are public and name the
borrower's key, so an observer can list a key's
REPAIDlistings and recompute their leaves. With exactly two (the demo case, and Loan C on the devnet), that names both loans, both lenders and both principals; with more, it hides only which two of them. The facts commitment is also copied into each loan opened with it. Per-loan keys would close this; they are not built. - The lender still controls the price of its own loan. It picks the bar, can decline the application, and can let a quote lapse instead of re-quoting. Once a VERIFIED tier lapses with its quote (at least 30 minutes after the quote), the lender may offer 150%; that offer binds only if the borrower accepts it.
- A repayment record is only as good as its lender. The directory cannot
read the
Loan(no cross-contract reads), so a lender can still mark a listingACTIVEand record a loan it never disbursed as repaid. A borrower who lends to themselves from a second key can mint records. So can a stranger who lists your loan address with a lender key they hold, but the leaf names the stranger, not you: it is the same as self-lending. A lender allowlist is the Wave 3 fix. - A track record now has a price, and is cheap to mint. Two repaid loans
lower the collateral (105% / 130%), so a fake record is worth something. It
needs no
Loanat all: a borrower lists two made-up loan addresses naming a second key as lender, and that key marks themACTIVEand records them repaid: six directory transactions, and the root check passes. A lender can catch the crude version by reading the borrower'sREPAIDlistings (they are public) and checking each names a realLoanthat isREPAID; a self-lender who runs real loans gets past that too. The lender allowlist is the fix. Until then the discount is a mechanism, like aVERIFIEDtier on self-reported facts. - Off-chain root check. The
Loanaccepts any root its two paths lead to; only a reader'scheckRootagainst the directory tells a real one from a private tree.LoanClient.underwriterefuses to offer on a loan with a track record unless it is given the directory and the root checks; the console's Offer button is blocked when it does not. TheLoandoes not name a directory, so the check is only as good as the directory the lender trusts: a borrower's ownLoanDirectorypasses its owncheckRoot. - The records tree is finite.
HistoricMerkleTree<10>holds 1,024 records. Anyone can fill it with self-minted ones, after whichrecordRepaidfails for everyone. A deeper tree or the allowlist fixes it. - Up to 3 answers per loan. Each
proveTieranswers one comparison against a public quote; a loan caps this at 3 quotes and one proof per quote (privacy hardening). Each new loan against the same commitment allows 3 more. - Lender clock latitude.
disburseaccepts a start time up to 1 hour old, so the lender can pull every due date up to 1 hour earlier. - Off-chain facts binding. The check that a
Loan's commitment matches the borrower'sSolvencyProofruns in the client, not in a circuit. - Browser console skips proving. It executes the circuits; it does not generate proofs or use a wallet.
- The Live view does not re-read Preprod in the browser. Preprod's indexer is served by Blockfrost, which needs a private key; the page shows the run's committed record, which CI re-read from Preprod (on-chain evidence).
Wave 3 (Oct 27 – Nov 16): detail in docs/architecture-wave3.md.
- Attested data provenance. A data provider co-signs facts into private
state at ingestion, so
VERIFIEDstops being free; the same mechanism restricts repayment records to allowlisted lenders. - Auditor views. The preview already checks a payment history's integrity
offline (
/app/audit,contracts/audit.ts). Wave 3 adds a read-only regulator dashboard. - Bank channel. Signed, expirable attestations a bank verifies offline, without running a Midnight node.
- Per-loan borrower keys, so listings stop linking a borrower's loans and the history proof hides which loans, and how many.
contracts/ solvencyProof, registry (Wave 1); loan, loanDirectory (Wave 2);
witnesses.ts, index.ts, audit.ts
compiled/ compiler output; contract/ modules tracked, keys not
client/ KymiderClient (index.ts), LoanClient (loans.ts), CLIs, proof/loanMath.ts
scripts/ prove-onchain, verify-onchain, wait-for-dust
frontend/ React console; runs the compiled contracts in the browser; e2e/ Playwright
tests/unit/ offline contract tests + simulators
tests/simulation/ devnet simulations (CI)
docs/ architecture-wave1..3, scaffold
hackathon/ program rules, Wave 1 results, Wave 1 and Wave 2 submission material
Apache-2.0 (LICENSE). Wallet, provider and witness patterns are
adapted from midnightntwrk/example-battleship
(Apache-2.0), credited in the files that use them. Built with the
Midnight Compact toolchain and MidnightJS. Repo
topic: midnightntwrk.