Apache-2.0. Built for Midnight Wave Hacks.
The missing on-ramp for AI agents on Midnight: policy-gated, audit-logged unshielded signing, plus an on-chain zero-knowledge proof that the audit log extends correctly from its last anchor.
TEE co-location is in progress. This repo does not claim TEE-backed signing — the sidecar does not yet run inside an attested enclave.
| Track | What it does | Status |
|---|---|---|
| A — Intents signing | Unshielded NIGHT transfers on Preprod through 1Claw's Intents API: guardrails, spend caps, hash-chained audit log. The transfer itself proves nothing, but its fee is a DUST spend, so the proof server is on the signing path. | Live on Preprod — builds, signs and broadcasts; 63 tests |
| B — Audit anchor | A Compact contract that anchors 1Claw's audit chain head on Midnight, proving correct extension without revealing events, the head, or the agent's identity. | Deployed and anchored on Preprod — viewer shows a live commitment at epoch 2; 12 simulator tests |
The tracks are independent in Wave 1: the signer never calls the contract.
A naive anchor ("I know the preimage of a hash I chose") proves nothing — the caller picks both sides, and anyone could squat any agent id. Here the chain relation lives inside the circuit:
assert ownerTag(secretKey) == owners[agentCommitment] // caller owns this agent
assert headTag(prevHead) == commitments[agentCommitment] // prevHead is the anchored one
head := fold(prevHead, newEventHashes[0..count]) // folded in circuit
commitments[agentCommitment] := headTag(head); epochs++
The commitment can therefore only advance by a correct extension of the real chain, by its real owner.
Three things stay private: the audit events, the chain head, and the agent's
identity — map keys are persistentHash(agentId ‖ registrationSalt), and the
salt never leaves the 1Claw vault.
DEMO.md — a 3-minute run sheet with reproducible steps.
Live viewer: https://1claw-anchor-viewer.vercel.app — reads the anchored ledger, and verifies a fold offline using the contract's own circuit even before a contract address is configured.
Deployed contract (Preprod):
ba10cd4ac487b7a470f00ab6509295ea0673cdc5a26a866948c7bc2657fc2c86
Preprod signing transaction:
47f1556d605be39b3e3c6b9c26312096e56a2c179c8b868ab45534fce92ad66e
— block 2332316, 2026-08-30 16:14:30 UTC.
That is the deployment itself, which is a signed transaction and not a separate demo one: the deployer's unshielded key signed it, the local proof server proved the circuit, and the network accepted it. Verify either against the public indexer without any local setup:
curl -s -X POST https://indexer.preprod.midnight.network/api/v4/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ contractAction(address: \"ba10cd4ac487b7a470f00ab6509295ea0673cdc5a26a866948c7bc2657fc2c86\") { __typename address transaction { hash block { height timestamp } } } }"}'__typename comes back as ContractDeploy, and the hash matches the one above.
Preprod broadcast through the signer sidecar:
82e641fbad9dd568e6beacd004c6c536f08013414448da631fc100352d659286
— block 2344291, 2026-08-31 12:12:00 UTC.
That one is a plain unshielded transfer built, signed and submitted by
POST /v1/build-and-sign — the sidecar's own path, not the deploy script's —
and it is the end-to-end proof of the WalletFacade migration: the Zswap-only
wallet could not assemble a fee-paying transaction at all. submitTransaction
returns a transaction identifier
(0091521a0164efb772bc23e23c0f6325aee56063ab281c7a6e6f4344aa09c178a4), which
is what the indexer takes as offset: {identifier: ...}; the hash above is
what it returns.
curl -s -X POST https://indexer.preprod.midnight.network/api/v4/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ transactions(offset: {identifier: \"0091521a0164efb772bc23e23c0f6325aee56063ab281c7a6e6f4344aa09c178a4\"}) { hash block { height timestamp } unshieldedCreatedOutputs { owner value } dustLedgerEvents { __typename } } }"}'The outputs come back as 1000000 to the recipient plus 4999000000 change —
5,000 NIGHT preserved, since it is a self-transfer — and the
DustSpendProcessed event is the fee being paid in DUST rather than NIGHT.
Anchored audit chain (Preprod): the contract is not just deployed, it holds
a real commitment. demo-agent was registered and then extended by a batch of
three events:
anchorInitial— tx00020462a64410d5a3c60935be7724a2e00fd2424bdecaf9fa0de55b65e3de92ebanchorExtend— tx0032e0b69910129aa00f7403dfde75ee532b38a0ddd3873a48c5ee7f479202796f
The viewer now renders one row — commitment 447a28285208d82c…, epoch 2,
owner tag 89e838ebee93ce63…. What is not there is the point: no events, no
chain head, and nothing identifying the agent. Both writes are made by
scripts/anchor.ts:
npx tsx scripts/anchor.ts --agent demo-agent # first run: registers
npx tsx scripts/anchor.ts --agent demo-agent --events 3 # after: folds a batchThe agent commitment is derived with the contract's own compiled foldStep
rather than a second hand-written persistentHash encoding, for the same
reason the viewer decodes with the compiled ledger(): two encodings of one
relation drift, and the contract is the definition.
For the threat model, why the obvious designs fail, what the proof does not claim, and the privacy analysis, see WHITEPAPER.md.
git clone https://github.com/1clawAI/midnight.git && cd midnight
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh
export PATH="$HOME/.local/bin:$PATH"
compact update 0.34 # ledger 9 — see version pinning below
cd contracts/audit-anchor
npm install
npm run ci # compile → typecheck → 12 simulator testsnpm run sync-wallets prints two addresses per wallet. They are not
interchangeable:
| Address | Looks like | Use |
|---|---|---|
| Unshielded | mn_addr_preprod1… |
the faucet — it rejects shielded addresses |
| Shielded | mn_shield-addr_test1… |
what the wallet SDK reports and transacts with |
Only the shielded one appears in WalletState; the unshielded key lives on the
HD tree under Roles.NightExternal and is bech32m-encoded separately
(scripts/unshielded-address.ts). Note the HRP is preprod even though Preprod
runs under the TestNet network id — the two are encoded independently.
-
Request tNIGHT at https://faucet.preprod.midnight.network/ using the unshielded address. Rate limited; ~1000 tNIGHT per request.
-
Register the NIGHT for DUST generation. Holding NIGHT alone produces nothing, and DUST — not NIGHT — is what pays fees. This is the step that catches people out: a wallet looks funded and still cannot transact.
The faucet hands out unregistered NIGHT. Every unshielded UTXO carries a
registeredForDustGenerationflag, and on a fresh faucet grant it isfalse. Check yours:npx tsx scripts/check-dust-registration.tsRegistration is backed by a Cardano UTXO — the indexer's
DustRegistrationkeys onutxoTxHash/utxoOutputIndex, both documented as Cardano fields, anddustGenerationStatustakes Cardano reward addresses and rejects a Midnight one. Nothing in this repo can perform it, and neither the Midnight faucet page nor the installed wallet SDK exposes it. -
Once
check-dust-registrationreports generating, runscripts/watch-dust-and-deploy.sh— it waits for DUST, deploys the anchor, and publishes the viewer.
npm --workspace @1claw/midnight-signer run start then answers /v1/dry-run
with exactly which of those three is missing.
The signer runs a full WalletFacade (shielded + unshielded + dust), because
fees are paid in DUST and the Zswap-only wallet has no DustWallet to see it. A
cold Preprod sync is ~1.47M indices at roughly 300/s — about 80 minutes —
so the wallet is checkpointed to .sync-checkpoints/<seed-hash>.json and
resumes from there in well under a minute. The directory is gitignored; each
checkpoint is ~11MB, and losing one costs time, not funds.
Two states are never written, because persisting either brings the wallet back
reporting dust: 0 permanently — the coin is not rescanned and the reserved
spend never clears:
- a wallet that has not caught up, and
- a wallet that has built a transaction, broadcast or not.
That second one is why /v1/build-and-sign with broadcast: false is not free
of side effects: building the recipe reserves the dust coin, so the wallet
reports dust: 0 and cannot build another until the reservation clears.
DUST pays fees, and faucet NIGHT does not generate it. Every unshielded UTXO
carries registeredForDustGeneration, and a fresh faucet grant has it false.
A wallet can look funded and be unable to transact.
npx tsx scripts/check-dust-registration.ts # is this NIGHT generating?
npx tsx scripts/register-dust.ts # register it (proof server on :6300)
npx tsx scripts/check-dust-registration.ts # expect: generatingregister-dust.ts builds the wallet facade, waits for sync, selects the
unregistered coins and submits registerNightUtxosForDustGeneration →
finalizeRecipe → submitTransaction — the same transaction Lace's
Generate tDUST button sends, from a seed you control, so the wallet that
ends up able to pay fees is also the one deploy-anchor.sh drives.
The first registration pays for itself. A wallet with zero DUST can still submit its own registration: NIGHT accrues DUST retroactively from the UTXO's creation time, so the fee is covered by generation that has already happened. There is no airdrop step and no chicken-and-egg. Verified on Preprod — a wallet holding 5,000 NIGHT and no DUST registered itself and came back with 356 DUST.
The sync is the slow part: allow roughly an hour on a cold wallet. Both scripts print progress every 20 seconds, so a stall is visible rather than looking like patience.
The registration API is in a different npm scope.
@midnightntwrk/wallet-sdk — no hyphen — is not the same package as
@midnight-ntwrk/wallet. The DUST surface lives only in the former. We searched
the hyphenated scope, found nothing, and concluded no registration API existed
anywhere. It does. Every other item here is downstream of that one character.
Do not hand-roll the unshielded address. We derived the key correctly under
Roles.NightExternal and then bech32m-encoded those bytes directly. The key
bytes matched the SDK exactly; the address did not. We faucet-funded an address
the SDK never derives, then spent a long time concluding the toolchain and Lace
disagreed about derivation — when only our own encoder did. Use
createKeystore(key, networkId).getBech32Address(), which is what both the SDK
and Lace use.
WalletBuilder cannot pay a fee. The builder in @midnight-ntwrk/wallet is
Zswap-only: it reports coins: 0 for a wallet holding unshielded NIGHT, and
then fails with expected instance of LedgerParameters because it cannot
assemble a fee-paying transaction without the DUST wallet. That error names the
symptom, not the cause. Use WalletFacade, which carries all three wallets —
shielded, unshielded and dust. It balances into a recipe which you then
finalize, rather than exposing a single balanceTransaction.
And two network identifiers that disagree. midnight-js keeps a string and
wants "preprod"; zswap's NetworkId enum has no Preprod variant, so the
wallet takes TestNet. Both are needed, and setting the first to "test"
silently encodes addresses under the wrong HRP and finds no coins.
compact update with no argument installs the newest toolchain, which currently
targets ledger 9 and will not deploy to Preprod. Pin deliberately:
| Component | Pinned | Why |
|---|---|---|
| Compact toolchain | 0.31.1 | what midnightntwrk/example-bboard pins for Preprod |
| Compact runtime | 0.16.0 | must match the compiler's runtime version |
| Ledger | 8.0.2 | what the 0.31.1 compiler targets |
| Wallet SDK | 4.0.0 / zswap 3.0.6 | the ledger-8 pairing |
| midnight-js | 4.1.1 | latest stable; 5.x is beta only |
| Proof server | 8.1.0 | matrix-specified for Preprod (Track B only) |
We tried ledger 9 (toolchain 0.34 / wallet 5 / zswap 4) because Preprod serves
unshieldedCreatedOutputs, dustLedgerEvents and dustGenerationStatus, and a
faucet payout arrives as unshielded NIGHT — all ledger-9 concepts. It was the
wrong conclusion: midnight-js has no stable ledger-9 release (5.0.0-beta.7 only,
on an Effect-based API), and the actively maintained example-bboard still pins
ledger-v8 8.1.0 with midnight-js 4.1.1. Preprod evidently serves the newer
indexer surface over a ledger-8 chain.
Two things that cost time on 0.34, in case anyone tries again:
eventbecame a reserved word in language 0.26.Contract.initialStateand the impure circuits became async, andCircuitContextturned into a call-tree built bycreateCircuitContext.
contracts/audit-anchor/ # Track B — the Compact contract
src/AuditAnchor.compact
src/witnesses.ts # private state + witness functions
src/test/ # 12 tests against the compiled circuits
packages/midnight-signer/ # Track A — HTTP sidecar the Vault calls
src/{validate,wallet-pool,routes,server}.ts
src/test/ # 19 tests
demo/anchor-viewer/ # read-only UI over the deployed contract
src/anchor.ts # decode + offline verify (8 tests)
scripts/
sync-wallets-preprod.ts # derive + watch the two Phase 0 wallets
deploy-anchor.sh # compile, prove, deploy, write viewer config
anchor.ts # register an agent, then fold events into it
lib/anchor-runtime.ts # wallet + providers shared by both anchor scripts
# npm install, not npm ci: the lockfile carries dangling references into an
# optional light-client subtree (@substrate/connect -> smoldot) with no package
# entries, which npm ci refuses and npm install resolves. Regenerating the lock
# reproduces the same gap, so it is upstream rather than something to fix here.
npm install
# Tests — no account, no funds, no network needed
cd contracts/audit-anchor && npm test # 12 simulator tests
cd ../../packages/midnight-signer && npm test # 63 sidecar tests
cd ../../demo/anchor-viewer && npm test # 8 viewer tests
cd ../.. && npm run test:scripts # 13 sync-liveness tests
# Preprod, in order. Each step needs the previous one.
npx tsx scripts/sync-wallets-preprod.ts # derive + print the faucet address
# fund it at https://faucet.preprod.midnight.network/
npx tsx scripts/check-dust-registration.ts # expect: not generating
npx tsx scripts/register-dust.ts # proof server on :6300
npx tsx scripts/check-dust-registration.ts # expect: generating
bash scripts/deploy-anchor.sh # deploys, writes the viewer config
npx tsx scripts/anchor.ts --agent demo-agent # register — without this the
# viewer renders an empty table,
# because a deploy publishes no
# commitment of its own
# Or leave it unattended once the wallet is registered:
bash scripts/watch-dust-and-deploy.sh # waits for DUST, deploys, publishesThe proof server is required for anything that proves a circuit — registration
and deployment both do, and so does signing, which was not obvious. An
unshielded transfer has no circuit of its own, but its fee is paid by spending
DUST, and a DUST spend carries a proof. Measured on the broadcast recorded
above: POST /prove at 12:11:53Z, block at 12:12:00Z.
docker run -d -p 6300:6300 midnightntwrk/proof-server:8.1.0-arm64 \
-- midnight-proof-server --network preprodThis repo would not exist without the following, and several of them solved problems we could not have solved from the docs alone.
Documentation and tooling
- Midnight Network documentation — the language reference for Compact, and the DUST/NIGHT model that governs everything about funding a Preprod wallet.
midnightntwrk/midnight-node-docker— local-dev images. The proof server (proof-server:8.1.0) runs from here; deployment proves a circuit and cannot work without it. Pin it to the version in the support matrix for the target network — 8.1.0 alongside Midnight.js 4.1.1 and Wallet SDK 1.2.0 on Preprod. A newer server still proves, and the node then rejects the DUST fee proof at submission asCustom error: 170, which names neither the proof server nor the version.midnightntwrk/compact— the compiler that producesmanaged/, the.zkirand the verifier keys.
Reference implementations we read closely
midnightntwrk/example-bboard— the witness +QueryContextsimulator pattern our contract tests follow. OurAuditAnchorSimulatoris a direct descendant. Checking our toolchain versions against a working reference is also what led us to revert an attempted ledger 9 upgrade and stay on the v8 line.midnightntwrk/example-counter— the smallest complete deploy-and-call loop, and the clearest illustration of provider wiring.mashharuki/midnight-sample-fullstack-app— a community full-stack sample showing contract, wallet and frontend fitting together, which the official examples deliberately keep separate. The same author'smidnight-awesome-dappswas the fastest way to find working code for a given problem.
Packages
@midnight-ntwrk/compact-runtime— circuit execution and the ledger types the simulator asserts against.@midnight-ntwrk/midnight-js-*(contracts, providers, network-id) — deployment and the indexer/proof/private-state providers.@midnight-ntwrk/walletand@midnight-ntwrk/zswap— wallet construction and the shielded balance model.@midnight-ntwrk/wallet-sdk-hdand@midnight-ntwrk/wallet-sdk-address-format— the two that unblocked this project. No wallet SDK surface exposes an unshielded address:WalletStateis Zswap-only in both the 4.x and 5.x generations, so it reports zero for a wallet the faucet has funded. The unshielded key has to be derived from the HD tree underRoles.NightExternaland bech32m-encoded separately, with thepreprodHRP rather thantestnet.scripts/unshielded-address.tsis that, and it is the reason anything here can be funded at all.
Platform
- 1Claw — the vault, Intents API and hash-chained audit log this contract anchors. (docs · for AI agents)
Apache-2.0. 1Claw's own vault/ and shroud/ are separate and not covered here;
this repo imports nothing from them and is cloneable standalone.