# Building on Blacklight L1 — a brief for your coding agent
**How to use this.** Save this file into your project as `AGENT-BRIEF.md` and tell your agent: *"Read AGENT-BRIEF.md and follow it."* Then leave it there — agents lose context, and an agent that can re-read this will not re-learn the same traps twice.
**Who this is for.** A coding agent with a shell, able to `npm install` and read files. It assumes you know Solidity and viem. It does not explain either. Everything below was verified against the live Sepolia deployment and the published npm package on 2026-08-27, by building and settling a real application on it. Where a number is a chain parameter, this file tells you to read it live rather than quoting it, because quoted parameters rot.
---
## 1. The primitive, in ten lines
You seal a **payload** to a committee of `m` nodes, publish it with an on-chain **release condition**, and the fleet unseals it when that condition fires. The unsealing transaction can call **your contract** in the same transaction.
- The payload is arbitrary bytes. Only the committee can open it, and only `k` of `m` are needed.
- The condition is evaluated by nodes off chain, against their own price feed.
- Nobody has to be online for it to fire. Nodes reveal because they are paid to.
- Your contract is called with the revealed bytes and gets to do arbitrary EVM work.
**The one-sentence version: it is a sealed instruction with an unsuppressable, unattended opening.**
## 2. What you can and cannot express — read this before designing anything
The condition is a **52-byte record**, and it can express **only**:
- a **price threshold** — `asset >= X` or `asset <= X`. Asset names resolve through a table you pass to `compileCondition`. **Six ids exist and neither shipped table lists all six:** `ASSETS_V1` (the default) is `BTC:1, ETH:2, SOL:3, LINK:4, XRP:5`; `ASSETS_V2` is `BTC:1, ETH:2, SOL:3, USDT:6`. Ids are append-only, so pass a merged table for anything neither ships — `compileCondition(spec, { ...ASSETS_V1, USDT: 6 })`. Prices are exact decimal strings scaled 1e8 — **floats are rejected, deliberately.**
- a **time window** — `[t1, t2]`, absolute unix seconds.
- or both.
The CLI sugars both: `--condition "BTC >= 100000"` absolute, or `"BTC >= @market"` with `--at-market-bps -100` for a level relative to spot, and `--window +60:+900` for a relative window. The 52 bytes on chain are the same either way.
**There is nothing else.** No sports results, no flight delays, no weather, no election outcomes, no "did this contract emit an event", no arbitrary oracle. If your idea rests on a real-world condition, the protocol cannot observe it, and saying so early is cheaper than discovering it late.
### The reframe that rescues most ideas
**The condition only decides *when the payload opens*. It does not have to be the thing your app cares about.** So: use a **time window**, and put the real decision in your contract at reveal time. The payload opens on schedule; your hook reads whatever state it needs — an oracle you trust, a check-in timestamp, a multisig's verdict — and decides what to do, including doing nothing. What you keep is the part that is genuinely hard without this primitive: **the payload is sealed until T, nobody can suppress it, and it opens whether or not anyone shows up.** What you give up is having the chain adjudicate the condition, which for most application shapes was never the interesting part. The trade to be explicit about: a time-windowed trigger tells the world **when** something opens. If the *timing* is your secret, this primitive does not have an answer for you.
## 3. Install, and prove you are on the live deployment
```bash
npm install @nillion/blacklight-l1-sdk viem
npm pkg set type=module # REQUIRED — the package is ESM-only and will not import without this
```
`viem` is a **peer** dependency, so you will not end up with two copies.
**Pin exactly one address.** C0 is `ProtocolConfig`, behind a permanent proxy. Every other module address resolves off it at runtime:
```ts
import { resolveAddresses } from "@nillion/blacklight-l1-sdk";
const C0 \= "0xebB338689fB32317DDFD8282F8a42dcA6271cB2d"; // Sepolia, chainId 11155111
const addr \= await resolveAddresses(pub, C0);
// -> { config, market, staking, registry, emissions, nil }
```
Do **not** carry a deployment file with five addresses in it. A superseded deployment keeps answering rather than going dark, so a stale address file fails as *wrong data*, not as an error.
**Prove you are on the current deployment before you trust a single read:**
```ts
import { stakingAbi } from "@nillion/blacklight-l1-sdk"; // server-side import, see §6
// This MUST revert. If it returns a value, you are pointed at a superseded deployment.
await pub.readContract({ address: addr.staking, abi: stakingAbi, functionName: "jailedUntil", args: [1n] });
```
**Free reads with no setup at all.** The package installs a `blacklight-l1-sdk` binary whose defaults point at the live deployment:
```bash
npx blacklight-l1-sdk candidates # which nodes can be picked
npx blacklight-l1-sdk status --id N # is trigger N resolved
npx blacklight-l1-sdk shares --id N # what has been posted
npx blacklight-l1-sdk reconstruct --id N
npx blacklight-l1-sdk reveal --id N
npx blacklight-l1-sdk retry --id N # re-run an unacknowledged settlement
```
**The binary was called `dusk` up to 0.4.0 and is `blacklight-l1-sdk` from 0.4.1.** If a tutorial says `npx dusk`, it predates the rename. Writes need `AUTHOR_KEY` in the environment — never a flag, so it cannot land in shell history. That key needs Sepolia ETH for gas **and NIL for the protocol fee**.
**Funding a wallet you generated.** Do not ask your operator to connect a wallet and transfer to you by hand. Hand them ONE link with your address already in it:
`https://faucet.testnet.nillion.network/?chain=blacklight&address=<YOUR_ADDRESS>`
They press "Send NIL" and 20 NIL arrives. No wallet connection, about ten seconds. At the current fee that is roughly 780 posts, so once is enough.
**It sends NIL only.** For Sepolia ETH — gas, plus the escrow you fund on every trigger — send them to any public Sepolia faucet with the same address. Two links, one address, no transfers.
Then **watch the chain and continue when both land**, rather than waiting to be told. An operator who wanders off mid-task otherwise leaves you blocked on a message that never arrives.
**Do not POST that faucet endpoint yourself, never.**
## 4. The lifecycle, and who pays for what
|---|---|---|---|
| 1 | you | `post_trigger` — payload sealed to the committee, plus the condition | `TriggerPosted` |
| 2 | nodes | `post_share`, once each, when they judge the condition met | `SharePosted` |
| 3 | anyone | `post_result` with `k` shares — reconstructs, then calls your hook | `TriggerResolved`, `HookInvoked` |
**An author pays four separate things, and only one comes back:**
- **ETH escrow**, as `msg.value`. A gas-reimbursement pot for the nodes. It must equal the quote **exactly** or `WrongValue` reverts — **it cannot be overfunded, so it can never be the source of funds for whatever your hook does.** Refunded down to actual cost at resolve or expiry.
- **A flat NIL protocol fee**, non-refundable, straight to the treasury. Read it live from `ProtocolConfig.protocolFee()`. NIL has **6 decimals**. Fund the wallet from the faucet — see §3.
- **L1 gas on the post itself.** Never refunded, not part of the escrow.
- **Your hook's gas**, via the hook line in the escrow. You declare the budget; you fund it.
**Never re-derive the escrow locally.** Quote it through the contract — `quoteEscrow` in the SDK, which prices through the same code path the post does, so the quote and the requirement cannot disagree. A locally-computed escrow that is one wei off reverts. **`k` is priced, not averaged.** The escrow takes the **sum of the `k` highest markups**, not the average — so raising `k` buys collusion resistance and costs real money. Budget for it.
**One operator must not hold `k` slots.** The SDK refuses to build such a committee, and the reason is the whole security argument: `k` slots under one operator is a committee that can open your payload alone.
**Do not cache the live node set.** Node ids restart at 1 whenever a redeploy resets the registry, and keys lapse as nodes go quiet. Derive it with `deriveLivePin()`, and re-derive when a post throws `only N eligible nodes; need m=M`. This is reported as the mistake that catches the most people.
## 5. Writing a hook
The interface is one function and one magic value:
```solidity
interface IRevealHook {
function onReveal(uint256 triggerId, bytes calldata plaintext) external returns (bytes4);
}
// HOOK_ACK \== bytes4(keccak256("onReveal(uint256,bytes)")) \== 0xd8e071b6
```
Six rules. Each of these has cost somebody real time. **1. `plaintext` is NOT your payload — strip 32 bytes from the END.** The revealed bytes are `payload ‖ nonce`, and the nonce is always 32 bytes. Decoding `plaintext` directly gives you garbage or a revert.
```solidity
bytes calldata payload \= plaintext[0 : plaintext.length - 32];
```
**2. Gate on the caller. This is your whole access control.**
```solidity
if (msg.sender != market) return bytes4(0);
```
Without it, `onReveal` is a public function — and **after a reveal the plaintext is public**, so anyone could replay it and settle any position whose commitment they can supply. **3. Never revert. Return `HOOK_ACK`, and be idempotent.** These are one rule. A reverting hook cannot be distinguished from a starved one, and the reveal has already happened either way. Return `HOOK_ACK` to mean *decided* — including "decided to do nothing". Return `bytes4(0)` only where a retry could plausibly help. Idempotence is not optional: once the plaintext is public, **anyone can post a second trigger with the same commitment**, so your hook must keep its own settled flag and refuse the second call. **4. The budget is yours to declare and to pay for.** `hookGasLimit` defaults to `ProtocolConfig.defaultHookGas()` and is bounded by `maxHookGas()`. Design to fit your budget — do not raise the limit to fit a fat hook, because the real constraint is the reveal transaction's own bound, not yours. Live today, and every one of these is a `ProtocolConfig` getter you should read rather than trust this table: `defaultHookGas` 250,000 · `maxHookGas` 8,000,000 · `maxRetryWindowSecs` 2,592,000 (30 days) · `maxM` 16 · `protocolFee` 25,600 (= 0.0256 NIL). **5. Emit your own success event. `HookInvoked(triggerId, ok)` does not mean what it looks like.** `ok` means your hook **acknowledged**, never that it succeeded. A hook that declines cleanly also reports `ok \= true`. If you build a dashboard on `HookInvoked` you will report failures as successes. **6. Keep your own `triggerId → your object` mapping, and validate what you are arming.** The market does not know what your hook is for, and `TriggerPosted` does not carry the hook address — there is no way to tell from that event alone whether a trigger belongs to you. Join on something you control: a commitment stored on both sides works well.
**One more, from the type surface:** a `hook` address holding **no code** is refused at post time by a preflight, because a codeless hook settles nothing and cannot be re-run. If you are deploying a counterfactual hook on purpose, you must pass `allowUndeployedHook`.
## 6. Client-side: what runs where
The package's `exports` map has exactly three entries — `.`, `./browser`, `./package.json`. There is no deep-import path; trying one fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`.
- **`@nillion/blacklight-l1-sdk/browser`** — what a page may import. Sealing, condition compilation, address resolution, the sponsored-post signature.
- **`@nillion/blacklight-l1-sdk`** (root) — pulls node-bound modules. **Server only.** This is where the ABIs live (`triggerMarketAbi`, `protocolConfigAbi`, `stakingAbi`, …).
**Consequence you will hit:** the price helpers (`fetchSpotMedian`, `atMarketThreshold`) live in a module the browser entry deliberately excludes. **Anything price-related needs a server route.** Plan for one from the start rather than discovering it when your bundle breaks.
## 7. The traps, with their symptoms
1. **You hand-declared an event signature.** Symptom: `getLogs` returns `[]`, no error anywhere, your UI shows nothing. Wrong field order or a missing field moves topic0. **Fix: import the ABI from the SDK.** Never transcribe one — this is the protocol's own recorded scar.
2. **You cached addresses in a file.** Symptom: everything reads fine and describes a deployment that no longer matters. Fix: pin C0, resolve the rest, and run the `jailedUntil(1)` discriminator.
3. **An empty `catch {}` around your hook's work.** Symptom: the gas-starved path becomes a *successful* transaction, so `eth_estimateGas` converges on it and every reveal runs out of gas at exactly its own estimate. Fix: never swallow silently; make the starved path observably different.
4. **`ttl` vs a time window.** `ttl` is for price-only conditions; a public time window wants `ttl \= 0` and `t1`/`t2` that **match the compiled record**. Mismatched, the post reverts.
5. **You expected a retry to happen.** `retryWindowSecs` keeps `retry_hook` *open*; nothing calls it. If you need retries, you build the caller.
6. **A node in your committee has an unfunded operator key.** Symptom: it silently stops posting shares, and if that drops you below `k` nothing ever reveals. Nodes pay their own gas. This is what `deriveLivePin()` and the liveness helpers are for — a registered node is not a working one.
7. **You expected firing to be instant.** A price must **hold** — a touch-and-bounce resets the clock. Budget ~90 seconds from post to settlement, and strike a margin past spot if you want a demo to fire while someone is watching.
8. **You assumed `noBounty` buys privacy.** It does not, and the SDK's own types say so: the order leaks through the public `SharePosted` events *before any reveal is possible*. See §8.
## 8. What this is the wrong tool for
Be honest about these early; each has caused someone to build the wrong thing.
- **Front-running resistance. You do not have it.** The order becomes publicly reconstructible the moment the `k`-th share is posted, which is **at least one full block before it executes**. In a settlement I traced: shares confirmed at 12:16:48, reveal executed at 12:17:00 — a 12-second public window, and the share transactions were visible in the mempool before that. Anyone can reconstruct and bundle their own trades around the reveal. Your slippage floor is the extraction cap, not the gas price.
- **Privacy from the committee.** "Private" means hidden from the public *until it fires*. Every one of the `m` operators can read the condition while it is pending, and `k` of them can open the payload. It is not `1-of-m` secrecy.
- **Privacy of size.** The escrow is `msg.value` in a public transaction. The condition is sealed; the magnitude usually is not.
- **Privacy after the fact.** `TriggerResolved` publishes the plaintext. Sealing is bounded in time.
- **Guaranteed execution.** Nodes act for a bounty on their own judgement. There is no SLA.
- **Anything needing a condition the 52-byte record cannot express** — see §2, then read the reframe.
## 9. The choices you will face
|---|---|---|
| **PRIVATE vs PUBLIC condition** | the threshold itself is sensitive | PUBLIC is cheaper and simpler; the layers are smaller |
| **Hook vs no hook** | settlement must be atomic with the reveal | without one you read the plaintext yourself, later, unatomically |
| **Bounty on vs `noBounty`** | you want it to fire unattended | `noBounty` cuts the escrow and hands you reveal liveness as an operational problem |
| **Sponsored vs self-posted** | your users will not hold NIL | sponsored needs a relayer holding NIL and an 18-field EIP-712 signature. **Without a `payer` bound into it, the signed bundle is a bearer instrument — transmit it privately** |
| **`k` and `m`** | — | `k` of `m`, with `m` bounded by `ProtocolConfig.maxM()` (16 today). A larger `m` buys liveness and costs privacy — every operator in the committee can read your condition |
## 10. Your first milestone, and the order to build in
Do not start with your application. Start with this, because it proves the whole path in one sitting:
1. **Read, no key needed.** `npx blacklight-l1-sdk candidates`, then `resolveAddresses`, then the `jailedUntil(1)` discriminator. You now know you are on the live chain.
2. **Read `node_modules/@nillion/blacklight-l1-sdk/dist/post-core.d.ts`.** Every field of `PostArgs` is documented inline with its default and its bound. This is your API reference — 22 `.d.ts` files ship in the tarball and they carry the reasoning, not just the types.
3. **Deploy the smallest possible hook**: gate the caller, strip 32 bytes, emit one event with the payload, return `HOOK_ACK`. Nothing else.
4. **Post one trigger** with a price condition struck just past spot so it fires quickly, `k=2`, a small `m`, and your hook. Watch for `SharePosted`, then `TriggerResolved`, then your own event.
5. **Only now** design your application. You have a working reveal path and a measured cost.
**A real settlement you can read.** These five transactions are one complete flow on Sepolia — deposit, sponsored post, two shares, and a reveal that swapped on Uniswap and paid the user, all in the reveal transaction. Read them on Etherscan when you want to see the shape rather than the prose:
```
1 0xed1ab5ce94bfbbb60809de6defe2340a46d100a478a67d6713432451d04190a2 user funds a hook contract
2 0xaad5321c042de7af47a98a4b2e79017dae45e249265c38f58066d8c77c288fbd relayer posts the trigger
3 0xec646e6b75461f2f84a08c182b0ccec5965f697694c46ee4247309694348e4e0 share, slot 2
4 0xa869605b50b8ff4409ae30a273205ef9e5fb3185b0345577e4130a994572d5e3 share, slot 0
5 0xc69563416d82a9775951f6a5015dbcea2cb0143befff6816ba0f06bfdc9f3255 reveal + hook + swap + payout
```
Transaction 5 is the one to study: eight logs, one atomic unit, and the output token goes from the pool **straight to the end user** — it never touches the hook contract.
## 11. Where the truth lives
In this order. Do not skip up the list.
1. **`node_modules/@nillion/blacklight-l1-sdk/dist/*.d.ts`** — 22 files, heavily commented, always version-matched to the code you installed. This is the reference for signatures.
2. **[docs.nillion.com/blacklight/l1/sdk](https://docs.nillion.com/blacklight/l1/sdk)** — the maintained guide: committee selection, conditions, hooks, sponsored posting, the full command reference. It also carries its own shorter agent prompt. **Where it and this file disagree about a command or a signature, believe the docs**; this file's value is the semantics and the traps.
3. **The chain.** Read parameters live off `ProtocolConfig`. The 116 custom errors across the shipped ABIs mean any revert you get is decodable — decode it before guessing.
4. **The package README** — install notes, the CLI, and the environment table.
5. **This file** — semantics, ordering, and the traps.
**When a revert confuses you, decode it against the ABIs before doing anything else.** `stakingAbi` alone carries 18 errors and `triggerMarketAbi` carries 45; the answer is almost always in the name.
---
*Written against `@nillion/blacklight-l1-sdk@0.4.1` and the Sepolia deployment at C0 `0xebB338689fB32317DDFD8282F8a42dcA6271cB2d`, 2026-08-27. Chain parameters are quoted only where the text tells you to read them live; if a number here disagrees with the chain, the chain is right.*