惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

D
Docker
G
Google Developers Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
GbyAI
GbyAI
V
Vulnerabilities – Threatpost
Hugging Face - Blog
Hugging Face - Blog
I
Intezer
S
Securelist
Forbes - Security
Forbes - Security
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Jina AI
Jina AI
Y
Y Combinator Blog
N
News | PayPal Newsroom
S
Schneier on Security
O
OpenAI News
T
The Blog of Author Tim Ferriss
V
Visual Studio Blog
Simon Willison's Weblog
Simon Willison's Weblog
Martin Fowler
Martin Fowler
人人都是产品经理
人人都是产品经理
雷峰网
雷峰网
NISL@THU
NISL@THU
阮一峰的网络日志
阮一峰的网络日志
WordPress大学
WordPress大学
N
News and Events Feed by Topic
Microsoft Azure Blog
Microsoft Azure Blog
P
Proofpoint News Feed
The Cloudflare Blog
Last Week in AI
Last Week in AI
博客园 - 司徒正美
L
LangChain Blog
C
CERT Recently Published Vulnerability Notes
L
LINUX DO - 热门话题
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
aimingoo的专栏
aimingoo的专栏
Apple Machine Learning Research
Apple Machine Learning Research
Recent Commits to openclaw:main
Recent Commits to openclaw:main
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
The Hacker News
The Hacker News
博客园 - Franky
Attack and Defense Labs
Attack and Defense Labs
Security Latest
Security Latest
T
Tailwind CSS Blog
博客园_首页
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Microsoft Security Blog
Microsoft Security Blog
V2EX - 技术
V2EX - 技术
腾讯CDC
V
V2EX

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
# [Tutorial] Confidential Asset Management on Midnight
ayush singh · 2026-05-13 · via DEV Community

📁 Source: Midnight-dApps/confidential-asset-management

The TradFi-to-DeFi pipeline has been stuck on one specific question for half a decade: how do you give LPs the programmability and speed of public chains without doxxing the entire allocator book and giving away the GP's edge?

Tribute, Enzyme, dHedge, and every other on-chain fund I've looked at has the same gap. They publish the cap table, they publish the holdings, and increasingly they publish the strategy. That's fine for retail trying their luck with $100. It's a deal-breaker for a real $100M fund.

Midnight is the first chain where this trade-off actually splits the right way. AUM can be public. ROI can be public. Per-LP allocations stay private. Strategy stays off-chain. Auditors get verifiable counter values; LPs get the privacy they need to participate.

This dApp is a working demonstrator of that model. ~120 lines of Compact, ~600 lines of React, four roles wired in (GP, LP, auditor, observer). I'll walk through the contract piece by piece, then the frontend, then what's missing for production.

Use cases this could actually serve

  1. Family-office managed funds — single-family offices pool capital across cousins, in-laws, and siblings. Today these are private LP agreements with annual paper statements. Move them on-chain on Midnight and you keep the privacy from outsiders, give every family branch independent verifiability of pool performance, and settle distributions same-day instead of quarterly.

  2. Crypto-native hedge funds with TradFi LPs — a quant fund that takes capital from both crypto-native LPs and traditional allocators. The crypto LPs want on-chain transparency; the TradFi LPs require a privacy guarantee before they'll allocate. Today the fund has to pick one. With this contract, both can sit in the same vehicle.

  3. Tokenised emerging-markets credit funds — high-yield credit funds where the LP list itself is sensitive (sanctions exposure, reputation, jurisdictional issues). Aggregate AUM and yield can be public so the protocol stays auditable; per-LP positions are commitments in a Merkle tree.

Prerequisites

  • Node 20+
  • Lace or 1AM, on Midnight Preprod
  • tNIGHT + tDUST from the Preprod faucet
  • Docker
  • The Compact compiler
curl --proto '=https' --tlsv1.2 -LsSf \
  https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh

Enter fullscreen mode Exit fullscreen mode

Privacy/auditability — where the line sits

The interesting design decision in this dApp is which fields are public versus private. There's no single right answer; it depends on what the regulator needs to see, what the LPs want hidden, and what the GP needs to keep secret.

Here's how I drew the lines:

Field Public? Why
manager Yes Sealed at deploy; identifies who can act as GP
LP commitments Tree only Chain sees leaves, never wallets
LP allocation amount Public Added to aum; the chain needs to verify solvency
aum Yes Public AUM for compliance and LP comfort
reportedRoiBp Yes LP and regulator verifiability of performance
Per-LP holdings ratio Off-chain GP keeps these books
Strategy Off-chain The GP's edge
Payout amounts Public Solvency requires it
Payout recipients Private Same pattern as the dividend dApp

