Skip to content

Repository files navigation

1Claw × Midnight — policy-gated agent signing with an on-chain audit anchor

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.

Two tracks

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.

What the proof actually claims

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 — tx 00020462a64410d5a3c60935be7724a2e00fd2424bdecaf9fa0de55b65e3de92eb
  • anchorExtend — tx 0032e0b69910129aa00f7403dfde75ee532b38a0ddd3873a48c5ee7f479202796f

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 batch

The 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.

Quickstart (judges — no 1Claw checkout needed)

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 tests

Funding a Preprod wallet

npm 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.

  1. Request tNIGHT at https://faucet.preprod.midnight.network/ using the unshielded address. Rate limited; ~1000 tNIGHT per request.

  2. 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 registeredForDustGeneration flag, and on a fresh faucet grant it is false. Check yours:

    npx tsx scripts/check-dust-registration.ts
    

    Registration is backed by a Cardano UTXO — the indexer's DustRegistration keys on utxoTxHash / utxoOutputIndex, both documented as Cardano fields, and dustGenerationStatus takes 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.

  3. Once check-dust-registration reports generating, run scripts/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.

Signer sync checkpoints

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.

Getting DUST on Preprod (this catches everyone)

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: generating

register-dust.ts builds the wallet facade, waits for sync, selects the unregistered coins and submits registerNightUtxosForDustGenerationfinalizeRecipesubmitTransaction — 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.

Three things that cost us a day, recorded so they cost you nothing

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.

Version pinning (this bit matters)

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:

  • event became a reserved word in language 0.26.
  • Contract.initialState and the impure circuits became async, and CircuitContext turned into a call-tree built by createCircuitContext.

Layout

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

Running it

# 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, publishes

The 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 preprod

Ecosystem attribution

This 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 as Custom error: 170, which names neither the proof server nor the version.
  • midnightntwrk/compact — the compiler that produces managed/, the .zkir and the verifier keys.

Reference implementations we read closely

  • midnightntwrk/example-bboard — the witness + QueryContext simulator pattern our contract tests follow. Our AuditAnchorSimulator is 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's midnight-awesome-dapps was 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/wallet and @midnight-ntwrk/zswap — wallet construction and the shielded balance model.
  • @midnight-ntwrk/wallet-sdk-hd and @midnight-ntwrk/wallet-sdk-address-formatthe two that unblocked this project. No wallet SDK surface exposes an unshielded address: WalletState is 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 under Roles.NightExternal and bech32m-encoded separately, with the preprod HRP rather than testnet. scripts/unshielded-address.ts is that, and it is the reason anything here can be funded at all.

Platform

License

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.

About

Policy-gated agent signing + on-chain ZK audit anchor for Midnight. Apache-2.0. Built for Midnight Wave Hacks.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages