Building on STYX
The covenant is frozen; the software around it is not. This chapter is the contract between the frozen v1 spec and the testnet SDK: what a transaction builder must guarantee, which duties a keeper owes the system, and where the two genuinely hard problems live.
1Where things stand
Everything in this spec is implemented and validated on-node by a working reference implementation: builders for every operation, the witness-sum encoders, address and CMR derivation, and the collateral-ratio math. The public testnet version is built iteratively from that reference, against this spec - explicitly not a rewrite, but decomposition, typing, and test coverage of what already works.
Two decisions are locked:
- Transaction format: PSET (Partially Signed Elements Transaction). The protocol has multiple signers per transaction - the oracle quorum, the vault owner, the keeper - and PSET is what wallets interoperate on. Builders emit PSET, not raw transactions; covenant witnesses (the script path plus the pruned Simplicity program) and the Schnorr signatures slot into PSET input fields.
- Freeze before building. Builders encode exact witness structures, transaction layouts,
index conventions, and CMR-derived addresses. If the
.simfsource moved under them, every builder and every golden fixture would churn. The economic pass is complete, the golden-CMR gate is in place, and builder work starts from the frozen source.
2The layered SDK
The reference implementation fuses construction with node I/O: each operation builds, sends, and mines in one method, and all coverage is one end-to-end run. The testnet crate splits this into six layers so that most of the test weight becomes fast and pure:
| Layer | Contents | Tested by |
|---|---|---|
| 1 · Pure primitives | CR math (coll_at_cr, required_sats, exact truncation direction),
leaf / scriptPubKey / CMR / control-block derivation, witness-sum encoders |
unit tests, milliseconds, no node |
| 2 · Covenant artifacts | compile + prune the five covenants, record CMRs / addresses / params;
deploy_preflight lives here |
golden-CMR fixtures |
| 3 · PSET builders | pure functions (state + intent) -> PSET, no send or mine inside; one builder
per operation |
prune-level accept / reject |
| 4 · Node adapter | funding, broadcast, confirmation, UTXO tracking | integration tests on regtest |
| 5 · Domain types | Vault {debt, owner, last_height, outpoint, coll}, Issuer,
Pot, Reserve, OracleTick {height, backing_k, quotes},
Config |
property tests |
| 6 · Roles | oracle signer, keeper / watchtower, borrower wallet | end-to-end scenarios |
The test pyramid
The reference implementation's pyramid is inverted: 100% of its coverage is top-tier on-node end-to-end. The SDK target is the usual shape:
- Unit (pure, milliseconds): math, encoding, address derivation.
- Prune-level (no node): build a transaction, run
satisfy_with_env, assert accept or reject. This layer gives the same verdict the node does, and it is where the single-cause negative tests live - the discipline inherited from the audits. - On-node end-to-end (regtest elementsd): the reference lifecycle run survives as the top smoke and acceptance suite.
3The two crux items
The nested-sum witness encoder
Every covenant dispatches its operations through one nested Either sum (the exact
shapes are in chapter Γʹ). This is the single most error-prone
surface in the SDK: one wrong nesting level does not merely fail - it can prune the program to a
different arm and run the wrong operation's guard. The plan: a type-safe enum per covenant
that mirrors the covenant's sum exactly, a total encoding function, and a test per variant proving
it prunes to the intended arm and no other.
CR-math parity
The collateral-ratio helper coll_at_cr exists twice: once in Rust (the builder side)
and once in SimplicityHL (the covenant side). Both must truncate identically - floor division, same
direction - or a builder will emit an amount exactly one satoshi off a <= gate and
the covenant will reject a transaction the builder considered valid. The plan: property tests that
for random (debt, price, k) the Rust value equals the covenant's, and that every
builder output passes the on-node assert.
4Builder and keeper invariants
The covenant leaves a short list of things deliberately on trust; they are cheap to enforce off-chain and expensive or impossible to enforce on-chain. The SDK must treat each of these as a hard rule, not a convention:
| Invariant | Rule | Why |
|---|---|---|
| Single pot, single reserve | Never fragment the pot or the reserve; a builder must refuse to build if it sees more than one UTXO at either address. | The covenant cannot consolidate fragments; value in a second UTXO is operationally stranded until spent back in one piece. |
| Keeper payout binding | Every permissionless operation (partial liquidation, full liquidation, bad debt, redeem) carries a mandatory keeper funding input; the builder must sign it SIGHASH_ALL - never an anyone-can-pay or output-masking mode. | The keeper's payout output is not pinned by the covenant. SIGHASH_ALL makes the keeper's own signature commit to the payout destination, so no mempool observer can redirect it (finding E-5). REFRESH has no payout and needs no binding. |
| Explicit values | Protocol outputs must be explicit (unblinded); only change and borrower-facing outputs may blind. | The covenant's explicit-read jets fail on blinded values; a blinded protocol output is unspendable by the covenant path. |
| Per-op fee sourcing | REPAY and REFRESH pay the network fee from a separate coin; CLOSE pays it from the freed collateral. | REPAY and REFRESH forbid the collateral from shrinking, so the fee cannot come out of the vault. |
| Issuer contention | Retry on conflict; sequence mempool-aware. | Every mint and every bad-debt attestation serializes through the one issuer UTXO; concurrent users race for it. |
| Keeper liveness | POKE the issuer anchor toward the tip and REFRESH dormant healthy vaults on a schedule, not best-effort. | The freshness ratchets are the anti-replay defense; they only protect vaults that someone keeps fresh (see chapter Εʹ). |
| backing_k sourcing | The oracle role needs a defined procedure to compute and co-sign the global backing ratio. | backing_k is off-chain data trusted to the same 3-of-5 quorum; only the
REDEEM floor reads it. |
5Do builders have to be trusted?
No. The authority is the covenant, not the builder, so protocol safety does not depend on any builder following these rules. A malicious builder cannot mint unbacked OBOL, steal collateral, drain the reserve, or brick a singleton - those are covenant-enforced whatever the builder does. The rules above split into two kinds, and neither is a lever a griefer can pull against the protocol.
Validity and self-interest rules - the incentive is intrinsic
Breaking one of these invalidates the builder's own transaction or costs the builder its own money. There is nothing to police, because the covenant (or the node) rejects a malformed transaction and a griefer who breaks these is just a griefer who cannot transact.
- Explicit values - a blinded protocol output fails the covenant's explicit-read jet: the transaction is rejected. Self-inflicted.
- Per-op fee sourcing - paying a REPAY or REFRESH fee out of collateral trips the "collateral must not shrink" assert: rejected. Self-inflicted.
- Keeper payout binding - a loosely-signed keeper input lets a mempool observer redirect the payout, but the liquidation still executes correctly (the vault heals or closes, the pot and reserve stay right). Only the careless keeper loses; the protocol's invariants are untouched.
- Single pot / reserve - a protocol operation cannot fragment the pot at all
(
pot_grows_bypins both ends to the genuine pot). A fragment appears only if someone donates a UTXO to the address outside an operation, which strands their own funds; the canonical singleton is untouched and honest indexers ignore the decoy (the decoy-pot theft vector is closed in the covenant by thecurrent_index - 1read). - Issuer contention - not retrying just means your transaction fails when someone else spends the singleton first. A liveness annoyance to yourself.
Liveness duties - permissionless, so not weaponizable
POKE and REFRESH are the only rules that protect the system rather than the builder, but they are permissionless: a griefer cannot prevent them, only decline to perform them. A single honest keeper closes the gap, and every mint already advances the issuer anchor. The exposed party - the owner of an un-refreshed dormant vault - carries its own liveness risk, and anyone may refresh it on their behalf. So the harm here materializes only under ecosystem-wide neglect, not adversarial action; it is the accepted residual of E-9 and M-2, not an attack.
| A griefer breaking the rules can | and cannot |
|---|---|
| contend for a singleton's outpoint (slowdown, section 9) | mint unbacked OBOL |
| donate decoy fragments (burning their own funds) | steal collateral |
| broadcast invalid transactions (the node rejects them, they pay the fee) | drain the reserve |
| decline to POKE or REFRESH (any honest actor closes it) | burn or brick a singleton |
In short, the builder rules exist so that an honest client transacts correctly and does not lose its own value. They are not load-bearing for safety - that is what moving every authority into the covenant buys.
6The roles
- Oracle signer
- One of the five genesis keys. Signs
sha256(height || price || backing_k)ticks on a schedule against a tight, aggregated price source. Must also agree thebacking_kfigure with the rest of the quorum. - Keeper / watchtower
- Permissionless and profit-motivated for liquidations and redemptions; duty-driven for liveness (POKE, REFRESH). Funds every permissionless operation from its own key, signed SIGHASH_ALL. Watches the mempool for issuer contention.
- Borrower wallet
- Builds OPEN / DRAW / REPAY / CLOSE PSETs, tracks the owner's vault state (debt, collateral,
last_height), and keeps the vault's data leaf reconstructible from the wallet's own records.
7Wallet recovery from seed
A vault address is not a function of the owner's key alone: it commits the dynamic state
(debt, owner, last_height), which a freshly restored wallet does not know. A
standard restore flow - derive addresses from the seed, subscribe to them - therefore finds
nothing. This is a structural property of state-in-address, and the recovery procedure
below is the required substitute, not an optional convenience.
The data leaf of an unspent vault is never visible on-chain (a taproot leaf is revealed only
on spend), but the protocol never needs it to be: every state value is exposed as Simplicity
witness data in the transaction that created that state. OPEN carries
(principal, owner, tick) in the issuer's witness; the issuer's DRAW arm carries the
full (old_debt, owner, old_last_height, new_debt, draw_height); and every vault
spend witnesses DEBT, OWNER, and LAST_HEIGHT outright. An
unspent vault's state is always reconstructible from its parent transaction's witnesses.
Recovery is a deterministic walk over the protocol's singleton chains:
- Walk the issuer chain. The issuer is a single UTXO and every OPEN, DRAW, POKE, and ATTEST spends it, so its spend chain is linear from genesis - one transaction per hop, enumerable with Esplora-style outspend queries, no full node required.
- Match the owner field. In each OPEN and DRAW witness, compare
owneragainst the wallet's derived keys. A match yields the vault, its creation state, and - since the address is a pure function of the state - its exact address and outpoint. - Follow the vault's outpoint. From there, each spend of the vault reveals the witness, from which the successor state and successor address are computed. This walk also captures every operation the owner did not sign: a keeper's REFRESH (the one operation that touches no singleton - but it spends a known outpoint), a partial liquidation, a redemption against the vault.
The walk recovers both the full history and the exact current state, including the
last_height needed to build the next operation. Two conventions make it work and
belong in the SDK:
- A fixed derivation path for owner keys, so a restored wallet derives the same x-only keys it once put into vault data leaves - without it, step 2 has nothing to match.
- A fresh key per vault with a gap-limit-style derivation window, mirroring ordinary wallet scanning discipline.
The same mechanism that makes recovery possible makes ownership public: the owner key appears in plaintext witness data on every vault operation, so an indexer maps every vault to its owner key, and key reuse links all of an owner's vaults to each other. This is consistent with the protocol's explicit-amounts stance, and per-vault keys are the available mitigation - they hide the linkage between vaults, not the vault-to-key mapping itself.
8The indexer
The recovery walk generalizes: a protocol-aware indexer, reading nothing but the chain,
reconstructs the entire vault system. It is the natural shared infrastructure behind all three
roles - the borrower wallet queries it for recovery and for the state needed to build the next
PSET, the keeper watchtower queries it for liquidation candidates, and the oracle signers
query it for the inputs to backing_k.
Available from the chain alone
| Data | How |
|---|---|
| the vault census (every genuine vault, live and closed) | walk the issuer singleton's linear spend chain; every OPEN and DRAW passes through it |
per-vault state (debt, owner, last_height), address, outpoint |
witness data: OPEN and DRAW expose the successor state in the issuer's witness, and every vault spend witnesses the full current state; successor states are pure functions of witness values |
| per-vault history, including operations the owner never signed (a keeper's REFRESH, liquidations, redemptions against the vault) | follow each vault's outpoint chain; every hop reveals its witness |
| collateral amounts, reserve balance, pot balance | explicit by protocol rule; the pot and reserve are single UTXOs at constant addresses |
system statistics: outstanding OBOL (supply - pot balance), aggregate
collateral, the CR distribution at a given price, liquidation candidates |
derived from the above; outstanding OBOL and the per-vault debt sum reconcile exactly, since fees are L-BTC and every mint moves through the pot |
Required from outside the chain
- The frozen covenant artifacts. Simplicity witness values are not self-describing; decoding them - and building control blocks for new spends - requires the published compiled covenants and genesis parameters. A generic script parser is not enough: the indexer must be protocol-aware.
- A fresh oracle tick. Prices are an off-chain feed by definition; the chain only ever shows ticks that were already used.
A vault UTXO funded directly at a vault address, bypassing OPEN, reveals nothing until spent: its data leaf is unrevealed and it never touched the issuer. The census above therefore contains genuine vaults only, which is exactly what statistics want - a conjured vault represents no minted OBOL, and it surfaces (in the ATTEST witness) only if its creator pays to close it.
Inside a protocol transaction, every output a covenant reads must be explicit - the explicit-read jets fail on blinded values, and OPEN pins the borrower's OBOL output outright. One hop later the picture inverts: OBOL and L-BTC received from the protocol are ordinary Liquid assets, and a standard confidential transaction moves them behind blinded amounts and assets. The working model is explicit inside the protocol, confidential immediately outside it. Aggregate statistics survive this - outstanding OBOL is computed from the always-explicit pot, not from tracking holders.
9Contention and throughput
The singleton UTXOs are shared mutexes: two users whose transactions spend the same singleton race for its current outpoint, and the loser must rebuild. The honest map of who contends with whom:
| Operation | Pot | Reserve | Issuer |
|---|---|---|---|
| OPEN | yes | yes | yes |
| DRAW | yes | - | yes |
| REPAY / CLOSE | yes | - | - |
| LIQUIDATE / FULL-LIQ / REDEEM | yes | yes | - |
| BAD-DEBT | yes | yes | yes |
| POKE | - | - | yes |
| REFRESH | - | - | - |
The pot is the universal mutex - everything except REFRESH and POKE spends it. The reserve is the second, and it sits on the crisis path: every liquidation and redemption routes a fee through it. The issuer binds only the mint path and attestations. REFRESH is the one fully parallel operation.
What a mutex costs in practice
A singleton is not "one operation per block": spends chain in the mempool (subject to the node's descendant limit, ~25), so the cooperative ceiling is on the order of about 25 operations per singleton per one-minute Liquid block. The real cost is the race: a losing transaction references a spent outpoint and must be rebuilt against the new tip - and because every signature commits SIGHASH_ALL, rebuilt means re-signed. The oracle tick survives a rebuild (it binds to the message and the lock time, not to outpoints), and a software key re-signs silently; a hardware wallet prompts its user on every lost race. Builders should treat retry-on-conflict as the normal path, not an error.
The intended v1 answer is coordination, not sharding: a sequencer service on top of the indexer (section 8) that hands builders the current tip of each singleton chain and linearizes submissions into a mempool chain. The sequencer holds no keys and confers no authority - anyone can bypass it and race permissionlessly; it only removes the accidental collisions. Contention metrics from testnet operation, not intuition, should decide whether anything more is needed: mints are human-paced, and the crisis burst - liquidations - has the ~25 per block ceiling on the pot and reserve chains.
Sharding is a documented v2 path
Nothing in the covenants asserts that a singleton is unique - a covenant cannot see the UTXO set. That leaves the door open, deliberately documented here and deliberately not taken in v1:
- K pot lanes - plausibly no covenant change. The pot is recognized by its constant address, and K UTXOs at that address are K independent spend chains. Within one transaction a second pot input cannot validly spend (the inflow leaf pins index 1, the outflow leaf demands token adjacency), so the per-instance guards hold. Lanes cannot be consolidated (the index pin forbids merging two pots in one transaction) but rebalance organically - a REPAY tops up whichever lane the builder picks. Accounting survives: outstanding OBOL is the supply minus the sum of lane balances.
- N issuer lanes - plausibly a genesis-only change. The pot and reserve gate on the
token's asset id, not on an address or a count, and
recurse_tokenpins each lane to one unit. Issuing N units into N covenant UTXOs at genesis would keep the same source and the same CMRs. The cost is real: N independentlast_mint_heightanchors, so the stale-mint defense degrades to the laggiest lane and the POKE duty multiplies by N. Cross-lane composition inside one transaction is blocked by the arms' fixed output indices (two OPENs would both pin outputs 0, 1, and 2 and contradict each other). - The reserve should not be sharded. Mechanically it could be, but the bad-debt
payout is
min(.., stab_in)against the one fragment being spent - splitting the reserve shrinks the backstop exactly when it is needed whole.
Multi-instance aliasing is precisely the bug class this protocol has already been burned by - two vaults aliasing one pot output, the decoy pot, the sybil reserve - and every pin in the frozen source was audited under the assumption that each singleton is unique. Any move to K pots or N issuers requires a dedicated adversarial pass over the two-instances-per-transaction and cross-lane-substitution classes, plus new deploy-preflight invariants. Plausible is not proven; do not shard on the strength of this page.
Every layer in the plan corresponds to code that already runs in the reference implementation; the SDK work is extraction and typing, not invention.