That last row is the key insight: the chain learns "an LP claimed 60,000 for period 2026-05" without learning which LP. The trade is "amount public, identity private" — the opposite of most on-chain funds today.

This is the opposite trade-off of every existing on-chain fund product. Tribute, Enzyme, et al. leak per-wallet holdings on a public chain. Off-chain funds leak nothing on-chain. Midnight finds the third option.

The contract

Full source: contracts/Contract.compact.

Ledger state

pragma language_version 0.22;
import CompactStandardLibrary;

export sealed ledger manager: Bytes<32>;

export ledger lpCommitments: HistoricMerkleTree<10, Bytes<32>>;
export ledger payoutNullifiers: Set<Bytes<32>>;

export ledger aum: Uint<64>;
export ledger reportedRoiBp: Uint<64>;

export ledger totalLps: Counter;
export ledger totalPayouts: Counter;

Enter fullscreen mode Exit fullscreen mode

The ledger has six pieces of state:

  • manager is the GP's public key, sealed at deploy. Every privileged circuit checks manager == publicKey(callerSk). There is no admin handover circuit — for a production system you'd add a two-step proposeNewManager + acceptManager flow with a time-lock.

  • lpCommitments is the LP cap table. Each leaf is H(lpSk, fundId). Depth 10 supports up to 1024 LPs, which is fine for any real fund (most institutional funds cap out at 200–300 LPs by SEC rules anyway).

  • payoutNullifiers is the anti-double-claim set. A nullifier is H(lpSk, fundId, period). Two claims with the same triple produce the same nullifier; the second one bounces.

  • aum is the public assets-under-management counter. Both admitLp (which increments) and redeemLp / claimPayout (which decrement) touch it.

  • reportedRoiBp is the per-period ROI in basis points. 1200 = +12.00%. Public so LPs can verify the GP is reporting what they claim, and so external observers can rank the fund's performance.

  • The two counters are public dashboard fodder.

Witnesses

witness localSecretKey(): Bytes<32>;
witness findLpPath(commit: Bytes<32>): MerkleTreePath<10, Bytes<32>>;

Enter fullscreen mode Exit fullscreen mode

Same pattern as the other two dApps in this repo: one witness for the caller's secret, one for the Merkle path. The TypeScript side that resolves them:

export const witnesses = {
  localSecretKey: ({ privateState }) => [privateState, privateState.secretKey],
  findLpPath: ({ privateState, ledger }, commit) => {
    const path = ledger.lpCommitments.findPathForLeaf(commit);
    if (!path) throw new Error('LP commitment not found in tree');
    return [privateState, path];
  },
};

Enter fullscreen mode Exit fullscreen mode

The Midnight runtime calls these during proof generation. The path becomes part of the ZK proof; the chain only verifies the resulting root match.

Constructor

constructor(managerSk: Bytes<32>) {
    manager = disclose(publicKey(managerSk));
}

circuit publicKey(sk: Bytes<32>): Bytes<32> {
    return persistentHash<Vector<2, Bytes<32>>>(
        [pad(32, "fund:pk:v1"), sk]
    );
}

Enter fullscreen mode Exit fullscreen mode

