# Build a Covenants app — 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 that can `npm install` and read files. It assumes you know Solidity and viem and does not explain either. Where a number is a chain parameter, this file tells you to read it live rather than trust a quoted value, because quoted parameters go stale.
---
## 1. The primitive, in ten lines
You seal a **payload** to a committee of `m` nodes and publish it with an on-chain **release condition**. The nodes unseal 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 the `m` nodes are needed.
- The condition is evaluated off chain by the nodes, against their own price feeds.
- 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 can 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** these:
- **A price threshold**, `asset >= X` or `asset <= X`.
- **Four assets are live: BTC, ETH, SOL, USDT.** Always compile against `ASSETS_V2`: `compileCondition(spec, ASSETS_V2)`.
- `compileCondition`'s *default* table is an older one. It includes LINK and XRP and omits USDT. A LINK or XRP condition compiles and posts without error, then **never fires**, because nodes do not price those assets.
- `ASSETS_V2` is exported from the root entry only. In browser code, pass the literal `{ BTC: 1, ETH: 2, SOL: 3, USDT: 6 }`.
- Prices are exact decimal USD strings with at most 8 decimal places. **Floats are rejected, deliberately.**
- **A time window** `[t1, t2]`, in absolute unix seconds, with `t1 < t2`.
- **Or both.**
The spec looks like `{ price: { asset: "BTC", op: ">=", priceUsd: "100000" }, window: { t1, t2 } }`. A price-only condition can also be written as a string: `compileCondition("BTC >= 100000", ASSETS_V2)`.
**How nodes judge a price.**
- Each node polls exchanges itself and takes the median of at least three venues.
- It abstains if the venues disagree by more than 2%.
- The condition must then **hold** for about 20 seconds without interruption. A touch-and-bounce resets the clock.
- USDT is quoted on only three venues, so a single venue outage pauses USDT triggers.
**CLI shortcuts.**
- `--condition "BTC >= 100000"` sets an absolute level.
- `--condition "BTC >= @market" --at-market-bps -100` sets a level relative to spot.
- `--window +60:+900` sets a relative window. **On the CLI, use `--window` only together with `--mode public`** (see §7).
The 52 bytes on chain are the same however you write the condition.
**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 depends on a real-world condition, the protocol cannot observe it. 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.**
- 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. For most application shapes that was never the interesting part.
**Be explicit about one trade.** A time-windowed trigger tells the world **when** something opens. If the *timing* is your secret, this primitive has no answer for you.
## 3. Install, pin one address, and prove where you are
```bash
npm install @nillion/covenants-sdk viem
npm pkg set type=module # new projects only — see below
```
- The package is **ESM-only**. In a new project, set `type=module` as above. In an existing CommonJS project, do not flip the whole project; import the SDK from `.mjs` files instead.
- `viem` is a **peer** dependency, so you will not end up with two copies.
- Older tutorials use the package name `@nillion/blacklight-l1-sdk` and the binaries `dusk` or `blacklight-l1-sdk`. Swap in `@nillion/covenants-sdk` and `covenants`.
### The deployments
**Pin exactly one address: C0, the `ProtocolConfig` proxy.** Resolve every other address from it at runtime. The rest of this table is there so you can check what you resolved, not to copy into a config file. If it disagrees with [docs.nillion.com/blacklight/l1/contracts](https://docs.nillion.com/blacklight/l1/contracts), the Contracts page is right.
|---|---|---|
| **ProtocolConfig (C0) — pin this** | `0xa75716772c17818A73104344b5A8888ae24ADc03` | `0x137c8BFdEd755FD61486e648b2494BE1A1264619` |
| TriggerMarket | `0xe04AB2338e53CEf004512687bd7957Ab18B85744` | `0x60Ab22031E47ff93bB9b88Aa2bfb9FEf90d435A6` |
| NodeRegistry | `0x9A2667ec55c769f49906d7aB718d36aE26c23be2` | `0xF90839b2e303Dc2190d69290F0f0303D3C7D652F` |
| Staking | `0xAcD5D3d8Eacb9f60CfEb6F26D65FB9CB9b06D21b` | `0xDe9a9e0473F85D53AA7Cac29E15Aa91010894795` |
| Emissions | `0xeff51614B2ccB89264e75a1303A17878cEA59C62` | `0x93B25ADaA711D0574548873BcEab76dB08AAfc35` |
| NIL token (6 decimals) | `0x7Cf9a80db3B29eE8efE3710AadB7b95270572d47` | `0x38E6D66fCbe15B7D68aa2E25Ba065A6c6da0c367` |
| AuthVerifier (sponsored posts; `market.authVerifier()`) | `0x9FAB7844e0282741C04607b5cCEDA2e1677Da5f7` | `0xeF52Ad9538011749995CA411Fd8E9FffEDdfa932` |
| EscrowMath (quotes; `market.escrowMath()`) | `0x3D41bB3322209e0E9ee4D63518375c8B19C7Ce24` | `0x151edFaD907D8388ff19f672B2A00afA6C8A8eB2` |
| TriggerMarket's first block (start `getLogs` here) | `26084540` | `11806679` |
```ts
import { createPublicClient, http } from "viem";
import { mainnet } from "viem/chains"; // use `sepolia` for the testnet
import { resolveAddresses } from "@nillion/covenants-sdk";
const C0 = "0xa75716772c17818A73104344b5A8888ae24ADc03"; // mainnet; Sepolia: 0x137c8BFdEd755FD61486e648b2494BE1A1264619
const pub = createPublicClient({ chain: mainnet, transport: http(process.env.RPC_URL) });
const addr = await resolveAddresses(pub, C0);
// -> { config, nil, market, registry, staking, emissions }
```
Do **not** carry a deployment file with five addresses in it. A superseded deployment keeps answering instead of going dark, so a stale address file fails as *wrong data*, not as an error. Older Sepolia deployments are all still live. One example is `0xebB338689fB32317DDFD8282F8a42dcA6271cB2d`, which is superseded. If anything points you there, it is out of date.
**Prove you are on the current deployment before you trust a single read.**
1. Check that `await pub.getChainId()` is the chain you meant.
2. Check that `addr.market` matches the table above.
3. Check that `npx covenants candidates` lists nodes. **If it lists none, that network is not taking triggers; do not post.**
A superseded deployment answers every call normally, so nothing else will warn you.
### The CLI
The package installs a `covenants` binary. **It picks its deployment from the chain `RPC_URL` serves, so `RPC_URL` is the only thing you set.**
- It reads the chain id from the endpoint and uses its built-in C0 for that chain: mainnet (`1`) or Sepolia (`11155111`), the addresses in the table above. It prints the C0 it chose on stderr; check it against the table.
- With `RPC_URL` unset, it targets **mainnet** over a keyless public endpoint. Set your own endpoint for anything beyond a quick read.
- `CONFIG_ADDRESS` is an optional override, for a deployment the built-ins do not cover.
```bash
# Ethereum mainnet
export RPC_URL=https://YOUR-MAINNET-ENDPOINT
# Sepolia testnet
export RPC_URL=https://YOUR-SEPOLIA-ENDPOINT
```
Run the CLI from the project where you installed the package, so `npx` finds the local binary. Outside it, use `npx -p @nillion/covenants-sdk covenants …`. **Never run a bare `npx covenants` outside your project.** No npm package of that name exists, so `npx` will fail, or fetch whoever registers the name later.
Reads, no key needed:
```bash
npx covenants candidates # which nodes can be picked
npx covenants status --id N # is trigger N resolved
npx covenants shares --id N # what has been posted
npx covenants reveal --id N # print the revealed payload
```
Writes, which need `AUTHOR_KEY` and cost gas:
```bash
npx covenants post … # post a trigger
npx covenants reconstruct --id N # assemble k shares and reveal on chain (earns the bounty)
npx covenants retry --id N # re-run an unacknowledged settlement, at your expense
npx covenants withdraw-refund --id N # pull a refund the automatic push could not deliver (payer only)
npx covenants claim-recon # withdraw reconstructor fees
```
`npx covenants --help` lists the rest.
Rules for `AUTHOR_KEY`:
- It comes from the environment only, never a flag, so it cannot land in shell history.
- Unset, it defaults to a publicly known local-development key, which the CLI refuses to sign with on any real chain.
- The key needs ETH for gas and the escrow, **and NIL for the protocol fee**.
### Funding
**On mainnet**, the wallet needs real NIL (the token above) and ETH from your operator. Use a **dedicated author key holding only what the next few posts need**, never the operator's main wallet.
**On testnet**, 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 testnet NIL arrives, with no wallet connection, in about ten seconds.
- It allows one claim per address per day. At the testnet protocol fee that covers hundreds of posts.
**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**, instead of waiting to be told. An operator who wanders off mid-task otherwise leaves you blocked on a message that never arrives.
**Check the NIL balance on `addr.nil`, the token `resolveAddresses` gave you, and on no other token.** Sepolia hosts several NIL tokens, and only that one pays this deployment's fee.
**Never POST to the faucet endpoint yourself.**
## 4. The lifecycle, and who pays for what
|---|---|---|---|
| 1 | you (or a relayer) | `postTrigger` (or `postTriggerSponsored`) | `TriggerPosted`, plus `HookConfigured` if you set a hook or `noBounty` |
| 2 | committee nodes | `postShare` / `postSharesBatched`, once per slot, when they judge the condition met | `SharePosted` |
| 3 | anyone, usually a node | `postResult` with the plaintext and exactly `k` shares; it reconstructs, then calls your hook | `TriggerResolved`, `HookInvoked` (if a hook is set), `ReconCredited`, then `RefundIssued` or `RefundOwed` |
| — | anyone | `retryHook`, after a settlement that was not acknowledged | `HookRetried` |
| — | anyone | `settleExpired`, after a trigger expired without revealing | — |
The function names are camelCase. Never hand-write a signature; import `triggerMarketAbi` from the SDK.
**Four separate things get paid, and only one comes back.** The **payer** (the author, or the relayer on a sponsored post) pays all four:
1. **ETH escrow**, as `msg.value`.
- It is a gas-reimbursement pot for the nodes. It must equal the quote **exactly** or the post reverts with `WrongValue`. **It cannot be overfunded, so it can never be the source of funds for whatever your hook does.**
- The escrow is refunded **to the payer**, down to actual cost.
- At resolve, the refund is pushed with a 30,000-gas stipend. If the push fails, the event is `RefundOwed`, and the payer pulls it with `withdrawRefund`.
- **Expiry is not automatic.** If a trigger never fires, its escrow stays locked until someone calls `settleExpired(triggerId)`, which first pays any nodes that posted shares. Nodes do not call it, and the CLI has no command for it, so call it yourself.
2. **A flat NIL protocol fee.**
- It is non-refundable and goes straight to the treasury.
- Read it live from `ProtocolConfig.protocolFee()`. NIL has **6 decimals**.
- The payer must approve the market. The SDK sends one unlimited approval on your first post unless you set `feeApprovalAmount`.
3. **L1 gas on the post itself.** It is never refunded and is not part of the escrow.
4. **Your hook's gas**, through the hook line in the escrow. You declare the budget, and you fund it.
**Never re-derive the escrow locally.** Quote it through the contract with `quoteEscrow` in the SDK. It 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. A node changing its markup between your quote and your post also gives `WrongValue`, so re-quote and retry.
**`k` is priced, not averaged.** The escrow takes the **sum of the `k` highest markups** in your committee, not the average. Raising `k` buys collusion resistance and costs real money, so budget for it.
**The gas ceiling is a liveness setting, not just a price.** `ceilingWei` is the highest gas price nodes are reimbursed at, and the escrow scales with it.
- **Nodes do not post shares while basefee is above your ceiling.**
- Too low, and nothing fires during a gas spike. Too high, and more ETH is locked until refund.
- `suggestCeiling(basefee)` gives a starting point.
**One operator must not hold `k` slots.** `selectCommittee` 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. A `keys` list you assemble by hand bypasses that check, so do not.
**Do not cache the live node set.** Node ids restart at 1 whenever a redeploy resets the registry, and keys lapse when nodes stop rotating them.
- Derive the live set with `deriveLivePin(pub, addr)`.
- Keep only candidates whose ids are in `pin.nodeIds` before you select.
- Re-derive when selection 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
// The market's own constant is private; compute it in your contract.
```
Eight 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.** `msg.sender` is the TriggerMarket proxy, which is `addr.market`.
```solidity
if (msg.sender != market) return bytes4(0);
```
Without this check, `onReveal` is a public function. And **after a reveal the plaintext is public**, so anyone could replay it.
**3. Authenticate the trigger, not just the caller.** **Anyone can post a trigger that names your hook**, and the market will call you for it. Use one of these:
- `postingParties(triggerId)` returns `(author, payer)`. Check that the author is one you accept.
- Store a commitment when you arm an object, and refuse plaintexts that do not match it.
Either way, keep your own `triggerId → your object` mapping.
**4. Never revert. Return `HOOK_ACK`, and be idempotent.** These are one rule.
- A reverting hook cannot be told apart 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 plaintext**, so your hook must keep its own settled flag and refuse the second call.
**5. The budget is yours to declare and to pay for.**
- `hookGasLimit` defaults to `ProtocolConfig.defaultHookGas()` and is capped at `maxHookGas()`. Design to fit your budget instead of raising the limit to fit a fat hook.
- The reveal forwards your budget. If the reveal transaction itself is short of gas, the 63/64 rule starves your hook, and that counts as a failure, which can be retried.
- Retries are paid by whoever calls them and are not gas-capped, so the budget is not a security boundary.
- Your hook cannot re-enter the market's value-moving functions, which are `nonReentrant`. Views are fine.
- Only the first 32 bytes of your return data are read.
**6. Retries are open, and nobody runs them for you.**
- `retryWindowSecs` set to 0 means `defaultRetryWindowSecs()`; the maximum is `maxRetryWindowSecs()`. You cannot turn retry off.
- The window starts at resolution. While it is open, anyone may call `retryHook` at their own expense, and it emits `HookRetried(triggerId, ok, caller)`.
- Nodes never call it. If you need retries, build the caller: `npx covenants retry --id N`, or `retryHookOnce` in the SDK.
**7. 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`. A dashboard built on `HookInvoked` will report failures as successes.
**8. No event carries the hook address.** Neither `TriggerPosted` nor `HookConfigured` names it. Read it with `hookMeta(triggerId)`, which returns `(hook, hookGas, hookOk, retryWindow, retryDeadline, bountyOff)`. Use it to confirm what the chain actually recorded for your trigger.
**A codeless hook.** The SDK refuses a `hook` address holding **no code** unless you pass `allowUndeployedHook`. The chain accepts one, with these consequences:
- At reveal, the hook is skipped (`HookInvoked` with `ok = false`).
- The reconstructor is still paid the hook fee floor out of your escrow.
- `retryHook` reverts until code exists. A counterfactual hook works only if you deploy it before the retry deadline.
**Payload size.** The plaintext (payload plus the 32-byte nonce) must fit within `maxPlaintextBytes()`. It must also fit its share of `maxCiphertextBytes()`, which is split across all `m` layers, so the limit shrinks as `m` grows. The SDK's `layerLenForPayload` and `maxLayerLen` compute it. Testnet's `maxPlaintextBytes` is far smaller than mainnet's; read it live.
**Live today on both networks.** Each of these is a `ProtocolConfig` getter you should read rather than trust this list:
- `defaultHookGas` 250,000
- `maxHookGas` 8,000,000
- `defaultRetryWindowSecs` 604,800 (7 days)
- `maxRetryWindowSecs` 2,592,000 (30 days)
- `maxM` 16
- `protocolFee` 64,000,000 on mainnet (64 NIL) and 25,600 on testnet (0.0256 NIL)
## 6. Client-side: what runs where
The package's `exports` map has exactly three entries: `.`, `./browser` and `./package.json`. There is no deep-import path; trying one fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`.
- **`@nillion/covenants-sdk/browser`** is what a page may import:
- sealing, through `initBrowserCrypto()`
- `compileCondition` (with the old `ASSETS_V1` only; pass the v2 literal from §2)
- `resolveAddresses`
- posting an already-sealed package (`postSealed`)
- signing a sponsored post (`signPostAuthorization`)
- **`@nillion/covenants-sdk`** (the root) pulls in node-bound modules, so it is **server only**. It is where these live:
- `post` (seal and post in one call)
- the ABIs (`triggerMarketAbi`, `protocolConfigAbi`, `stakingAbi`, …)
- `ASSETS_V2`
- the price helpers (`fetchSpotMedian`, `atMarketThreshold`)
- the reconstruct and watch helpers
**A consequence you will hit:** anything price-related needs a server route. Plan for one from the start, not when your bundle breaks.
**The library call** is `post(pub, wallet, addr, args)`. `wallet` is a viem wallet client with an account and a chain. `args` is `PostArgs`:
- **Required:** `payload`, `mode` (`"PRIVATE_CONDITION"` or `"PUBLIC_CONDITION"`), `conditionRecord`, `keys`, `k`, `ttl`, `ceilingWei`. The library gives `ttl` and `ceilingWei` no defaults.
- **Optional:** `hook`, `hookGasLimit`, `retryWindowSecs`, `noBounty`, `t1`/`t2`, `allowUndeployedHook`, `feeApprovalAmount`.
Some helpers (`status`, `fetchShares`, `reconstructAndPost`) take the market address, not the whole `addr` object; check the `.d.ts`.
## 7. The traps, with their symptoms
1. **You hand-declared an event signature.**
- Symptom: `getLogs` returns `[]`, with no error anywhere, and your UI shows nothing. A 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. **Your RPC pruned the history.**
- Symptom: the same silent `[]` from `getLogs`, or `null` for an old receipt. Some free endpoints keep only recent history and do not say so.
- Fix: scan from the TriggerMarket's first block (in the §3 table) on an archive-capable endpoint.
3. **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 checks in §3.
4. **An empty `catch {}` around your hook's work.**
- Symptom: the gas-starved path becomes a *successful* transaction. `eth_estimateGas` converges on it, and every reveal runs out of gas at exactly its own estimate.
- Fix: never swallow errors silently; make the starved path observably different.
5. **`ttl` versus a time window.** The rules depend on the mode, and a mismatch reverts the post:
- **PUBLIC with a window:** `ttl = 0`, and `t1`/`t2` must equal the compiled record. Expiry is `t2`.
- **PUBLIC, price only:** `0 < ttl <= maxTTL()`.
- **PRIVATE, always:** `ttl > 0` and `t1 = t2 = 0`. The window lives inside the sealed record and only the nodes enforce it, so make sure `ttl` outlasts `t2`.
- `t1` must still be in the future when the post is mined, so leave a margin.
- Nodes never post a first share within 120 seconds of expiry, so make windows comfortably wider than that.
6. **The CLI's `--window` in the default private mode.**
- Symptom: the post reverts with `WindowForbidden`.
- Fix: use `--window` only with `--mode public`. For a private time window, compile the record yourself and post through the library.
7. **You set settlement options on the CLI and assumed they took.**
- `--hook-gas`, `--retry-window` and `--no-bounty` may not reach the chain from the CLI. Set `hookGasLimit`, `retryWindowSecs` and `noBounty` through the library, and confirm them with `hookMeta(triggerId)` after posting.
- Do sponsored posting through the library as well (`signPostAuthorization`, then `postSponsored`).
8. **You expected a retry to happen.** `retryWindowSecs` keeps `retryHook` *open*; nothing calls it. If you need retries, build the caller.
9. **A node in your committee stopped posting.**
- Nodes pay their own gas. A node whose operator key runs dry, or that goes offline, stops posting shares.
- Spare committee members step in after about five minutes. With `m == k`, one dead node means nothing ever reveals.
- Choose `m > k`, and use `deriveLivePin()`: a registered node is not a working one.
10. **You expected firing to be instant.**
- The price must hold for about 20 seconds, and the post, shares and reveal each need a block.
- Budget about 90 seconds from post to settlement when every node is healthy, and minutes when one is not.
- Strike a margin past spot if you want a demo to fire while someone is watching.
11. **Your ceiling was below basefee.** Symptom: no shares, no error. Nodes wait until basefee is back under your ceiling, or the trigger expires.
12. **You used LINK or XRP, or the default asset table.** Symptom: the trigger posts and never fires. Use `ASSETS_V2` (§2).
13. **You assumed `noBounty` buys privacy.** It does not. The order leaks through the public `SharePosted` events *before any reveal is possible*. See §8.
14. **You forgot the escrow on a trigger that never fired.** It stays locked until someone calls `settleExpired(triggerId)` (§4).
## 8. What this is the wrong tool for
Be honest about these early; each has led someone to build the wrong thing.
- **Front-running resistance. You do not have it.**
- Shares are posted as plain Shamir shares in `SharePosted` events, so the order becomes publicly reconstructible the moment the `k`-th share lands. That is **at least one full block before the reveal executes**.
- In one traced settlement, shares confirmed at 12:16:48 and the reveal executed at 12:17:00: a 12-second public window. The share transactions were visible in the mempool before that.
- Anyone can reconstruct the order 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, and the chain enforces a public time window itself |
| **Hook vs no hook** | settlement must be atomic with the reveal | without one, you read the plaintext yourself, later, and not atomically |
| **Bounty on vs `noBounty`** | you want it to fire unattended | `noBounty` cuts the escrow and makes reveal liveness your operational problem |
| **Sponsored vs self-posted** | your users will not hold NIL | sponsored needs a relayer holding NIL and ETH, and an 18-field EIP-712 signature. The payer pays the fee and escrow and receives the refunds. **Without a `payer` bound into it, the signed bundle is a bearer instrument, so transmit it privately** |
| **`k` and `m`** | — | `k` of `m`, with `m` capped by `ProtocolConfig.maxM()`. A larger `m` buys liveness and costs privacy, because every operator in the committee can read your condition. Keep `m > k` |
## 10. Your first milestone, and the order to build in
Do not start with your application. Start with this, on **Sepolia**, because it proves the whole path in one sitting.
1. **Read, with no key.** Point `RPC_URL` at Sepolia as in §3. Run `npx covenants candidates`, then `resolveAddresses`, then the §3 checks. You now know you are on the deployment you meant.
2. **Fund a fresh key.** Use the faucet link plus a Sepolia ETH faucet (§3). Confirm the NIL balance on `addr.nil`.
3. **Read `node_modules/@nillion/covenants-sdk/dist/post-core.d.ts`.** Every field of `PostArgs` is documented inline, with its default and its bound. This is your API reference; the `.d.ts` files in the package carry the reasoning, not just the types.
4. **Deploy the smallest possible hook.** Gate the caller, strip 32 bytes, emit one event with the payload, return `HOOK_ACK`. Nothing else.
5. **Post one trigger** that fires quickly:
```bash
AUTHOR_KEY=0x… npx covenants post \
--condition "ETH >= @market" --at-market-bps -50 \
--k 2 --m 3 --ttl 1800 \
--hook 0xYOUR_HOOK --payload "hello"
```
A `>=` level struck 0.5% below spot is already true, so it fires after the hold. Watch for `SharePosted`, then `TriggerResolved`, then your own event. `npx covenants status --id N` and `hookMeta(N)` tell you where it is.
6. **Only now** design your application. You have a working reveal path and a measured cost.
**Move to mainnet only once a full flow works on Sepolia.** Every mainnet post costs real ETH and NIL, so use a dedicated author key holding only what the next few posts need.
**A real settlement you can read.** These five transactions are one complete flow on an earlier Sepolia deployment: a deposit, a sponsored post, two shares, and a reveal that swapped on Uniswap and paid the user, all in the reveal transaction. Open them on sepolia.etherscan.io (some free RPCs no longer return them):
```
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 in 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/covenants-sdk/dist/*.d.ts`.** Heavily commented, and always 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)** and **[/contracts](https://docs.nillion.com/blacklight/l1/contracts)**, the maintained guide and the address list. **Where they and this file disagree about a command, a signature or an address, believe the docs.** This file's value is the semantics and the traps.
3. **The chain.** Read parameters live from `ProtocolConfig`.
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.** `triggerMarketAbi` carries every market error, including the escrow and sponsored-post ones, and `stakingAbi` carries the staking errors. The answer is almost always in the error name. One exception: the NIL token's own ERC-20 errors (`ERC20InsufficientAllowance`, `ERC20InsufficientBalance`) are not in the market ABI. Decode those against a standard ERC-20 error ABI; they mean the payer is short of NIL or has not approved the market.
---
*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.*