cip-179
v0.5.0
Published
Reusable TypeScript building blocks for CIP-179 on-chain surveys and polls (label 17): the metadatum codec, pure domain semantics, the reproducible tally artifact, transaction-proof decoding, and the sealed-submission tlock stack.
Maintainers
Readme
cip-179
Reusable TypeScript building blocks for the
CIP-179 On-Chain Surveys and Polls format
(metadata label 17, spec version 5).
The package is organized as subpath entry points, layered by dependency weight. The root is a pure, side-effect-free codec with zero dependencies; the heavier layers add pure domain semantics, the reproducible tally, and the transaction-proof and sealed-submission stacks. The Cardano-serialization work those last two need is injected (a small port + an adapter), so no Cardano library is baked in: the evolution-sdk footprint is a swappable dependency, not a requirement. Any CIP-179 implementation — not just Tessera, on any Cardano stack — can build on these, interpret the same chain data, and produce hash-identical tally artifacts.
Entry points
| Import | What it is | Required peer deps |
| :------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------- |
| cip-179 | The label-17 codec: encode / decode / validate the metadatum format. | none |
| cip-179/domain | Pure semantics over on-chain records: dedupe, cancellation, credential proof, audit, answer rendering, survey aggregation. | none |
| cip-179/tally | The reference count / stake-weighted ruleset and the canonical, content-addressed tally artifact. | none |
| cip-179/content | Dereferencing content anchors: fetch a {uri, hash} document (racing IPFS gateways) and hash-verify it before returning. Does network I/O. | none |
| cip-179/txproof | Transaction CBOR → TxProof (mechanism-A/B evidence), over an injected TxProofCodec. Imports no Cardano library itself. | none¹ |
| cip-179/tlock | The sealed-submission stack for sealed_submission_mode: drand round math, timelock encrypt/decrypt, seal/reveal, over an injected MetadatumCodec. | @mattpiz/tlock-js |
| cip-179/evolution | An @evolution-sdk/evolution-backed implementation of the TxProofCodec and MetadatumCodec ports — inject it, or write your own. | @evolution-sdk/evolution |
¹ cip-179/txproof needs a TxProofCodec supplied at call time (see below); the
default one lives in cip-179/evolution.
@noble/hashes is a regular (small) dependency, so cip-179/tally and
cip-179/txproof pull it in automatically.
The Cardano-serialization seam (dependency injection)
The reusable layers (cip-179, /domain, /tally, and the interpretation in
/txproof and /tlock) contain no Cardano-serialization library. Everything
that needs one — decoding a transaction, canonical CBOR of a metadatum tree, the
bech32/CIP-129 id encodings — is expressed as a small port that the caller
injects an implementation of:
MetadatumCodec(cip-179/tlock) —metadatumToCbor/cborToMetadatum.TxProofCodec(cip-179/txproof) —stakeAddress/drepId/decodeTx(the last returns a library-neutralDecodedTx;cip-179keeps the CIP-179 interpretation — mechanism A/B, the Conway voter-tag semantics, native-script hashing).
cip-179/evolution is the only module that imports evolution-sdk; it ships a
ready evolutionCodec satisfying both ports. Consumers on the evolution stack
inject it:
import { decodeTxProof } from "cip-179/txproof";
import { sealAnswers, revealWithBeacon } from "cip-179/tlock";
import { evolutionCodec } from "cip-179/evolution";
const proof = decodeTxProof(evolutionCodec, txCborHex);
const sealed = await sealAnswers(evolutionCodec, answers, round, paddingSize);A downstream implementer on any other stack (Lucid, Mesh, CSL, …) provides
their own object satisfying MetadatumCodec / TxProofCodec, never imports
cip-179/evolution, and never installs evolution-sdk — while reusing all of the
CIP-179 interpretation logic unchanged.
@evolution-sdk/evolution and @mattpiz/tlock-js are declared as optional
peers, so codec / domain / tally consumers (and anyone bringing their own codec)
never install them. @mattpiz/tlock-js is lazy-imported inside the tlock
client, so a finalize/verify pass touches it only when a sealed survey is
actually present.
Workspace note. Inside this pnpm workspace the optional peers are satisfied by cip-179's own
devDependencies. A production-only install (pnpm install --prod) skips those, and peers of a symlinked workspace package do not resolve from the consumer'snode_modules— so a self-hosted Node deployment that usescip-179/tlockorcip-179/evolutionmust install with devDependencies included (or hoist the two packages). Registry consumers are unaffected: npm/pnpm resolve peers normally there.
The codec (cip-179)
The root export does three things, all without any I/O:
- Encode ergonomic domain types into a generic Cardano metadatum tree.
- Decode a metadatum tree back into domain types (total; throws
Cip179DecodeErrorwith a path on malformed input). - Validate the cross-field invariants the CDDL can't express (option bounds, abstain/required rules, points summing to budget, rating scales, …).
Library-agnostic by construction
The codec never depends on a specific Cardano library and never touches CBOR.
Its interchange type is a generic Metadatum, the
universal on-the-wire shape of transaction_metadatum:
type Metadatum =
| bigint // int
| string // text
| Uint8Array // bytes
| ReadonlyArray<Metadatum> // array
| ReadonlyMap<Metadatum, Metadatum>; // mapencodePayload / encodeMetadata produce this tree; hand it to whatever
library you use (evolution-sdk, Lucid, Mesh, CSL, …) to serialize to CBOR.
decodePayload / decodeMetadata consume the same tree, whatever library
parsed the CBOR. Maps are emitted with integer keys in ascending order so an
order-preserving encoder yields the RFC 8949 §4.2 canonical CBOR the CIP
requires.
Numeric convention
bigintfor integers a survey can set to any size: numeric-range bounds/values, rating-grid bounds/values, points budgets and allocations, and integers inside custom answers (aMetadatumint is always abigint).numberfor structural integers: tags, flags, indices, option and level counts, selection and ranking bounds, epochs, roles, drand round, padding size.
This is stricter than the CIP, whose CDDL bounds none of the second group.
The decoder refuses a record carrying one of them above 2^53, so a survey
definition like that is untalliable and a response like that is not counted.
None can usefully exceed 2^53: the CIP bounds some (tags, flags, the
survey_ref index), a transaction's size bounds others (a response has to list
that many entries), and the rest describe an epoch, a drand round or an option
count that cannot occur. The tally ruleset states the rule, so every
implementation reaches the same verdict.
Chunked text / bytes
Long titles, descriptions, prompts and tlock ciphertext are exposed as plain
string / Uint8Array. Chunking into ≤64-byte pieces (CIP-20 style) happens
only at encode time; decoding rejoins. Text is split on code-point boundaries so
chunks are always valid UTF-8.
What codec validation does not cover
validateDefinition / validateResponse are pure and check only what's
determinable from the data itself. Everything requiring ledger state is left to
the cip-179/domain layer (fed by an indexer with chain access): credential
proofs (required_signers / voting_procedures), role membership, epoch
cutoffs, cancellation status, latest-wins deduplication, and external-anchor
fetch/hash verification.
Usage
import {
encodeMetadata,
decodeMetadata,
validateDefinition,
Role,
type Cip179Payload,
} from "cip-179";
const payload: Cip179Payload = {
type: "definitions",
definitions: [
{
specVersion: 5,
owner: { type: "key", keyHash: ownerKeyHash /* Uint8Array(28) */ },
title: "Dijkstra hard-fork CIP shortlist",
description: "Select candidate CIPs for the Dijkstra hard fork.",
eligibleRoles: [Role.DRep],
endEpoch: 504,
submissionMode: { type: "public" },
questions: [
{
type: "multiSelect",
prompt: "Which CIPs should be shortlisted?",
options: { type: "options", labels: ["CIP-0108", "CIP-0119"] },
minSelections: 1,
maxSelections: 2,
},
],
},
],
};
const problems = validateDefinition(payload.definitions[0]);
if (problems.length) throw new Error(problems.join("; "));
// Generic metadatum map { 17 => payload }; serialize with any Cardano library.
const metadatum = encodeMetadata(payload);
// …later, after some library parses the CBOR back into a Metadatum:
const decoded = decodeMetadata(metadatum);CBOR (not included in the codec, by design)
The codec stops at the metadatum tree. If you need canonical CBOR bytes directly (e.g. to hash a payload for dedup), two options:
- Use your existing Cardano library's metadatum serializer (it must emit RFC 8949 canonical maps — most do for integer keys in insertion order).
- Add a small dependency-free canonical encoder for this five-type subset. A
lightweight general CBOR lib such as
cborgalso works. Note: evolution-sdk does not use an external CBOR library; it hand-rolls its own, so there is nothing to "share".
The domain layer (cip-179/domain)
Pure functions over the raw, decoded on-chain record shapes (SurveyRecord,
ResponseRecord, CancellationRecord, TxProof, ChainTip, SurveyBundle,
…). It implements the parts of CIP-179 that need chain data but not fetching:
latest-wins dedupe, owner-proven cancellation, mechanism-A/B credential proof,
response audit, answer rendering, and survey aggregation / lifecycle status.
The aggregate carries the two verdicts a definition alone decides: talliable
(spec-valid enough to count) and sealedUnsupported (sealed on a drand chain
other than quicknet, so never revealable — block responding, expect no
artifact); isSurveyTalliable and isSealedUnsupported answer the same from a
bare record or definition.
How the records are fetched is deliberately out of scope — that seam is application-specific. The record shapes are the input contract, so a Koios scan, a semantic indexer, or any other source can feed the same domain logic.
The tally & artifact (cip-179/tally)
The count and stake-weighted tally rules, the JSON-safe wire codec
(toJsonSafe / fromJsonSafe: bytes→hex, bigint→decimal string, Map→tagged
pairs), the canonical-JSON (RFC 8785 / JCS subset) encoding, and the
content-addressed tally artifact.
JsonSafe<T> is the type of a T after toJsonSafe and a trip through JSON,
so a producer can type what it sends against the interface its consumer decodes
into. fromJsonSafe is lenient by design — it rebuilds tagged values wherever
they appear and returns unknown. A record received over a wire goes through
decodeSurveyRecord, decodeResponseRecord or decodeCancellationRecord
instead: the same rebuild followed by a structural check of every field the
record type declares, throwing Cip179DecodeError with the offending path, so
a shape mismatch is a decode error at the boundary and not a crash later.
The artifact is content-addressed: RULESET_DESCRIPTOR names the exact
rules applied (covered roles, per-role weight measures, dedup/window/proof
rules, sealed-reveal handling), and rulesetHash() is the blake2b-256 of its
canonical JSON. Two implementations that apply the same rules to the same chain
data produce byte-identical artifacts and the same hash.
Interim spec status & compatibility
The artifact format is not yet part of the CIP — it is currently driven by
Tessera, pending specification and integration into CIP-179. Until then this
package is the normative description, and an emitted artifact is re-verified by
installing the cip-179 version whose rulesetHash matches the artifact's
recorded hash:
| cip-179 version | CIP-179 spec version | ruleset version | rulesetHash() |
| :---------------- | :------------------- | :-------------- | :----------------------------------------------------------------- |
| 0.1.0 | 4 | 3 | c5b2b4284db26af358ed084373cc0786b15e4f58bc27c4f82e769d16ba878eee |
| 0.2.0 | 5 | 4 | 64efbd0fb3614348e5c2620275baa9f9eb3e274e4ae9fa46d7fb9f8643fd24bc |
| 0.3.0 | 5 | 12 | c11a980bc23a6fdfb8fb5878d4764225dc46b1a2010b43da8c68b918cf7bbc97 |
| 0.5.0 | 5 | 13 | 38a4367b6ffe50ad032bc319cf43f017ff231fe7ea1d63cc611d6386ac9c6259 |
When the rules change, the ruleset version and hash change; add a new row rather than editing an existing one, so old artifacts stay re-verifiable against the matching release.
The txproof stack (cip-179/txproof)
decodeTxProof(codec, txCborHex) turns an already-fetched transaction's CBOR
into a TxProof: the mechanism-A evidence (required signers + witnessed native
scripts) and mechanism-B evidence (governance vote bindings) that a credential
proof is checked against. It imports no Cardano library — the transaction is
decoded to a neutral DecodedTx by the injected TxProofCodec, and this module
owns only the CIP-179 interpretation. The same port carries the bech32
stakeAddress / drepId id encoders. Inject cip-179/evolution or your own
codec (see the seam above).
The tlock stack (cip-179/tlock)
The sealed-submission (sealed_submission_mode) stack: drand quicknet
round/time math, the timelock encrypt/decrypt client, response padding, the CBOR
envelope, and seal/reveal orchestration. Only drand quicknet is supported.
sealAnswers / revealWithBeacon / revealResponses take a MetadatumCodec
(the metadatum ↔ CBOR seam is injected, not imported). Requires
@mattpiz/tlock-js for the timelock crypto; inject cip-179/evolution or your
own codec for the CBOR.
Development
pnpm install
pnpm type-check
pnpm test
pnpm build # cleans + emits dist/ (.js + .d.ts + maps) for every subpathIn the workspace the package is consumed straight from src (the exports
map points at TypeScript source, like every sibling package), so cross-package
edits are live with no build step. dist/ is only produced for publishing:
publishConfig.exports swaps the map to the compiled output at pnpm publish
time, and prepublishOnly rebuilds it from clean.
Layout
| Path | Subpath | Purpose |
| :--------------- | :------------------ | :-------------------------------------------------------------------- |
| src/*.ts | cip-179 | Codec: metadatum model, constants, types, encode/decode/validate. |
| src/domain/ | cip-179/domain | On-chain record shapes + pure domain semantics. |
| src/tally/ | cip-179/tally | Reference ruleset, wire/canonical codecs, content-addressed artifact. |
| src/content/ | cip-179/content | Content-anchor dereferencing: gateway race, hash verification. |
| src/txproof/ | cip-179/txproof | TxProof interpretation + the TxProofCodec port. |
| src/tlock/ | cip-179/tlock | Sealed-submission (drand tlock) stack + the MetadatumCodec port. |
| src/evolution/ | cip-179/evolution | evolution-sdk adapter: evolutionCodec implementing both ports. |