The deployer passes their secret key as a witness argument. We hash it (domain-separated with "fund:pk:v1" so it doesn't collide with public keys in other Midnight dApps using the same wallet) and seal the result into manager.

getLpCommitment(sk, fundId) — off-chain helper

circuit lpCommit(sk: Bytes<32>, fundId: Bytes<32>): Bytes<32> {
    return persistentHash<Vector<3, Bytes<32>>>(
        [pad(32, "fund:lp:v1"), fundId, sk]
    );
}

export circuit getLpCommitment(sk: Bytes<32>, fundId: Bytes<32>): Bytes<32> {
    return lpCommit(sk, fundId);
}

Enter fullscreen mode Exit fullscreen mode

lpCommit is the private helper. getLpCommitment is the public wrapper the frontend can call as a pure circuit — no on-chain tx, just a hash.

The same wallet always produces the same commitment for a given fund, which is what makes "lose your wallet seed, lose your LP identity" the security model. Same wallet across two funds produces different commitments because fundId is part of the hash, which is exactly what you want.

admitLp(commit, allocation) — GP only

export circuit admitLp(holderCommit: Bytes<32>, allocation: Uint<64>): [] {
    const sk = localSecretKey();
    assert(manager == disclose(publicKey(sk)), "Not the fund manager");
    lpCommitments.insert(disclose(holderCommit));
    aum = (aum + allocation) as Uint<64>;
    totalLps.increment(1);
}

Enter fullscreen mode Exit fullscreen mode

The GP onboards an LP by inserting their commitment and bumping the AUM. Three observations:

  1. The allocation is disclosed — it has to be, because we add it to the public aum. If we tried to keep allocations private, the AUM counter wouldn't be credible (the GP could lie about the sum).

  2. The chain learns the allocation but not who. It's "5 million was added by an LP whose commitment is somewhere in the new tree." Nobody can tell which leaf is the 5M vs the 50M.

  3. The off-chain bookkeeping is on the GP. They need to remember "commitment X is worth 5M, commitment Y is worth 50M" so they can compute payouts correctly. The contract has no way of enforcing that — it trusts the GP on per-LP weighting.

This is a deliberate trade-off. Putting per-LP allocations on-chain (even as commitments) would either reveal them or require a full SNARK over the sum, which gets expensive at scale.

reportRoi(roiBp) — GP only

export circuit reportRoi(roiBp: Uint<64>): [] {
    const sk = localSecretKey();
    assert(manager == disclose(publicKey(sk)), "Not the fund manager");
    reportedRoiBp = roiBp;
}

Enter fullscreen mode Exit fullscreen mode

Sets the public ROI in basis points. 1200 = +12.00%. Anyone can read this off the indexer.

The contract doesn't verify the ROI matches the AUM movement — that's by design. In a real fund, the GP signs off on a quarterly NAV from an admin (State Street, Northern Trust, et al.), and the on-chain ROI is the externally-attested number. You could build a full audit-chain on top (require a quarterly hash of the admin's report), but that's outside the MVP.

redeemLp(commit, allocation) — GP only

export circuit redeemLp(holderCommit: Bytes<32>, allocation: Uint<64>): [] {
    const sk = localSecretKey();
    assert(manager == disclose(publicKey(sk)), "Not the fund manager");
    assert(aum >= allocation, "Allocation exceeds AUM");
    aum = (aum - allocation) as Uint<64>;
}

Enter fullscreen mode Exit fullscreen mode

The GP can process an LP exit. Note we do not remove the commitment from the historic Merkle tree — that's because proofs against older roots are still valid in Compact's HistoricMerkleTree, so removing leaves would break already-issued proofs.

For a real partial-redemption flow you'd pair this with a per-LP redemption nullifier set, so the same LP can only redeem once per cycle.

proveLp(fundId) — LP, no-claim variant

export circuit proveLp(fundId: Bytes<32>): Boolean {
    const sk = localSecretKey();
    const commit = lpCommit(sk, fundId);
    const path = findLpPath(commit);
    assert(
        lpCommitments.checkRoot(disclose(merkleTreePathRoot<10, Bytes<32>>(path))),
        "Not an LP of this fund"
    );
    return disclose(true);
}

Enter fullscreen mode Exit fullscreen mode

A standalone "I'm an LP of this fund" proof, no payout side-effect. Useful for:

  • LP-only Discord channels that gate access on this proof
  • Compliance attestations ("I am a qualified investor in fund X")
  • Authentication flows that don't want passwords

The chain sees one tx; an observer learns "some LP of this fund authenticated." No way to map back to the wallet.

claimPayout(fundId, period, amount) — LP

export circuit claimPayout(fundId: Bytes<32>, period: Bytes<32>, amount: Uint<64>): Boolean {
    const sk = localSecretKey();
    const commit = lpCommit(sk, fundId);
    const path = findLpPath(commit);
    assert(
        lpCommitments.checkRoot(disclose(merkleTreePathRoot<10, Bytes<32>>(path))),
        "Not an LP of this fund"
    );

    const nul = payoutNullifier(sk, fundId, period);
    assert(!payoutNullifiers.member(disclose(nul)), "Payout already claimed for this period");
    assert(aum >= amount, "Payout exceeds AUM");

    payoutNullifiers.insert(disclose(nul));
    aum = (aum - amount) as Uint<64>;
    totalPayouts.increment(1);

    return disclose(true);
}

Enter fullscreen mode Exit fullscreen mode

This is the core LP action. Steps:

  1. Recompute the LP's commitment.
  2. Ask the witness for the Merkle path.
  3. Assert the path roots to a tree the contract has historically had.
  4. Compute the nullifier for (sk, fundId, period) and assert it hasn't been used.
  5. Assert solvency — the AUM has to be large enough for the requested amount.
  6. Side effects: insert the nullifier, deduct from AUM, bump payout counter.

The LP supplies amount themselves. They compute it off-chain by multiplying their private allocation by the public ROI. The contract doesn't enforce that the math is correct — it only checks solvency. In a real system you'd either:

  • Have the GP submit per-LP payouts (slower, more centralised)
  • Use a more sophisticated SNARK that proves amount == allocation * (1 + roi) without revealing allocation

Both have engineering cost. The MVP trusts the LP to compute their payout honestly, which works fine for small consortiums and breaks down for adversarial LPs in larger funds.

payoutNullifier(sk, fundId, period) — private helper

circuit payoutNullifier(sk: Bytes<32>, fundId: Bytes<32>, period: Bytes<32>): Bytes<32> {
    return persistentHash<Vector<4, Bytes<32>>>(
        [pad(32, "fund:nul:v1"), fundId, period, sk]
    );
}

Enter fullscreen mode Exit fullscreen mode

Four inputs in the hash: domain, fundId, period, secret. Different period → different nullifier → fresh claim allowed. Same period → same nullifier → claim rejected. The same LP can claim across funds without any cross-fund linkage because fundId is in the hash.

Frontend wiring

Same structural pattern as the other two dApps in this repo. The router has four protected routes:

<Routes>
  <Route path="/"       element={<HomePage />} />
  <Route path="/deploy" element={<RequireWallet><DeployPage /></RequireWallet>} />
  <Route path="/admit"  element={<RequireWallet><AdmitPage /></RequireWallet>} />
  <Route path="/report" element={<RequireWallet><ReportPage /></RequireWallet>} />
  <Route path="/payout" element={<RequireWallet><PayoutPage /></RequireWallet>} />
</Routes>

Enter fullscreen mode Exit fullscreen mode

RequireWallet redirects to / if the wallet isn't attached. No deep-linking around the wallet gate.

Wallet → role key

const APP_SALT = 'confidential-asset-management-v1';

export async function deriveRoleKey(shieldedCoinPublicKey, role) {
  const master = await deriveKeyFromPassword(APP_SALT, shieldedCoinPublicKey);
  return deriveKey(master, `fund:${role}`);
}

Enter fullscreen mode Exit fullscreen mode

Roles are 'lp' and 'manager'. The same wallet can act as both a GP of fund A and an LP of fund B — they get different role keys, derived deterministically from the same wallet seed. This is exactly what a family office that runs its own fund and allocates to others would need.

The provider bundle

src/lib/midnight.ts centralises the providers so the page files don't repeat themselves:

export async function buildProviders(opts) {
  const zkConfig = new FetchZkConfigProvider(`${CONTRACT_PATH}/managed/fund/keys`, fetch.bind(window));
  return {
    privateStateProvider: levelPrivateStateProvider({ privateStateStoreName: opts.privateStateStoreName }),
    publicDataProvider:   indexerPublicDataProvider(INDEXER_HTTP, INDEXER_WS),
    zkConfigProvider:     zkConfig,
    proofProvider:        httpClientProofProvider(PROOF_SERVER, zkConfig),
    walletProvider:       { /* getCoinPublicKey, balanceTx adapter */ },
    midnightProvider:     { /* submitTx adapter */ },
  };
}

export async function loadCompiledContract() {
  const contractPath = CONTRACT_PATH + '/managed/fund' + '/contract/index.js';
  const contractModule = await import(/* @vite-ignore */ contractPath);
  const cc = CompiledContract.make('fund', contractModule.Contract);
  const ccW = CompiledContract.withWitnesses(cc, witnesses);
  return {
    contractModule,
    finalContract: CompiledContract.withCompiledFileAssets(ccW, `${CONTRACT_PATH}/managed/fund`),
  };
}

Enter fullscreen mode Exit fullscreen mode

The string-concat for contractPath is on purpose — Vite 8's static import-analysis tries to resolve template literals at build time, which fails if the contract isn't compiled yet. String concatenation defeats that analysis and lets the import resolve at runtime. Same approach the upstream midnight-apps/fullstack-dapp uses.

Submitting a circuit call

Every action page (Admit, Report, Payout, Deploy) follows the same shape:

const providers = await buildProviders({ /* ... */ });
const { finalContract } = await loadCompiledContract();
const { findDeployedContract, createCircuitCallTxInterface } =
  await import('@midnight-ntwrk/midnight-js-contracts');

await findDeployedContract(providers, {
  contractAddress,
  compiledContract: finalContract,
  privateStateId: PRIVATE_STATE_ID,
  initialPrivateState: createFundPrivateState(managerSk),
});

const txInterface = createCircuitCallTxInterface(
  providers, finalContract, contractAddress, PRIVATE_STATE_ID,
);

await txInterface.admitLp(hexToUint8Array(holderCommit), BigInt(allocation));

Enter fullscreen mode Exit fullscreen mode

The lazy import of @midnight-ntwrk/midnight-js-contracts is significant — it's a ~1MB chunk and we don't need it until the user hits "Submit." Lazy-loading shaves the initial page-load by a few hundred ms.

Reading aggregate state

The Home page pulls the live AUM, ROI, LP count, and payout count straight from the indexer:

const provider = indexerPublicDataProvider(INDEXER_HTTP, INDEXER_WS);
const state = await provider.queryContractState(contractAddress);
const contractModule = await import('/src/contracts/managed/fund/contract/index.js');
const ledger = contractModule.ledger(state.data);

// ledger.totalLps         → bigint
// ledger.aum              → bigint
// ledger.reportedRoiBp    → bigint
// ledger.totalPayouts     → bigint

Enter fullscreen mode Exit fullscreen mode

For a production fund-admin dashboard you'd want an Express + Postgres polling cache in front of the indexer. The pattern is documented in the upstream fullstack-dapp tutorial. For an MVP, querying the indexer directly on page load is fine.

What an auditor sees

A regulator or LP auditor with the contract address gets:

  • manager = 0x... (sealed GP key)
  • totalLps = 14
  • totalPayouts = 13
  • aum = 67_250_000
  • reportedRoiBp = 1200 (i.e. +12.00%)

From these they can verify:

  • Every payout consumed exactly the declared amount from AUM
  • No nullifier was reused
  • Every claim came from a commitment in the tree
  • The number of LPs match the number of admitLp transactions in the indexer history

What they cannot see:

  • Which wallet owns which commitment
  • Per-LP allocation amounts (those exist only in the GP's books)
  • The fund's strategy or holdings
  • Any cross-period linkage of an individual LP's claims (different period → different nullifier hash)

This is the trade-off the dApp embodies in one paragraph. The chain proves process integrity and solvency; identities and strategies stay off the chain entirely.

Production extensions worth building

  • Per-LP allocation commitments. Replace the disclosed allocation with a Pedersen commitment, and have the AUM accumulate commitments rather than scalars. More private, much more expensive in ZK proving time.

  • Time-locked GP transfer. Two-step proposeNewManager + acceptManager with a block_height + 100 delay. Catches the "GP key compromised" case.

  • Performance fees with high-water mark. Have the GP commit to a high-water mark, and reject performance-fee claims if the current period's ROI doesn't exceed it. Useful for hedge-fund style 2-and-20 fee structures.

  • Multi-fund routing. Replace manager: Bytes<32> with Map<Bytes<32>, Bytes<32>> keyed by fundId. One deployed contract hosts many funds, each with its own manager.

  • Real settlement. Pair with a shielded token so payouts mint actual coin commitments to fresh recipient addresses, instead of just decrementing a counter.

Troubleshooting

  • Vite throws "Failed to resolve import .../fund/contract/index.js" — you haven't compiled. Run npx compact compile contracts/Contract.compact src/contracts/managed/fund. The path matters: the frontend imports from exactly there.

  • Not the fund manager on admitLp / reportRoi — your wallet is not the one that deployed. Switch wallets or redeploy.

  • Not an LP of this fund on claimPayout — the GP hasn't called admitLp for your commitment yet, or you computed the commitment with a different fundId. Double-check the fundId you're claiming on matches exactly what was used at admit time.

  • Payout already claimed for this period — the nullifier is working. Move to the next period or wait for the GP to declare a new one.

  • Payout exceeds AUM — either the LP is claiming more than the fund can pay, or the GP hasn't admitted enough capital to cover this period's payouts. Recheck the math.

  • Proof generation hangs — your local proof server died. Restart with docker run -p 6300:6300 midnightntwrk/proof-server:8.0.3 midnight-proof-server -v. First proofs after a server restart can take 30–60 seconds.

  • Wallet not detected — Lace and 1AM inject window.midnight asynchronously. The ConnectButton polls for up to 3 seconds. If still nothing, the button deep-links to lace.io.

  • BalanceCheckOverspend (error 138) — you're out of tDUST. Hit the Preprod faucet.

Repo and next steps

If you fork it and ship something interesting, I'd love to hear about it.