Loading...
Loading...
Build a time-lock vault dApp on Midnight Network — users lock unshielded NIGHT tokens in a Compact smart contract until a Unix timestamp, then the beneficiary releases them. Covers locker.compact (blockTimeGte unlock), Next.js frontend, 1AM wallet integration, low-level deploy/call, indexer polling, and ZK asset hosting. Use for token vesting, liquidity locks, escrow-style lockups, or any "lock until date" dApp on Midnight. Triggers: locker dApp, token lock, time vault, vesting schedule, lockup, release tokens, blockTime deadline, receiveUnshielded/sendUnshielded vault. Also use when debugging unlock timing, beneficiary auth, or extending the payment-dapp wallet/provider pattern.
npx skill4agent add kali-decoder/midnight-skills example-locker-dappunlockTimeUint<64>releasetemplates/locker-dapp/npm install && npm run compact && npm run devcontract/app/locker/lib/midnight.tsreferences/midnight-session.mdtemplates/locker-dapp/lib/midnight.tslib/locker.tslockTokensreleasepublic/zk/locker/references/midnight-session.mdcreateConnectedSessionreferences/gotchas.mdoffset: nullreferences/versions.json@midnight-ntwrk/*example-payment-dapp/1am-wallet/compact/blockTimeGteblockTimeLtdisclose()token-transfers/UserAddressindexer/blockTimeGte(unlockTime)Uint<64>Uint<16>createUnprovenDeployTxsubmitTxAsyncdeployContract()indexerPublicDataProviderqueryContractStateoffset: nullBigIntbeneficiaryKeylocker.compactcreateConnectedSessionreferences/midnight-session.mdlockTokens(amount, releaseTime, beneficiaryPkBytes)balanceunlockTimelockActiveblockTimeGte(unlockTime)release(recipient)locker-dapp/
├── package.json
├── next.config.mjs
├── lib/
│ ├── isomorphic-ws-fix.mjs
│ ├── midnight.ts # session, patched provider, hex helpers
│ └── locker.ts # deploy, lock, release, decode state
├── app/
│ ├── layout.tsx
│ └── locker/
│ └── LockerClient.tsx # all wallet + contract UI (client component)
├── contract/
│ ├── package.json
│ ├── src/
│ │ ├── locker.compact
│ │ ├── witnesses.ts
│ │ ├── index.ts # barrel → managed/locker
│ │ └── managed/locker/ # compiler output (gitignored)
│ └── tsconfig.json
└── public/zk/locker/ # synced keys + zkir (gitignored in dev, committed for deploy)node --version # 20+ for Next.js; 22+ if adding vitest contract tests
docker --version # optional local stack
# Compact compiler
curl --proto '=https' --tlsv1.2 -sSf \
https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh
source $HOME/.local/bin/envpreprodpackage.json{
"name": "locker-dapp",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --webpack",
"build": "npm run sync:assets && next build --webpack",
"compact": "npm run compact --prefix contract",
"sync:assets": "node scripts/sync-zk-assets.mjs",
"postinstall": "npm install --prefix contract"
},
"dependencies": {
"@midnight-ntwrk/compact-runtime": "0.16.0",
"@midnight-ntwrk/ledger-v8": "8.0.3",
"@midnight-ntwrk/midnight-js-contracts": "4.0.4",
"@midnight-ntwrk/midnight-js-fetch-zk-config-provider": "4.0.4",
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.4",
"@midnight-ntwrk/midnight-js-network-id": "4.0.4",
"@midnight-ntwrk/midnight-js-types": "4.0.4",
"@midnight-ntwrk/wallet-sdk-address-format": "3.1.0",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0"
}
}contract/package.json{
"name": "@locker/contract",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"compact": "compact compile src/locker.compact src/managed/locker"
},
"dependencies": {
"@midnight-ntwrk/compact-runtime": "0.16.0"
}
}npm run compact
# → contract/src/managed/locker/{contract,keys,zkir}/
npm run sync:assets
# → public/zk/locker/contract/src/locker.compactMap<Bytes<32>, LockRecord>pragma language_version >= 0.23;
import CompactStandardLibrary;
export ledger balance: Uint<128>;
export ledger unlockTime: Uint<64>;
export ledger beneficiary: Bytes<32>;
export ledger lockActive: Boolean;
export ledger totalLocked: Uint<128>;
export ledger totalReleased: Uint<128>;
witness beneficiaryKey(): Bytes<32>;
constructor() {
balance = 0;
unlockTime = 0;
beneficiary = default<Bytes<32>>;
lockActive = false;
totalLocked = 0;
totalReleased = 0;
}
// Deposit unshielded NIGHT and schedule release for beneficiaryPk at releaseTime (Unix seconds).
export circuit lockTokens(
amount: Uint<128>,
releaseTime: Uint<64>,
beneficiaryPk: Bytes<32>
): [] {
assert(!disclose(lockActive), "Vault already has an active lock");
assert(disclose(amount) > 0, "Amount must be positive");
receiveUnshielded(default<Bytes<32>>, disclose(amount));
beneficiary = disclose(beneficiaryPk);
unlockTime = disclose(releaseTime);
balance = disclose(amount);
lockActive = disclose(true);
totalLocked = disclose((totalLocked + amount) as Uint<128>);
}
// Beneficiary releases locked tokens after unlockTime to recipient (unshielded UserAddress).
export circuit release(recipient: UserAddress): [] {
assert(disclose(lockActive), "No active lock");
assert(blockTimeGte(disclose(unlockTime)), "Lock period not finished");
assert(deriveKey(beneficiaryKey()) == beneficiary, "Only beneficiary can release");
const amount = disclose(balance);
assert(amount > 0, "Nothing to release");
sendUnshielded(
default<Bytes<32>>,
amount,
right<ContractAddress, UserAddress>(disclose(recipient))
);
totalReleased = disclose((totalReleased + amount) as Uint<128>);
balance = 0;
lockActive = false;
}
pure circuit deriveKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "locker:beneficiary:v1"), sk]);
}blockTimeGte(t)block_time >= tUint<64>block_timecontract/src/witnesses.tsimport type { WitnessContext } from '@midnight-ntwrk/compact-runtime';
export type LockerPrivateState = {
beneficiarySecretKey: Uint8Array;
};
export const witnesses = {
beneficiaryKey: (context: WitnessContext<LockerPrivateState>) =>
[context.privateState, context.privateState.beneficiarySecretKey] as const,
};beneficiarycontract/src/index.tsimport { CompiledContract } from '@midnight-ntwrk/compact-runtime';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { witnesses } from './witnesses.js';
export {
Contract,
ledger,
pureCircuits,
type Ledger,
} from './managed/locker/contract/index.js';
import { Contract } from './managed/locker/contract/index.js';
const currentDir = path.dirname(fileURLToPath(import.meta.url));
export const zkConfigPath = path.resolve(currentDir, 'managed', 'locker');
export const CompiledLockerContract = CompiledContract.make('locker', Contract).pipe(
CompiledContract.withWitnesses(witnesses),
CompiledContract.withCompiledFileAssets(zkConfigPath),
);example-payment-dapp/SKILL.mdConnectedSessioncreateConnectedSession/zk/locker/coinPublicKeyToBytescreatePatchedPublicDataProvidercreatePrivateStateProviderZK_ASSET_PATHFetchZkConfigProvider/zk/lockersetNetworkId(config.networkId)api.getConfiguration()const wallet =
(window as any).midnight?.['1am'] ??
Object.values((window as any).midnight ?? {})[0];Object.values(window.midnight)react-wallet-connector/lib/locker.tsimport { createUnprovenDeployTx, submitCallTxAsync } from '@midnight-ntwrk/midnight-js-contracts';
import { ContractState, sampleSigningKey } from '@midnight-ntwrk/compact-runtime';
import { CompiledLockerContract, Contract, ledger } from '../contract/src/index.js';
import type { ConnectedSession } from './midnight.js';
import { fromHex, pollForState } from './midnight.js';
const PRIVATE_STATE_ID = 'LockerPrivateState';
const ZK_ASSET_PATH = '/zk/locker';
function makeCompiledContract() {
return CompiledLockerContract as any;
}
export async function deployLocker(
session: ConnectedSession,
beneficiarySecretKey: Uint8Array,
): Promise<string> {
const initialPrivateState = { beneficiarySecretKey };
const deployTxData = await (createUnprovenDeployTx as any)(
{
zkConfigProvider: session.providers.zkConfigProvider,
walletProvider: session.providers.walletProvider,
},
{
compiledContract: makeCompiledContract(),
args: [],
privateStateId: PRIVATE_STATE_ID,
initialPrivateState,
signingKey: sampleSigningKey(),
},
);
const contractAddress = deployTxData.public.contractAddress;
await (submitTxAsync as any)(session.providers, {
unprovenTx: deployTxData.private.unprovenTx,
});
await session.providers.privateStateProvider.setContractAddress(contractAddress);
await session.providers.privateStateProvider.set(PRIVATE_STATE_ID, initialPrivateState);
await session.providers.privateStateProvider.setSigningKey(
contractAddress,
deployTxData.private.signingKey,
);
return contractAddress;
}
export async function lockTokens(
session: ConnectedSession,
contractAddress: string,
amount: bigint,
releaseTimeUnix: bigint,
beneficiaryPkBytes: Uint8Array,
) {
await (submitCallTxAsync as any)(session.providers, {
compiledContract: makeCompiledContract(),
contractAddress,
circuitId: 'lockTokens',
args: [amount, releaseTimeUnix, { bytes: beneficiaryPkBytes }],
privateStateId: PRIVATE_STATE_ID,
});
}
export async function releaseTokens(
session: ConnectedSession,
contractAddress: string,
recipientBytes: Uint8Array,
) {
await (submitCallTxAsync as any)(session.providers, {
compiledContract: makeCompiledContract(),
contractAddress,
circuitId: 'release',
args: [{ bytes: recipientBytes }],
privateStateId: PRIVATE_STATE_ID,
});
}
export function decodeLockerState(stateHex: string) {
const contractState = ContractState.deserialize(fromHex(stateHex));
const l = ledger(contractState.data);
return {
balance: l.balance as unknown as bigint,
unlockTime: l.unlockTime as unknown as bigint,
beneficiary: l.beneficiary as unknown as Uint8Array,
lockActive: l.lockActive as unknown as boolean,
totalLocked: l.totalLocked as unknown as bigint,
totalReleased: l.totalReleased as unknown as bigint,
};
}
export async function fetchLockerState(queryUrl: string, contractAddress: string) {
const hex = await pollForState(queryUrl, contractAddress);
return decodeLockerState(hex);
}app/locker/LockerClient.tsx| State | UI |
|---|---|
| Wallet disconnected | Connect button |
| Connected, no contract | Deploy locker |
| Deployed, no lock | Lock form (amount, unlock datetime, beneficiary = self) |
| Active lock, before unlock | Show balance, unlock date, countdown, disabled Release |
| Active lock, after unlock | Enabled Release → recipient = own unshielded address bytes |
| Released | Show totals, offer new lock (deploy new contract or extend contract) |
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
deployLocker,
fetchLockerState,
lockTokens,
releaseTokens,
} from '@/lib/locker';
const STARS_PER_NIGHT = 1_000_000n;
export default function LockerClient() {
const [session, setSession] = useState<ConnectedSession | null>(null);
const [contractAddress, setContractAddress] = useState<string | null>(null);
const [ledger, setLedger] = useState<LockerLedgerView | null>(null);
const [amountNight, setAmountNight] = useState('1');
const [unlockAt, setUnlockAt] = useState(''); // datetime-local input
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const nowUnix = BigInt(Math.floor(Date.now() / 1000));
const canRelease = ledger?.lockActive && ledger.unlockTime <= nowUnix;
const refresh = useCallback(async () => {
if (!session || !contractAddress) return;
const state = await fetchLockerState(session.config.indexerUri, contractAddress);
setLedger(state);
}, [session, contractAddress]);
useEffect(() => { void refresh(); }, [refresh]);
async function onConnect() {
setError(null);
const wallet =
(window as any).midnight?.['1am'] ??
Object.values((window as any).midnight ?? {})[0];
if (!wallet) throw new Error('Install a Midnight wallet extension');
const api = await wallet.connect('preprod');
const { createConnectedSession } = await import('@/lib/midnight');
setSession(await createConnectedSession(api, '/zk/locker'));
}
async function onDeploy() {
if (!session) return;
setBusy(true);
setError(null);
try {
const beneficiarySecretKey = session.coinPublicKeyBytes; // lock to self
const addr = await deployLocker(session, beneficiarySecretKey);
setContractAddress(addr);
await refresh();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
async function onLock() {
if (!session || !contractAddress) return;
setBusy(true);
setError(null);
try {
const amount = BigInt(amountNight) * STARS_PER_NIGHT;
const releaseTime = BigInt(Math.floor(new Date(unlockAt).getTime() / 1000));
await lockTokens(
session,
contractAddress,
amount,
releaseTime,
session.coinPublicKeyBytes,
);
await refresh();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
async function onRelease() {
if (!session || !contractAddress) return;
setBusy(true);
setError(null);
try {
const recipientBytes = unshieldedAddressToBytes(session.unshieldedAddress);
await releaseTokens(session, contractAddress, recipientBytes);
await refresh();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
return (
<div>
<h1>Token Locker</h1>
{!session ? (
<button type="button" onClick={onConnect} disabled={busy}>Connect Wallet</button>
) : (
<>
<p>Connected: {session.unshieldedAddress}</p>
{!contractAddress ? (
<button type="button" onClick={onDeploy} disabled={busy}>Deploy Locker</button>
) : (
<>
<p>Contract: {contractAddress}</p>
{ledger?.lockActive ? (
<>
<p>Locked: {formatStars(ledger.balance)} Stars</p>
<p>Unlocks: {new Date(Number(ledger.unlockTime) * 1000).toLocaleString()}</p>
<button type="button" onClick={onRelease} disabled={busy || !canRelease}>
Release Tokens
</button>
</>
) : (
<>
<label>
Amount (NIGHT)
<input value={amountNight} onChange={(e) => setAmountNight(e.target.value)} />
</label>
<label>
Unlock at
<input type="datetime-local" value={unlockAt} onChange={(e) => setUnlockAt(e.target.value)} />
</label>
<button type="button" onClick={onLock} disabled={busy}>Lock Tokens</button>
</>
)}
</>
)}
</>
)}
{error ? <p role="alert">{error}</p> : null}
</div>
);
}
function formatStars(n: bigint) {
return n.toString();
}
// Implement: unshieldedAddressToBytes — decode mn_addr_* to 32-byte recipient for circuits
// Never use encodeUserAddress with raw coin pk; use bytes helper from 1am-wallet skillreact-wallet-connector/scripts/sync-zk-assets.mjsimport { cpSync, mkdirSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const src = join(root, 'contract/src/managed/locker');
const dest = join(root, 'public/zk/locker');
rmSync(dest, { recursive: true, force: true });
mkdirSync(dest, { recursive: true });
for (const dir of ['keys', 'zkir']) {
cpSync(join(src, dir), join(dest, dir), { recursive: true });
}http://localhost:3000/zk/locker/keys/lockTokens.proverexample-payment-dapp/SKILL.mdlib/isomorphic-ws-fix.mjsnext.config.mjsasyncWebAssemblytopLevelAwaitisomorphic-wsnext dev --webpacknext build --webpack1. npm install && npm run compact && npm run sync:assets
2. npm run dev
3. Connect 1AM wallet (preprod, funded with tNIGHT)
4. Deploy locker contract
5. Lock 1 NIGHT until chosen datetime
6. UI shows locked balance + unlock time (poll indexer every 2s after tx)
7. Wait until unlock (or advance clock on local undeployed only)
8. Beneficiary clicks Release → tokens sent to unshielded address| Symptom | Cause | Fix |
|---|---|---|
| | Wait until on-chain time ≥ |
| Witness key ≠ ledger | Private state |
| Single-lock contract | Release first, or deploy another contract instance |
| Wrong integer width | Use |
| Deploy hangs 30–120s | Used | Use |
GraphQL | Default indexer provider | Use patched |
| ZK 404 | Assets not synced | |
| Wrong address encoding for recipient | Pass |
| Release button works before time in UI | UI-only check | Contract still rejects — fix UX but trust on-chain |
locker.compactlockTokensreleaseblockTimeGtepublic/zk/locker/createConnectedSessionsubmitCallTxAsyncbeneficiarySecretKeycoinPublicKeyBytesledger(contractState.data)ContractStateBigInt--webpack| Use case | How this template maps |
|---|---|
| Fixed deposit | User locks until |
| Token vesting | Deploy one locker per tranche, or extend with |
| Liquidity lock | Lock LP amount until |
| Escrow | Set beneficiary to counterparty pk; release after deadline when conditions met off-chain |
| NFT lock | Not covered here — use |
// After lockTokens tx — poll until balance > 0
const state = await fetchLockerState(indexerUrl, contractAddress);
console.log({
locked: state.lockActive,
amount: state.balance,
unlockAt: new Date(Number(state.unlockTime) * 1000).toISOString(),
});MaplockActivestruct LockRecord {
amount: Uint<128>,
unlockTime: Uint<64>,
beneficiary: Bytes<32>,
active: Boolean,
}
export ledger locks: Map<Bytes<32>, LockRecord>;
export ledger nextLockId: Counter;lockIdreleaselockTokensunlockTimevesting.compactList<VestTranche>claimTranche(index)blockTimeGte(tranche.unlockTime)react-wallet-connector/references/midnight-session.mdreferences/gotchas.md| Symptom | Fix |
|---|---|
| Use |
| Release before deadline | |
| Wrong beneficiary on release | Witness |
| Deploy hangs on preprod | Use |
| Next step | Skill |
|---|---|
| Wallet connect only | |
| Payment vault (no timelock) | |
| Full CLI + tests reference | |
| NFT locking | |
| Privacy audit | |