Arkiv Ideathon · Challenge 3 · DeFi
Selisih is Indonesian for the difference between two numbers. Berselisih is to be in dispute. One word for both halves of this product.
A multi-witness flight recorder for DeFi risk state. Independent watchers each publish their own signed snapshot of the same lending market — and the product is not the snapshot, it’s the disagreement between them.
$creator, and the gap is checkable against the roster that was live at round 812.The nearest neighbours are oracle networks, so the difference is worth stating plainly rather than letting it be assumed away. Chainlink aggregates many sources into one feed. Pyth does the same at high frequency. UMA takes a proposed value, opens a challenge window, and escalates disputes to a token vote that returns a resolved answer. Different mechanisms, one shared purpose: collapse N observations into 1 value something downstream can settle against.
| Property | Oracle networks | SELISIH |
|---|---|---|
| Output | one canonical value | N attributable readings, kept apart |
| Disagreement is | a failure to resolve away | the product |
| Settles anything? | yes, that is the point | no, ever |
| Slashing / token vote | central to the design | absent by construction |
| Position | on the hot path | explicitly off it |
An oracle returning seven different numbers has malfunctioned. SELISIH returning seven different numbers is SELISIH working.
The median is drawn as a faint dashed line and is never written anywhere. The moment a canonical value exists, something builds on it — and SELISIH is on a hot path it was designed to stay off. What it does borrow honestly is the dispute window: UMA runs a 48-hour liveness period, and 72 hours is chosen as the snapshot lifetime for the same reason. It is roughly how long a challenge to market evidence stays live.
$creator on it. Script and raw output ship with the submission; hover any mark for its values.NewTransmission and publishes only the median — so the disagreement SELISIH exists to keep is already signed and on-chain, and discarded. At block 20,458,998 the 31 nodes saw $2,141 to $2,335 and the feed said $2,233.80; six nodes were more than 200 bps from that number. Median node spread is 15× higher under stress; the maximum is 85×. Day one, SELISIH’s witnesses are these nodes, decoded — nothing to recruit. Hover any bar for its round.When a lending market has a bad day, the post-mortem comes from the protocol’s own logs — written by the party the investigation is about, from a database it controls, weeks later. Risk committees deciding on reimbursement, underwriters settling a claim, and liquidated borrowers all need the same thing and none of them can get it.
The gap is not that there is no data. It is that there is no data anyone on the other side of the dispute will accept.
Seven witnesses reporting the same number is noise. One reporting something else is a signal — a bad feed, a partial view, or an agenda. The product surfaces the spread, not the value, and every reading is attributable.
A witness registration is alive only while it keeps being renewed. So “w7 did not report in round 812” is a checkable fact rather than a missing row. A watcher that goes quiet exactly when a market blows up cannot hide it.
Cost is size × lifetime, so a witness paying for ninety days on a reading is staking money on being right. And a witness letting a damning reading lapse is making a visible choice. Both are queryable.
Arkiv permits only an entity’s owner to update, delete or extend it. A first draft had a dispute reaching in and extending every witness’s snapshot; that is simply not possible, and finding out reshaped the design around a better idea.
Retention is never done to evidence by an interested party. Each witness funds its own lifetime, priced in storage, which turns conviction into the numeric attribute fundedDays. Everyone else preserves what they care about by writing their own EvidencePin — their entity, their cost, their name on it — carrying the copy and the original’s tx hash. Expiry removes an entity from the query surface without erasing on-chain history, so a pin stays checkable against the original write long after the original has left the index.
Nobody can destroy someone else’s evidence. Nobody needs permission to save it.
There is a hole big enough to hollow the whole product out: the cheapest way to be a witness is to read what witness 1 published and copy it. A copier costs nothing to run, never diverges, never gets flagged, and quietly turns seven independent observers into one observer and six mirrors — while the board keeps reporting reassuring agreement.
Consensus produced by copying looks exactly like consensus produced by correctness.
So each round runs in two beats. Witnesses first write a tiny Commit carrying only a salted hash; once the commit window closes they publish the snapshot with the salt. Arkiv’s own guidance is to keep secrets off and store commitments instead — nothing confidential is written, and the reading is public a minute later anyway. A witness cannot copy a number nobody has revealed, and one that reveals a value not matching its commit has published a discrepancy under its own key that anyone can check forever.
The cost is one cheap write per round and a minute of latency — affordable precisely because SELISIH is off the execution hot path. On a hot path this would be unacceptable; here it is free.
RosterEpoch exists because registrations expire in days while disputed snapshots live for months — so “who was on the roster in round 812?” cannot be answered by querying live registrations. It stores that fact while it is still true. The orange edge is the only write that touches an existing entity: a dispute extending the evidence it is about.String attributes support eq() only; ranges need integers. Results come back newest-first with no server-side ordering, so every filter below is narrow enough that a page is a real answer rather than an arbitrary slice.
| Screen | Predicate | Why it holds |
|---|---|---|
| Divergence board | eq(market, M) ∧ eq(round, 812) |
Bounded by roster size, not position count. Needs no ordering at all. |
| Who is missing | count of the above vs count of live registrations |
Two counts, no fetch. The dashboard never pulls what it only needs to tally. |
| Witness track record | eq(witness, 0x…) ∧ gte(severityTier, 3)and eq(vindicatedWitness, 0x…) |
Two numbers, never one. Times it broke from its peers, and times it broke from them and was right — because a reputation query that only counts disagreement punishes the best witness in the system. |
| Conviction | eq(market, M) ∧ gte(fundedDays, 30) |
Readings their own authors paid to keep well past the dispute window. |
| Danger scan | eq(market, M) ∧ lt(healthFactorBps, 10500) ∧ gte(round, N) |
Health factor is an integer in basis points. As text it would be unqueryable. |
| Open disputes → pin queue | eq(statusCode, 0) ∧ gte(deadlineTs, now), then lt(expiresAtTs, deadlineTs) |
The readings that will lapse before the dispute they matter to is resolved. That list is the call to action: anyone who cares can pin them, at their own cost, before they go. |
| Incident pull | eq(market, M) ∧ gte(observedTs, T0) ∧ lte(observedTs, T1) |
Cursor-paginated. Verifiable by the reader without asking the protocol for anything. |
Every primitive below does real work. The two that are not used are listed too, because leaving them out was a decision.
| Primitive | Where it does work |
|---|---|
$creator | Attribution on every reading — the reputation primitive. Immutable, so a track record cannot be sold. |
$owner · changeOwnership | Operational control of a registration, separated from authorship. Keys rotate and desks get acquired; authorship does not move. |
| Owner-only extension | The constraint the design is built on. Nobody can preserve or destroy someone else’s evidence — so each party funds what it believes. |
| Cost = size × lifetime | Turned into fundedDays: conviction priced in storage, and filterable. A conventional database charges the writer nothing for keeping a row, so this signal cannot exist there. |
expiresIn | Five differentiated lifetimes: 72h floor snapshots · 7d registrations · window+30d disputes · 1y roster epochs · pinner-funded pins. |
extendEntity | The witness heartbeat, and a witness choosing to stand behind its own reading. |
mutateEntities | Snapshot plus roster update in one batch, chunked under the 1000-operation cap. |
updateEntity | One entity type only — Dispute — where a single writer makes full-replace safe. |
deleteEntity | Deliberately unused. A system where evidence can be removed on request is not an evidence system. |
| Numeric attributes | Prices ×1e8, debt ×1e6, health in basis points. The scale is in the attribute name. |
| String attributes | market, witness, kind — enumerated slugs, never free text, because eq() is all they support. |
| Counts · cursors | “Who is missing” without fetching rows; cursor pagination on the incident pull. |
| Tx hashes | Rendered beside each reading, so the reader verifies rather than trusts. |
| Hash commitments | The commit round. Arkiv’s guidance is to store commitments, not secrets — a digest is exactly that. |
| Project namespace | project: "selisih" on every entity and first in every predicate. A shared public database has no other separation, and type: "snapshot" is a word other projects will reach for too. |
Who pays. Witnesses pay for their own readings — a cost the writer bears is what makes fundedDays mean anything. Pinners pay for pins. The roster epoch is written once a day by whoever runs the board. Nobody pays for anyone else’s opinion, which is the only reason the cost signal is readable at all.
The roadmap says the phase after Devcon 8 is decided with the teams using Arkiv, and the current architecture came out of hackathon feedback. So here is the feedback this design actually earned — from friction, not from a wishlist.
EvidencePin exists only because a reader who wants a reading preserved must duplicate it rather than fund it, storing the same bytes twice. Letting a non-owner pay to extend, while gaining no right to modify, would remove that duplication and keep every guarantee intact.$creator, but “was this attribute backdated relative to when it was written?” needs the transaction receipt, entity by entity. As a numeric system attribute it would be a range predicate, and consistency checking across a whole register would become a query instead of an audit.Neither is needed for SELISIH to work as designed. Both would make it smaller.
Remove any one and SELISIH is a worse Grafana.
The fourth pillar, stated precisely. Block production stays centralised through November 2026 per the roadmap. SELISIH relies on the Ethereum anchor, not on decentralised sequencing: the operator can order or refuse writes but cannot rewrite an anchored $creator or tx hash without it showing against L1. Verifiability now; censorship-resistance is a later phase and is not claimed.
sourceHash.Offline design, not a deployment — the fundamentals say sketching with @arkiv-network/sdk is fine. It type-checks with tsc --strict against the published @arkiv-network/sdk@0.7.0 package — installed from npm, not summarised from docs. That check corrected the first draft in four places: the API is client-based (publicClient.select().where().limit().fetch(), operators from @arkiv-network/sdk/query), attributes are an array of {key, value}, contentType is required, and select() returns a projected type rather than a full Entity. It also surfaced .createdBy(), not(key), validAtBlock(), createdAtBlock metadata, and cost in the on-chain events — none of which the summary docs mention.
// SELISIH โ entity-model sketch, type-checked against the REAL @arkiv-network/sdk@0.7.0 package.
// Offline design only: no client is ever connected here, and nothing is deployed.
// `tsc --strict` passes against the published types (output reproduced in the write-up).
import { createPublicClient, createWalletClient, type Attribute } from "@arkiv-network/sdk";
import { eq, gte, lte, lt, not, type Predicate } from "@arkiv-network/sdk/query";
import type { Hex } from "viem";
// Clients are constructed by the app, never here. Types only.
type Pub = ReturnType<typeof createPublicClient>;
type Wal = ReturnType<typeof createWalletClient>;
type HasAttrs = { readonly attributes: Attribute[] }; // select() returns a PROJECTED type, not full Entity
// ---------- constants ----------
const APP = "selisih"; // project namespace โ on EVERY entity, first in EVERY predicate
const CT = "application/json"; // contentType is required by CreateEntityParameters
const SNAPSHOT_FLOOR_SEC = 259_200; // 72h floor; the witness may fund longer โ that is the bet
const COMMIT_SEC = 86_400; // 24h
const REGISTRATION_SEC = 604_800; // 7d heartbeat; lapsing IS deregistration (emits ArkivEntityExpired)
const ROSTER_EPOCH_SEC = 31_536_000; // 1y
const PIN_DEFAULT_SEC = 31_536_000; // 1y, pinner chooses and pays
const PAGE = 200; // SDK hard cap per page
// expiresIn must be a positive multiple of 2 (2-second blocks) or the SDK throws InvalidExpirationError
const even = (s: number) => (s % 2 === 0 ? s : s + 1);
// attributes are an ARRAY of { key, value }; value is string | number (numbers must be integers)
const attrs = (o: Record<string, string | number>): Attribute[] => Object.entries(o).map(([key, value]) => ({ key, value }));
const attr = (e: HasAttrs, key: string) => e.attributes.find(a => a.key === key)?.value;
const enc = (v: unknown) => new TextEncoder().encode(JSON.stringify(v));
const now = () => Math.floor(Date.now() / 1000);
// `.count()` in 0.7.0 is the length of ONE page (โค200). For anything that can exceed a page, sum pages.
async function countAll(p: Pub, preds: Predicate[]) {
const res = await p.select({ key: true }).where(preds).limit(PAGE).fetch();
let total = res.entities.length;
while (res.hasNextPage()) { await res.next(); total += res.entities.length; } // next() mutates in place
return total;
}
// ---------- 1. commit โ reveal (closes the copy-the-leader hole) ----------
export async function commit(w: Wal, market: string, round: number, digest: string) {
return w.createEntity({
payload: new Uint8Array(0), contentType: CT, expiresIn: COMMIT_SEC,
attributes: attrs({ project: APP, kind: "commit", market, round, digest, committedTs: now() }),
});
}
export async function reveal(w: Wal, s: {
market: string; round: number; blockNumber: number; observedTs: number;
priceE8: number; healthFactorBps: number; totalDebtE6: number; collateralE6: number;
atRiskCount: number; deviationBps: number; severityTier: 0 | 1 | 2 | 3 | 4; sourceHash: string;
salt: string; supersedesRound?: number;
}, topK: unknown, fundedDays = 3) {
const expiresIn = even(Math.max(SNAPSHOT_FLOOR_SEC, fundedDays * 86_400));
const { supersedesRound, ...rest } = s;
// No `witness` attribute: the SDK exposes `.createdBy()` and `entity.creator` natively, so mirroring
// $creator into an attribute is redundant. The earlier draft did that and was wrong.
// The cost actually paid for `expiresIn` is emitted on-chain in ArkivEntityCreated(..., cost) โ
// so `fundedDays` is checkable against the event, not merely self-reported.
return w.createEntity({
payload: enc(topK), contentType: CT, expiresIn,
attributes: attrs({ project: APP, kind: "snapshot", ...rest, fundedDays, expiresAtTs: now() + expiresIn,
...(supersedesRound !== undefined ? { supersedesRound } : {}) }),
});
}
// ---------- 2. the divergence board โ Q1 + Q2 ----------
export async function divergence(p: Pub, market: string, round: number) {
const res = await p.select({ key: true, creator: true, attributes: true, expiresAtBlock: true, createdAtBlock: true })
.where(eq("app", APP), eq("kind", "snapshot"), eq("market", market), eq("round", round),
not("supersedesRound")) // `not(key)` = attribute ABSENT โ originals only
.limit(PAGE).fetch();
const rows = res.entities; // bounded by roster size โ one page holds it
const hf = rows.map(r => Number(attr(r, "healthFactorBps"))).sort((a, b) => a - b);
const median = hf[Math.floor(hf.length / 2)]; // computed client-side and NEVER written
const registered = await p.select({ key: true })
.where(eq("app", APP), eq("kind", "witness"), eq("market", market)).limit(PAGE).count();
return { rows, median, missing: registered - rows.length };
}
// ---------- 3. reputation as two numbers โ Q3 (native createdBy, not a mirrored attribute) ----------
export async function trackRecord(p: Pub, witness: Hex) {
const broke = await countAllBy(p, witness, [eq("app", APP), eq("kind", "snapshot"), gte("severityTier", 3)]);
const vindicated = await countAll(p, [eq("app", APP), eq("kind", "resolution"), eq("vindicatedWitness", witness)]);
return { broke, vindicated }; // one number alone punishes the best witness
}
async function countAllBy(p: Pub, creator: Hex, preds: Predicate[]) {
const res = await p.select({ key: true }).createdBy(creator).where(preds).limit(PAGE).fetch();
let total = res.entities.length;
while (res.hasNextPage()) { await res.next(); total += res.entities.length; }
return total;
}
// ---------- 4. pin queue โ Q5: readings that lapse before their dispute resolves ----------
export async function pinQueue(p: Pub, market: string, roundFrom: number, roundTo: number, deadlineTs: number) {
const res = await p.select({ key: true, creator: true, attributes: true, payload: true })
.where(eq("app", APP), eq("kind", "snapshot"), eq("market", market),
gte("round", roundFrom), lte("round", roundTo), lt("expiresAtTs", deadlineTs))
.limit(PAGE).fetch();
return res.entities;
}
// Only the OWNER may extend, so a reader preserves evidence by writing their OWN entity.
export async function pin(w: Wal, original: HasAttrs & { readonly creator: Hex; readonly payload: Uint8Array },
originTxHash: Hex, expiresIn = PIN_DEFAULT_SEC) {
return w.createEntity({
payload: original.payload, contentType: CT, expiresIn: even(expiresIn),
attributes: attrs({ project: APP, kind: "pin", market: String(attr(original, "market")),
round: Number(attr(original, "round")), pinnedWitness: original.creator,
originTxHash, pinnedTs: now(), expiresAtTs: now() + expiresIn }),
});
}
// ---------- 5. heartbeat โ a witness stands behind its own registration ----------
export async function heartbeat(w: Wal, registrationKey: Hex) {
return w.extendEntity({ entityKey: registrationKey, expiresIn: REGISTRATION_SEC });
}
// ---------- 6. daily roster epoch โ ONE atomic tx ----------
export async function rosterEpoch(w: Wal, market: string, epoch: number, roundFrom: number, roundTo: number, witnesses: Hex[]) {
return w.mutateEntities({ // โค1000 ops per tx; chunk beyond that
creates: [{
payload: enc(witnesses), contentType: CT, expiresIn: ROSTER_EPOCH_SEC,
attributes: attrs({ project: APP, kind: "roster", market, epoch, roundFrom, roundTo, witnessCount: witnesses.length }),
}],
});
}
// ---------- 7. "who was on the roster at round 812?" โ the upgrade path ----------
// The builder exposes validAtBlock(). IF the network serves historical state there, this replaces
// RosterEpoch entirely. Whether expired entities are returned at a past block is not documented,
// so the design keeps RosterEpoch and treats this as the upgrade path โ not as a promise.
export async function rosterAtBlock(p: Pub, market: string, block: bigint) {
const res = await p.select({ key: true, creator: true })
.where(eq("app", APP), eq("kind", "witness"), eq("market", market))
.validAtBlock(block).limit(PAGE).fetch();
return res.entities.map(e => e.creator);
}
The sketch code above runs, unchanged, against MemArkiv — an executable specification of the twelve Arkiv rules this design depends on, each cited to the SDK source or the fundamentals. It is not Arkiv and claims nothing about performance; it is the referee for the design’s logic. Eight SELISIH invariants, all passing, offline, in under a second. The first run of one of them failed because the test was wrong and the design was right; that is left in as a comment.
// Executable invariants for LAYAK and SELISIH โ runs the real sketch code (type-checked against
// @arkiv-network/sdk@0.7.0) against MemArkiv, an executable spec of the documented semantics.
// Run: node --test dist/invariants.test.js (after tsc). Nothing here touches a network.
import { test } from "node:test";
import assert from "node:assert/strict";
import { MemArkiv, NotOwnerError, InvalidExpirationError } from "./memarkiv.js";
import * as L from "../layak.sketch.js";
import * as S from "../selisih.sketch.js";
import type { Hex } from "viem";
const INSPECTOR = "0x1111111111111111111111111111111111111111" as Hex;
const CONTRACTOR = "0x2222222222222222222222222222222222222222" as Hex;
const BUYER = "0x3333333333333333333333333333333333333333" as Hex;
const W = (n: number) => ("0x" + String(n).repeat(40)) as Hex;
const any = (x: unknown) => x as any; // MemArkiv is structurally the surface the sketches use; the SDK's client type is a viem client
// ============================== LAYAK ==============================
test("LAYAK-1: an expired certificate is not returned โ with no date filter anywhere", async () => {
const db = new MemArkiv(INSPECTOR);
await L.recordExamination(any(db), { assetId: "A-4471", siteId: "S1", certType: "SLO-angkat", bodyId: "PJK3-7", examRecordId: "E1", regimeCode: 2, outcomeCode: 0, defectCount: 0, testRatioBps: 12500, reportHash: "0xabc" });
// First run of this test expected RED here and FAILED: the sketch returns AMBER โ certified, but no Asset entity
// claims responsibility. That is the designed behaviour (ยง9, orphaned certificate); the test expectation was wrong.
assert.equal((await L.gateCheck(any(db), "A-4471", "SLO-angkat")).code, "AMBER_UNCLAIMED", "certified but unclaimed โ amber, never green");
db.createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 63_072_000, attributes: [{ key: "app", value: "layak" }, { key: "kind", value: "asset" }, { key: "assetId", value: "A-4471" }] });
assert.equal((await L.gateCheck(any(db), "A-4471", "SLO-angkat")).code, "GREEN");
db.advanceSeconds(31_536_000 + 2); // one year + one block
assert.equal((await L.gateCheck(any(db), "A-4471", "SLO-angkat")).code, "RED_NO_CERT", "lifetime lapsed โ the row no longer exists to be returned");
assert.ok(db.events.some(e => e.name === "ArkivEntityExpired"), "expiry is an on-chain event, not a silent row state");
});
test("LAYAK-2: a FAILED exam writes the statutory record and NO certificate, atomically", async () => {
const db = new MemArkiv(INSPECTOR);
const r = await L.recordExamination(any(db), { assetId: "A-9", siteId: "S1", certType: "SLO-angkat", bodyId: "B", examRecordId: "E9", regimeCode: 2, outcomeCode: 2, defectCount: 3, testRatioBps: 12500, reportHash: "0x" });
assert.equal(r.createdEntities.length, 1);
const exams = await db.select().where({ type: "eq", key: "kind", value: "exam" }).fetch();
const certs = await db.select().where({ type: "eq", key: "kind", value: "cert" }).fetch();
assert.equal(exams.entities.length, 1); assert.equal(certs.entities.length, 0);
assert.equal((await L.gateCheck(any(db), "A-9", "SLO-angkat")).code, "RED_NO_CERT", "absence is the fail state โ the same absence as expiry");
});
test("LAYAK-3: the record outlives the certificate; the two lifetimes are opposite", async () => {
const db = new MemArkiv(INSPECTOR);
await L.recordExamination(any(db), { assetId: "A-1", siteId: "S1", certType: "SLO-angkat", bodyId: "B", examRecordId: "E1", regimeCode: 2, outcomeCode: 0, defectCount: 0, testRatioBps: 12500, reportHash: "0x" });
db.advanceSeconds(31_536_000 + 2);
const certs = await db.select().where({ type: "eq", key: "kind", value: "cert" }).fetch();
const exams = await db.select().where({ type: "eq", key: "kind", value: "exam" }).fetch();
assert.equal(certs.entities.length, 0, "certificate gone");
assert.equal(exams.entities.length, 1, "statutory record still served โ LOLER/riksa uji retention duty");
});
test("LAYAK-4: only the owner can extend โ and an owner extending a cert is CAUGHT by Q8", async () => {
const db = new MemArkiv(INSPECTOR);
await L.recordExamination(any(db), { assetId: "A-1", siteId: "S1", certType: "SLO-angkat", bodyId: "B", examRecordId: "E1", regimeCode: 2, outcomeCode: 0, defectCount: 0, testRatioBps: 12500, reportHash: "0x" });
const cert = (await db.select().where({ type: "eq", key: "kind", value: "cert" }).fetch()).entities[0];
await assert.rejects(async () => db.as(CONTRACTOR).extendEntity({ entityKey: cert.key, expiresIn: 63_072_000 }), NotOwnerError, "the contractor cannot extend a certificate it does not own");
assert.deepEqual(await L.extensionAnomalies(any(db), "A-1"), [], "clean before");
// the corrupt inspector CAN extend their own entity โ the protocol allows it (R3) โ but cannot do it in secret:
db.extendEntity({ entityKey: cert.key, expiresIn: 63_072_000 });
// the mirrored expiresAtTs must be rewritten for the extension to be useful on the renewal queue; a forger updating it exposes the lie
db.updateEntity({ entityKey: cert.key, payload: cert.payload, contentType: cert.contentType, expiresIn: 63_072_000,
attributes: cert.attributes.map(a => a.key === "expiresAtTs" ? { key: a.key, value: (a.value as number) + 31_536_000 } : a) });
assert.deepEqual(await L.extensionAnomalies(any(db), "A-1"), ["E1"], "certificate living longer than its own examination justifies โ flagged, by anyone, without permission");
});
test("LAYAK-5: a live certificate with a lapsed Asset is AMBER, not GREEN", async () => {
const db = new MemArkiv(INSPECTOR);
db.createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 60, attributes: [{ key: "app", value: "layak" }, { key: "kind", value: "asset" }, { key: "assetId", value: "A-1" }] });
await L.recordExamination(any(db), { assetId: "A-1", siteId: "S1", certType: "SLO-angkat", bodyId: "B", examRecordId: "E1", regimeCode: 2, outcomeCode: 0, defectCount: 0, testRatioBps: 12500, reportHash: "0x" });
assert.equal((await L.gateCheck(any(db), "A-1", "SLO-angkat")).code, "GREEN");
db.advanceSeconds(62);
assert.equal((await L.gateCheck(any(db), "A-1", "SLO-angkat")).code, "AMBER_UNCLAIMED", "certified, but nobody is renewing responsibility for the machine");
});
test("LAYAK-6: a Prohibition turns the gate red without touching the certificate (no triggers exist)", async () => {
const db = new MemArkiv(INSPECTOR);
db.createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 63_072_000, attributes: [{ key: "app", value: "layak" }, { key: "kind", value: "asset" }, { key: "assetId", value: "A-1" }] });
await L.recordExamination(any(db), { assetId: "A-1", siteId: "S1", certType: "SLO-angkat", bodyId: "B", examRecordId: "E1", regimeCode: 2, outcomeCode: 0, defectCount: 0, testRatioBps: 12500, reportHash: "0x" });
const pro = db.createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 86_400, attributes: [{ key: "app", value: "layak" }, { key: "kind", value: "prohibition" }, { key: "assetId", value: "A-1" }] });
assert.equal((await L.gateCheck(any(db), "A-1", "SLO-angkat")).code, "RED_PROHIBITION");
db.advanceSeconds(86_402); // prohibition lapses (or the inspector lifts it) โ green again, cert untouched
assert.equal((await L.gateCheck(any(db), "A-1", "SLO-angkat")).code, "GREEN");
assert.ok(pro.entityKey);
});
test("LAYAK-7: resale moves $owner and keeps $creator on every certificate", async () => {
const db = new MemArkiv(INSPECTOR);
const asset = db.as(CONTRACTOR).createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 63_072_000, attributes: [{ key: "app", value: "layak" }, { key: "kind", value: "asset" }, { key: "assetId", value: "A-1" }] });
await L.recordExamination(any(db), { assetId: "A-1", siteId: "S1", certType: "SLO-angkat", bodyId: "B", examRecordId: "E1", regimeCode: 2, outcomeCode: 1, defectCount: 2, testRatioBps: 12500, reportHash: "0x" });
await L.sell(any(db.as(CONTRACTOR)), asset.entityKey, BUYER);
const a = (await db.select().where({ type: "eq", key: "kind", value: "asset" }).fetch()).entities[0];
const e = (await db.select().where({ type: "eq", key: "kind", value: "exam" }).fetch()).entities[0];
assert.equal(a.owner, BUYER); assert.equal(a.creator, CONTRACTOR); assert.equal(e.creator, INSPECTOR, "the past was never the seller's to leave behind");
});
test("LAYAK-8: .count() is one page โ complianceGap sums pages, so 350 assets are 350, not 200", async () => {
const db = new MemArkiv(CONTRACTOR);
for (let i = 0; i < 350; i++) db.createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 63_072_000, attributes: [{ key: "app", value: "layak" }, { key: "kind", value: "asset" }, { key: "assetId", value: "A-" + i }, { key: "siteId", value: "S1" }] });
const naive = await db.select().where({ type: "eq", key: "kind", value: "asset" }).limit(200).count();
assert.equal(naive, 200, "the naive count the first draft relied on");
const gap = await L.complianceGap(any(db), "S1", "SLO-angkat");
assert.equal(gap.assets, 350); assert.equal(gap.gap, 350);
});
test("LAYAK-9: an odd expiresIn is rejected (2-second blocks)", () => {
const db = new MemArkiv(INSPECTOR);
assert.throws(() => db.createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 31_536_001, attributes: [] }), InvalidExpirationError);
});
// ============================== SELISIH ==============================
const snap = (round: number, hf: number, tier: 0|1|2|3|4 = 0) => ({ market: "aave-v3-eth-wsteth", round, blockNumber: 20_459_000, observedTs: 1_722_800_000, priceE8: 213_928_000_000, healthFactorBps: hf, totalDebtE6: 1, collateralE6: 1, atRiskCount: 0, deviationBps: 0, severityTier: tier, sourceHash: "0x", salt: "s" });
test("SELISIH-1: divergence board returns a SET per round, one row per witness, outlier attributable by creator", async () => {
const db = new MemArkiv(W(1));
for (const [w, hf] of [[1, 10420], [2, 10420], [3, 10420], [4, 9980], [5, 10420]] as const) {
await S.reveal(any(db.as(W(w))), snap(812, hf, w === 4 ? 4 : 0), []);
db.as(W(w)).createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 604_800, attributes: [{ key: "app", value: "selisih" }, { key: "kind", value: "witness" }, { key: "market", value: "aave-v3-eth-wsteth" }] });
}
db.as(W(7)).createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 604_800, attributes: [{ key: "app", value: "selisih" }, { key: "kind", value: "witness" }, { key: "market", value: "aave-v3-eth-wsteth" }] });
const d = await S.divergence(any(db), "aave-v3-eth-wsteth", 812);
assert.equal(d.rows.length, 5); assert.equal(d.median, 10420); assert.equal(d.missing, 1, "6 registered, 5 reported โ w7 is the interesting row");
const outlier = d.rows.find(r => r.attributes.some(a => a.key === "healthFactorBps" && a.value === 9980));
assert.equal(outlier?.creator, W(4), "the outlier is named, not anonymised into an error bar");
});
test("SELISIH-2: a correction is a NEW entity; the board shows originals only via not(supersedesRound)", async () => {
const db = new MemArkiv(W(1));
await S.reveal(any(db), snap(812, 10420), []);
await S.reveal(any(db), { ...snap(812, 10300), supersedesRound: 812 }, []);
const d = await S.divergence(any(db), "aave-v3-eth-wsteth", 812);
assert.equal(d.rows.length, 1); assert.equal(d.median, 10420, "the original is never silently edited; the correction sits beside it");
const all = await db.select().where({ type: "eq", key: "kind", value: "snapshot" }).fetch();
assert.equal(all.entities.length, 2);
});
test("SELISIH-3: only the owner may extend โ a disputant CANNOT preserve a witness's reading; a pin can", async () => {
const db = new MemArkiv(W(1));
const { entityKey } = await S.reveal(any(db), snap(812, 9980, 4), [], 3); // 72h floor
await assert.rejects(async () => db.as(W(9)).extendEntity({ entityKey, expiresIn: 7_776_000 }), NotOwnerError);
const original = (await db.select().where({ type: "eq", key: "kind", value: "snapshot" }).fetch()).entities[0];
await S.pin(any(db.as(W(9))), { attributes: original.attributes, creator: original.creator, payload: original.payload }, entityKey, 31_536_000);
db.advanceSeconds(259_200 + 2); // the original lapses
const snaps = await db.select().where({ type: "eq", key: "kind", value: "snapshot" }).fetch();
const pins = await db.select().where({ type: "eq", key: "kind", value: "pin" }).fetch();
assert.equal(snaps.entities.length, 0, "the witness's reading left the query surface");
assert.equal(pins.entities.length, 1); assert.equal(pins.entities[0].creator, W(9), "the pin is the reader's own entity, funded by the reader, carrying the original tx hash");
assert.equal(pins.entities[0].attributes.find(a => a.key === "originTxHash")?.value, entityKey);
});
test("SELISIH-4: conviction is a receipt โ cost in ArkivEntityCreated scales with the funded lifetime", async () => {
const db = new MemArkiv(W(1));
await S.reveal(any(db), snap(812, 10420), [], 3);
await S.reveal(any(db), snap(813, 10420), [], 90);
const [c3, c90] = db.events.filter(e => e.name === "ArkivEntityCreated").map(e => (e as any).cost as bigint);
assert.ok(c90 > c3 * 20n, `90-day funding costs ${c90} vs 3-day ${c3}: fundedDays is checkable against what was actually paid`);
});
test("SELISIH-5: pinQueue lists readings that will lapse BEFORE the dispute deadline", async () => {
const db = new MemArkiv(W(1));
await S.reveal(any(db), snap(812, 10420), [], 3); // lapses in 72h
await S.reveal(any(db), snap(813, 10420), [], 30); // funded past the deadline
const deadline = db.nowUnix() + 7 * 86_400;
const q = await S.pinQueue(any(db), "aave-v3-eth-wsteth", 800, 900, deadline);
assert.equal(q.length, 1); assert.equal(q[0].attributes.find(a => a.key === "round")?.value, 812);
});
test("SELISIH-6: silence is an event โ a witness that stops renewing emits ArkivEntityExpired under its own key", async () => {
const db = new MemArkiv(W(4));
const reg = db.createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 604_800, attributes: [{ key: "app", value: "selisih" }, { key: "kind", value: "witness" }, { key: "market", value: "m" }] });
await S.heartbeat(any(db), reg.entityKey); db.advanceSeconds(604_800 - 100);
assert.equal(db.events.filter(e => e.name === "ArkivEntityExpired").length, 0, "renewed โ still live");
db.advanceSeconds(200);
const ev = db.events.find(e => e.name === "ArkivEntityExpired") as any;
assert.equal(ev?.owner, W(4), "leaving is a log entry with your key on it");
});
test("SELISIH-7: track record uses createdBy natively โ no mirrored witness attribute exists in the schema", async () => {
const db = new MemArkiv(W(4));
await S.reveal(any(db), snap(1, 9000, 4), []); await S.reveal(any(db), snap(2, 9000, 3), []); await S.reveal(any(db), snap(3, 10420, 0), []);
db.as(W(8)).createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 86_400, attributes: [{ key: "app", value: "selisih" }, { key: "kind", value: "resolution" }, { key: "vindicatedWitness", value: W(4) }] });
const tr = await S.trackRecord(any(db), W(4));
assert.deepEqual(tr, { broke: 2, vindicated: 1 });
const s = (await db.select().where({ type: "eq", key: "kind", value: "snapshot" }).fetch()).entities[0];
assert.ok(!s.attributes.some(a => a.key === "witness"), "$creator is metadata, not an attribute to mirror");
});
test("SELISIH-8: RosterEpoch answers 'who was expected' after registrations have expired", async () => {
const db = new MemArkiv(W(1));
for (const w of [1, 2, 3]) db.as(W(w)).createEntity({ payload: new Uint8Array(0), contentType: "application/json", expiresIn: 604_800, attributes: [{ key: "app", value: "selisih" }, { key: "kind", value: "witness" }, { key: "market", value: "m" }] });
await S.rosterEpoch(any(db), "m", 1, 800, 900, [W(1), W(2), W(3)]);
const atRound = db.block;
db.advanceSeconds(30 * 86_400); // a month into a dispute: registrations long gone
const live = await db.select().where({ type: "eq", key: "kind", value: "witness" }).count();
assert.equal(live, 0, "querying live registrations now answers WRONG with total confidence");
const epoch = (await db.select().where({ type: "eq", key: "kind", value: "roster" }, { type: "lte", key: "roundFrom", value: 812 }, { type: "gte", key: "roundTo", value: 812 }).fetch()).entities[0];
assert.equal(epoch.attributes.find(a => a.key === "witnessCount")?.value, 3, "the fact was stored while it was true");
// and the upgrade path: validAtBlock() โ if the network serves history, this replaces RosterEpoch
assert.equal((await S.rosterAtBlock(any(db), "m", atRound)).length, 3);
});
// MemArkiv โ an executable specification of the Arkiv semantics this design depends on.
// NOT Arkiv, and not a substitute for it. Every rule below is cited to the published SDK source
// (@arkiv-network/sdk@0.7.0) or the arkiv-fundamentals doc, so that the sketches โ which already
// type-check against the real package โ can be EXECUTED and their invariants asserted, offline.
//
// R1 expiresIn is seconds; lifetimes are 2-second blocks (utils/expirationTime.ts, consts BLOCK_TIME=2)
// R2 an expired entity leaves the query surface (fundamentals: "drops off the query surface")
// R3 only the owner may update / delete / extend (ideation-guide ยง4)
// R4 updateEntity is a full replace (fundamentals: "An attribute you omit ... is silently removed")
// R5 $creator is immutable; $owner moves via changeOwnership (types/entity.ts, actions/wallet/changeOwnership.ts)
// R6 results are newest-first; no server-side ordering (docs: "always returns matching entities newest first")
// R7 string attributes support eq() only; range ops on numerics (fundamentals + query/predicate.ts)
// R8 not(key) = attribute absent; neq = not equal (query/predicate.ts)
// R9 .count() = length of ONE page, limit โค 200 (query/queryBuilder.ts count(): queryResult.data.length)
// R10 mutateEntities is one atomic transaction (actions/wallet/mutateEntities.ts: single sendArkivTransaction)
// R11 ArkivEntityCreated(..., cost) / ArkivEntityExpired / ArkivEntityBTLExtended(..., cost) are emitted
// (actions/public/subscribeEntityEvents.ts arkivABI); cost โ size ร lifetime (fundamentals)
// R12 createdAtBlock / expiresAtBlock / creator / owner are returned as metadata (types/entity.ts)
import type { Attribute } from "@arkiv-network/sdk";
import type { Predicate } from "@arkiv-network/sdk/query";
import type { Hex } from "viem";
export const BLOCK_TIME = 2;
const PAGE_MAX = 200;
type Row = {
key: Hex; creator: Hex; owner: Hex; payload: Uint8Array; contentType: string;
attributes: Attribute[]; createdAtBlock: bigint; expiresAtBlock: bigint; lastModifiedAtBlock: bigint; seq: number;
};
export type Event =
| { name: "ArkivEntityCreated"; entityKey: Hex; owner: Hex; expirationBlock: bigint; cost: bigint }
| { name: "ArkivEntityBTLExtended"; entityKey: Hex; owner: Hex; oldExpirationBlock: bigint; newExpirationBlock: bigint; cost: bigint }
| { name: "ArkivEntityExpired"; entityKey: Hex; owner: Hex }
| { name: "ArkivEntityOwnerChanged"; entityKey: Hex; oldOwner: Hex; newOwner: Hex };
export class NotOwnerError extends Error {}
export class InvalidExpirationError extends Error {}
// One shared store per chain; `as(signer)` returns a view over the SAME store with a different wallet.
type Store = { block: bigint; seq: number; rows: Map<Hex, Row>; events: Event[]; genesisUnix: number };
export class MemArkiv {
private st: Store;
constructor(public signer: Hex, st?: Store) {
// genesis chosen so that chain time โ wall-clock at construction: the sketches stamp `now()` from Date.now()
this.st = st ?? { block: 1000n, seq: 0, rows: new Map(), events: [], genesisUnix: Math.floor(Date.now() / 1000) - 1000 * BLOCK_TIME };
}
as(signer: Hex) { return new MemArkiv(signer, this.st); }
get block() { return this.st.block; }
get rows() { return this.st.rows; }
get events() { return this.st.events; }
// ---- time ----
advanceSeconds(s: number) {
this.st.block += BigInt(Math.ceil(s / BLOCK_TIME));
for (const r of this.rows.values()) // R2 + R11
if (r.expiresAtBlock <= this.block && !r.attributes.some(a => a.key === "__expired")) {
r.attributes.push({ key: "__expired", value: 1 });
this.events.push({ name: "ArkivEntityExpired", entityKey: r.key, owner: r.owner });
}
}
blockToUnix(b: bigint) { return this.st.genesisUnix + Number(b) * BLOCK_TIME; }
nowUnix() { return this.blockToUnix(this.block); }
// ---- wallet actions (same parameter shapes as the SDK) ----
private cost(payload: Uint8Array, attributes: Attribute[], expiresIn: number) {
const bytes = payload.length + JSON.stringify(attributes).length;
return BigInt(bytes) * BigInt(Math.ceil(expiresIn / BLOCK_TIME)); // R11: size ร lifetime
}
createEntity(p: { payload: Uint8Array; attributes: Attribute[]; contentType: string; expiresIn: number }) {
if (!Number.isInteger(p.expiresIn) || p.expiresIn <= 0 || p.expiresIn % 2 !== 0) throw new InvalidExpirationError(String(p.expiresIn)); // R1
const seq = ++this.st.seq;
const key = ("0x" + seq.toString(16).padStart(64, "0")) as Hex;
const exp = this.block + BigInt(p.expiresIn / BLOCK_TIME);
this.rows.set(key, { key, creator: this.signer, owner: this.signer, payload: p.payload, contentType: p.contentType,
attributes: [...p.attributes], createdAtBlock: this.block, expiresAtBlock: exp, lastModifiedAtBlock: this.block, seq });
const cost = this.cost(p.payload, p.attributes, p.expiresIn);
this.events.push({ name: "ArkivEntityCreated", entityKey: key, owner: this.signer, expirationBlock: exp, cost });
return { entityKey: key, txHash: ("0x" + "t".repeat(0) + key.slice(2)) as Hex };
}
private own(key: Hex) { const r = this.rows.get(key); if (!r) throw new Error("no such entity"); if (r.owner !== this.signer) throw new NotOwnerError(key); return r; } // R3
extendEntity(p: { entityKey: Hex; expiresIn: number }) {
const r = this.own(p.entityKey); const old = r.expiresAtBlock;
r.expiresAtBlock = this.block + BigInt(p.expiresIn / BLOCK_TIME); r.lastModifiedAtBlock = this.block;
this.events.push({ name: "ArkivEntityBTLExtended", entityKey: r.key, owner: r.owner, oldExpirationBlock: old, newExpirationBlock: r.expiresAtBlock, cost: this.cost(r.payload, r.attributes, p.expiresIn) });
return { entityKey: r.key, txHash: r.key };
}
updateEntity(p: { entityKey: Hex; payload: Uint8Array; attributes: Attribute[]; contentType: string; expiresIn: number }) {
const r = this.own(p.entityKey); // R4: full replace
r.payload = p.payload; r.attributes = [...p.attributes]; r.contentType = p.contentType;
r.expiresAtBlock = this.block + BigInt(p.expiresIn / BLOCK_TIME); r.lastModifiedAtBlock = this.block;
return { entityKey: r.key, txHash: r.key };
}
deleteEntity(p: { entityKey: Hex }) { this.own(p.entityKey); this.rows.delete(p.entityKey); return { entityKey: p.entityKey, txHash: p.entityKey }; }
changeOwnership(p: { entityKey: Hex; newOwner: Hex }) {
const r = this.own(p.entityKey); const old = r.owner; r.owner = p.newOwner; // R5: creator untouched
this.events.push({ name: "ArkivEntityOwnerChanged", entityKey: r.key, oldOwner: old, newOwner: p.newOwner });
return { entityKey: r.key, txHash: r.key };
}
mutateEntities(p: { creates?: Parameters<MemArkiv["createEntity"]>[0][]; extensions?: Parameters<MemArkiv["extendEntity"]>[0][];
ownershipChanges?: Parameters<MemArkiv["changeOwnership"]>[0][]; deletes?: { entityKey: Hex }[] }) {
// R10: atomic โ validate ownership/expiry for every op BEFORE applying any
for (const e of p.extensions ?? []) this.own(e.entityKey);
for (const o of p.ownershipChanges ?? []) this.own(o.entityKey);
for (const d of p.deletes ?? []) this.own(d.entityKey);
for (const c of p.creates ?? []) if (c.expiresIn % 2 !== 0 || c.expiresIn <= 0) throw new InvalidExpirationError(String(c.expiresIn));
const createdEntities = (p.creates ?? []).map(c => this.createEntity(c).entityKey);
const extendedEntities = (p.extensions ?? []).map(e => this.extendEntity(e).entityKey);
const ownershipChanges = (p.ownershipChanges ?? []).map(o => this.changeOwnership(o).entityKey);
const deletedEntities = (p.deletes ?? []).map(d => this.deleteEntity(d).entityKey);
return { txHash: "0xbatch" as Hex, createdEntities, updatedEntities: [] as Hex[], deletedEntities, extendedEntities, ownershipChanges };
}
// ---- public query surface (same chain shape as the SDK builder) ----
select(_fields?: unknown) { return new MemQuery(this); }
}
function matches(r: Row, p: Predicate): boolean {
if (p.type === "and") return p.predicates.every(q => matches(r, q));
if (p.type === "or") return p.predicates.some(q => matches(r, q));
const lp = p as Extract<Predicate, { key: string }>;
const a = r.attributes.find(x => x.key === lp.key);
if (lp.type === "not") return a === undefined; // R8
if (a === undefined) return false;
const v = a.value;
switch (lp.type) {
case "eq": return v === lp.value;
case "neq": return v !== lp.value;
default:
if (typeof v !== "number" || typeof lp.value !== "number") return false; // R7: ranges on numerics only
return lp.type === "gt" ? v > lp.value : lp.type === "gte" ? v >= lp.value : lp.type === "lt" ? v < lp.value : v <= lp.value;
}
}
export class MemQuery {
private preds: Predicate[] = []; private _limit = PAGE_MAX; private _offset = 0; private _creator?: Hex; private _owner?: Hex; private _at?: bigint;
constructor(private db: MemArkiv) {}
where(...ps: (Predicate | Predicate[])[]) { this.preds.push(...ps.flat()); return this; }
createdBy(h: Hex) { this._creator = h; return this; }
ownedBy(h: Hex) { this._owner = h; return this; }
limit(n: number) { this._limit = Math.min(n, PAGE_MAX); return this; }
validAtBlock(_b: bigint) { this._at = _b; return this; }
private all() {
const at = this._at ?? this.db.block;
return [...this.db.rows.values()]
.filter(r => r.createdAtBlock <= at && r.expiresAtBlock > at) // R2 (validAtBlock reads history in the spec; see design note)
.filter(r => (!this._creator || r.creator === this._creator) && (!this._owner || r.owner === this._owner))
.filter(r => this.preds.every(p => matches(r, p)))
.sort((a, b) => b.seq - a.seq); // R6: newest-first, nothing else
}
async fetch() {
const rows = this.all(); const page = rows.slice(this._offset, this._offset + this._limit);
const entities = page.map(r => ({ key: r.key, creator: r.creator, owner: r.owner, payload: r.payload, contentType: r.contentType,
attributes: r.attributes.filter(a => a.key !== "__expired"), createdAtBlock: r.createdAtBlock, expiresAtBlock: r.expiresAtBlock, lastModifiedAtBlock: r.lastModifiedAtBlock })); // R12
const self = this;
const res = {
entities,
hasNextPage: () => self._offset + self._limit < rows.length,
async next() { self._offset += self._limit; const n = await self.fetch(); res.entities = n.entities; res.hasNextPage = n.hasNextPage; },
};
return res;
}
async count() { return (await this.fetch()).entities.length; } // R9: ONE page, not a total
}
0.8.0-devThe September testnet runs a rebuilt architecture, and its SDK is already on npm under the dev tag. I read that too. It makes this design smaller and confirms one piece of feedback.
| On 0.7.0 | On 0.8.0-dev | Effect |
|---|---|---|
expiresAtTs mirrored so the pin queue can range-filter | $expiresAt is a queryable system attribute | The mirror disappears |
| integers, scale in the key | dec type, 18 exact fractional digits; addr for witnesses | Prices stored as written; severityTier stays a bucket on purpose |
validAtBlock() as the RosterEpoch upgrade path | atBlock() — still there | The upgrade path survives the rewrite |
.count() = one page | no count() in the builder | Page-and-sum is the right shape on both |
createdAtBlock returned, not filterable | $createdAt is still result-only | Protocol feedback #2 stands on the new architecture |
Day one, the witnesses already exist. A single process tails the ETH/USD aggregator’s NewTransmission logs and writes each node’s observation as its own entity — 31 per round, 161 rounds in the crash window alone — with nothing asked of Chainlink. Independent witnesses join the same schema. The minimum human slice is still: one market. Three witnesses, run from three machines by three people. One screen showing the divergence row and a “6 of 7 reporting” badge. If three independent parties can look at one screen and agree about what they disagreed about, the idea is proven.
Not building in v1: the arbitration flow, and the bond contract.
The honest limit: divergence detects disagreement, not a cartel. What SELISIH guarantees is attributable readings, not correct ones — a smaller claim than it first appears, and one worth stating plainly.