From ed88d6adbbf58ae8086f8d9f41990e16ef6fdcf6 Mon Sep 17 00:00:00 2001
From: Quantum Explorer
Date: Mon, 28 Sep 2026 22:42:52 +0700
Subject: [PATCH 1/4] docs: say what the example check runs: the parser, not
the meta-schema
@dashevo/evo-sdk is built without dpp's validation feature, so
DataContract.fromJSON(json, true, 14) runs the contract parser but not the
document meta-schema or the parser checks gated behind that feature. The
README, the examples registry and the validation script claimed more.
Co-Authored-By: Claude Opus 5.5
---
README.md | 6 ++++--
scripts/validate-examples.mjs | 4 +++-
src/examples/index.ts | 9 +++++----
3 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 06adfc3..c6a0c6b 100644
--- a/README.md
+++ b/README.md
@@ -97,8 +97,10 @@ and a file (which usually leaves defaults out) compare equal.
## Examples
-Bundled, offline, and each one passes full validation by the protocol version 14 parser
-(`DataContract.fromJSON(json, true, 14)` from `@dashevo/evo-sdk`), checked in CI:
+Bundled, offline, and each one passes the protocol version 14 contract parser as
+`@dashevo/evo-sdk` compiles it (`DataContract.fromJSON(json, true, 14)`), checked in CI.
+The SDK is built without dpp's `validation` feature, so this does not run the document
+meta-schema or the parser checks gated behind that feature:
| Key | What it shows |
|---|---|
diff --git a/scripts/validate-examples.mjs b/scripts/validate-examples.mjs
index f413a12..0638edd 100644
--- a/scripts/validate-examples.mjs
+++ b/scripts/validate-examples.mjs
@@ -6,7 +6,9 @@
// npm run validate:examples -- --write # regenerate src/model/fixtures/sdk-references.json
//
// DataContract.fromJSON(json, fullValidation = true, 14) runs the protocol
-// version 14 meta-schema and parser (try_from_schema) from @dashevo/evo-sdk.
+// version 14 parser (try_from_schema) as @dashevo/evo-sdk compiles it. The SDK
+// is built without dpp's `validation` feature, so the document meta-schema and
+// the parser checks gated behind that feature do not run here.
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
diff --git a/src/examples/index.ts b/src/examples/index.ts
index 20ae675..48fbd80 100644
--- a/src/examples/index.ts
+++ b/src/examples/index.ts
@@ -1,7 +1,8 @@
-// Bundled example contracts. They render offline and each one passes full
-// validation by the protocol version 14 parser (DataContract.fromJSON with
-// full validation in @dashevo/evo-sdk 4.2), so what they show is what the
-// platform accepts.
+// Bundled example contracts. They render offline and each one passes the
+// protocol version 14 contract parser as @dashevo/evo-sdk 4.2 compiles it
+// (DataContract.fromJSON with full validation). The SDK is built without
+// dpp's `validation` feature, so the document meta-schema, and the parser
+// checks gated behind it, are not part of that check.
import marketplace from './marketplace.json';
import moderationCharters from './moderation-charters.json';
From ae88c085d49be495f01902f23acb5cad8f724339 Mon Sep 17 00:00:00 2001
From: Quantum Explorer
Date: Tue, 29 Sep 2026 02:35:18 +0700
Subject: [PATCH 2/4] feat: show a document type's GroveDB layout, computed by
Drive
A "GroveDB layout" button in the inspector opens every tree and element
Drive writes for the selected document type: the documents by id (and
revisions), and for each index the property and value trees down to the
[0] where it ends, with each layer's element (count, sum, ranked, ...),
its zero-contribution wrapper, the indexes that use it, conditions (the
null fallback of a unique index, skipIfAbsent, preallocated, time window
overlap) and a link to that kind of layer in the GroveDB structure viewer.
The layout comes from documentTypeLayout in @dashevo/evo-sdk
(dashpay/platform#5153), so it follows Drive's own rules. It is
feature-detected: with an SDK that predates it, the panel says so.
Co-Authored-By: Claude Opus 5.5
---
README.md | 31 +-
src/App.tsx | 37 +-
src/components/InspectorPanel.tsx | 21 +-
src/components/LayoutPanel.tsx | 181 +++++
src/model/fixtures/document-type-layouts.json | 732 ++++++++++++++++++
src/model/layout.test.ts | 91 +++
src/model/layout.ts | 162 ++++
src/sdk/layout.ts | 50 ++
src/styles.css | 34 +
9 files changed, 1328 insertions(+), 11 deletions(-)
create mode 100644 src/components/LayoutPanel.tsx
create mode 100644 src/model/fixtures/document-type-layouts.json
create mode 100644 src/model/layout.test.ts
create mode 100644 src/model/layout.ts
create mode 100644 src/sdk/layout.ts
diff --git a/README.md b/README.md
index c6a0c6b..1e779fa 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,30 @@ Every keyword links to its chapter of [The Dash Platform Book](https://dashpay.g
- **Property constraints** get their own compartment, rendered as formulas: `rewardSplit.leader + rewardSplit.equal + rewardSplit.actions = 100`.
- **Contract metadata** (top-left): id, owner, version, timestamps, keywords, description, config, **moderation** (lists, who moderates, election windows, moderated types), `schemaDefs`, and every type's chips.
+## How a document type is stored (GroveDB layout)
+
+Select a document type and click **GroveDB layout** in the inspector to see every tree and
+element Drive writes for it, under `[64, contract id, 1, ]`: the documents by id (with
+their revisions when the type keeps history) and, for each index, the property and value
+trees down to the `[0]` where the index ends. Each layer shows:
+
+- its key (a fixed key, or `‹…›` for one key per document or value; a time window level
+ shows its grid, `‹$createdAt window: 1d every 1h›`);
+- its element, coloured by what it totals: a plain tree, a count, sum or count-and-sum tree
+ (provable or not), a ranked indexed tree with its axes, or a value / reference;
+- the wrapper a continuation tree gets under a counting or summing value tree
+ (`NonCounted`, `NotSummed`, `NotCountedOrSummed`);
+- the indexes that use it, and conditions: the tree a unique index falls back to when a
+ value is null, `nullSearchable`, `skipIfAbsent`, preallocated indexes, time window overlap;
+- a `↗` link to that kind of layer in the
+ [GroveDB structure viewer](https://dashpay.github.io/grovedb-structure-viewer/), which holds
+ the general description (element flags, Merk shapes, ...).
+
+The layout is computed by Drive's own rules (`documentTypeLayout` in `@dashevo/evo-sdk`,
+[dashpay/platform#5153](https://github.com/dashpay/platform/pull/5153)), held to what Drive
+writes by a Drive test, and needs no network. It needs an SDK release that includes it; until
+the dependency is bumped the panel says so.
+
## Compare two versions (contract updates)
**Compare** in the toolbar diagrams two versions of a contract at once: the union of both,
@@ -161,7 +185,9 @@ src/
github.ts # a PR's changed JSON files at merge base and head (GitHub REST API)
sdk/
client.ts # Evo SDK trusted connection, memoised per network
- contract.ts # fetch contract (toJSON) -> ContractModel, or a bundled example
+ contract.ts # any source (id, link, example) -> contract JSON -> ContractModel
+ layout.ts # documentTypeLayout through the SDK (feature-detected)
+ pool.ts # pooled connections, resettable without loading the SDK
examples/ # bundled example contracts + registry
model/
introspect.ts # document schemas -> ContractModel (fields, indexes, keywords)
@@ -170,6 +196,7 @@ src/
constraints.ts # propertyConstraints -> formulas
describe.ts # keyword chips and plain-language descriptions
diff.ts # compare two versions: changes, statuses, the union model
+ layout.ts # a document type's GroveDB layout (from the SDK) and its display helpers
updateRules.ts # each keyword's update rule, per the book, with its error code
types.ts
fixtures/ # the SDK's reading of the examples' references
@@ -181,7 +208,7 @@ src/
Canvas.tsx # React Flow canvas, legend, export/re-layout panel
selection.ts, exportImage.ts
components/ # Toolbar, InspectorPanel, ContractMetaPanel, PasteContractModal,
- # CompareModal, ChangesPanel
+ # CompareModal, ChangesPanel, LayoutPanel
App.tsx, main.tsx, styles.css
scripts/
validate-examples.mjs # examples through DataContract.fromJSON(…, true, 14)
diff --git a/src/App.tsx b/src/App.tsx
index da689d5..0086360 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEdgesState, useNodesState, type Edge } from '@xyflow/react';
import { loadConfig, saveOverride, type Network, type ViewKind } from './config';
-import { loadContractJson, loadContractModel } from './sdk/contract';
+import { loadContractJson } from './sdk/contract';
import { resetConnections } from './sdk/pool';
import { modelFromPastedJson } from './model/introspect';
import { withRelationships } from './model/relationships';
@@ -15,6 +15,7 @@ import { InspectorPanel } from './components/InspectorPanel';
import { PasteContractModal } from './components/PasteContractModal';
import { ContractMetaPanel } from './components/ContractMetaPanel';
import { ChangesPanel } from './components/ChangesPanel';
+import { LayoutPanel } from './components/LayoutPanel';
import { CompareModal, type CompareSpec } from './components/CompareModal';
import { EXAMPLES, exampleId, exampleKey } from './examples';
import { fileLabel, looksLikeUrl, urlFromSource, urlSourceId } from './urlSource';
@@ -71,6 +72,10 @@ export default function App() {
const [compareError, setCompareError] = useState(undefined);
const [compareInitialPr, setCompareInitialPr] = useState(undefined);
const [focus, setFocus] = useState<{ id: string; n: number } | undefined>(undefined);
+ const [layoutView, setLayoutView] = useState<{ documentType: string; json: unknown } | null>(null);
+ // The JSON of the contract on screen (both sides in compare mode), for the GroveDB layout panel.
+ const contractJsonRef = useRef(null);
+ const compareJsonRef = useRef<{ base: unknown; head: unknown } | null>(null);
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges] = useEdgesState([]);
@@ -126,7 +131,9 @@ export default function App() {
);
const applyModel = useCallback(
- async (m: ContractModel, source: string, net: Network, devnet: string) => {
+ async (m: ContractModel, source: string, net: Network, devnet: string, json: unknown) => {
+ contractJsonRef.current = json;
+ compareJsonRef.current = null;
setDiff(null);
diffRef.current = null;
setCompareSpec(null);
@@ -148,9 +155,10 @@ export default function App() {
setStatus('loading');
setErrorMsg(null);
try {
- const m = await loadContractModel({ network: net, contractId: source, devnetName: devnet || undefined, view: viewRef.current });
+ const json = await loadContractJson({ network: net, contractId: source, devnetName: devnet || undefined, view: viewRef.current });
+ const m = withRelationships(modelFromPastedJson(json));
saveOverride({ network: net, contractId: source, devnetName: devnet || undefined });
- await applyModel(m, source, net, devnet);
+ await applyModel(m, source, net, devnet, json);
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : String(err));
setStatus('error');
@@ -172,6 +180,7 @@ export default function App() {
: Promise.resolve(EMPTY_CONTRACT);
const [b, h] = await Promise.all([load1(spec.base), load1(spec.head)]);
const d = diffContracts(b, h);
+ compareJsonRef.current = { base: b, head: h };
setDiff(d);
diffRef.current = d;
setCompareSpec(spec);
@@ -278,7 +287,7 @@ export default function App() {
const spec = compareSpec;
if (!d) return;
const headSource = spec?.head ? toSourceId(spec.head) : '';
- void applyModel(d.head, headSource, network, devnetName);
+ void applyModel(d.head, headSource, network, devnetName, compareJsonRef.current?.head ?? null);
if (spec?.head) setContractInput(spec.head);
}, [applyModel, compareSpec, network, devnetName]);
@@ -296,6 +305,18 @@ export default function App() {
[load, network, devnetName],
);
+ /** Open the GroveDB layout of a document type: the head's version in compare mode, else the base's. */
+ const openLayout = useCallback((documentType: string) => {
+ const compare = compareJsonRef.current;
+ const d = diffRef.current;
+ const json = compare && d
+ ? d.head.entities.some((e) => e.name === documentType)
+ ? compare.head
+ : compare.base
+ : contractJsonRef.current;
+ if (json) setLayoutView({ documentType, json });
+ }, []);
+
const onNetwork = useCallback((n: Network) => {
setNetwork(n);
resetConnections();
@@ -308,7 +329,7 @@ export default function App() {
const m = withRelationships(modelFromPastedJson(parsed));
setPasteOpen(false);
setPasteError(undefined);
- void applyModel(m, '', network, devnetName);
+ void applyModel(m, '', network, devnetName, parsed);
} catch (err) {
setPasteError(err instanceof Error ? err.message : String(err));
}
@@ -460,10 +481,14 @@ export default function App() {
onOpenContract={onOpenContract}
onClose={() => setSelection(null)}
diff={diff ?? undefined}
+ onShowLayout={openLayout}
/>
)}
+ {layoutView && (
+ setLayoutView(null)} />
+ )}
{pasteOpen && (
setPasteOpen(false)} error={pasteError} />
)}
diff --git a/src/components/InspectorPanel.tsx b/src/components/InspectorPanel.tsx
index 02b667b..1668298 100644
--- a/src/components/InspectorPanel.tsx
+++ b/src/components/InspectorPanel.tsx
@@ -25,6 +25,8 @@ interface Props {
onClose: () => void;
/** Compare mode: show the change behind the selected element. */
diff?: ContractDiff;
+ /** Open the GroveDB layout panel for a document type. */
+ onShowLayout?: (documentType: string) => void;
}
function changeFor(diff: ContractDiff | undefined, selection: Selection): Change | undefined {
@@ -248,7 +250,15 @@ function FieldView({ entity, field }: { entity: Entity; field: Field }) {
);
}
-function EntityView({ entity, model }: { entity: Entity; model: ContractModel }) {
+function EntityView({
+ entity,
+ model,
+ onShowLayout,
+}: {
+ entity: Entity;
+ model: ContractModel;
+ onShowLayout?: (documentType: string) => void;
+}) {
const chips = documentTypeChips(entity);
const shown = new Set(['ttl', 'indexOnly', 'documentsMutable', 'canBeDeleted', 'canBeDeletedByModerators', 'canBeDeletedByModeratorsFor', 'creationRestrictionMode', 'transferable', 'tradeMode', 'documentsKeepHistory', 'keepsTransferHistory', 'keepsPurchaseHistory', 'keepsPricingHistory', 'actionFees', 'tokenCost', 'documentsCountable', 'documentsSummable', 'documentsAverageable', 'rangeCountable', 'rangeSummable', 'rangeAverageable', 'signatureSecurityLevelRequirement', 'requiresIdentityEncryptionBoundedKey', 'requiresIdentityDecryptionBoundedKey']);
const rest = Object.fromEntries(Object.entries(entity.config).filter(([k]) => !shown.has(k)));
@@ -262,6 +272,11 @@ function EntityView({ entity, model }: { entity: Entity; model: ContractModel })
{entity.indices.length} indexes · {outgoing} references out · {incoming} in
{entity.description && {entity.description}
}
+ {onShowLayout && (
+ onShowLayout(entity.name)}>
+ GroveDB layout
+
+ )}
{entity.typeReferences.map((r) => (
@@ -315,7 +330,7 @@ function RelationshipView({
);
}
-export function InspectorPanel({ selection, model, hiddenEdges, onToggleEdge, onOpenContract, onClose, diff }: Props) {
+export function InspectorPanel({ selection, model, hiddenEdges, onToggleEdge, onOpenContract, onClose, diff, onShowLayout }: Props) {
if (!selection) return null;
const change = changeFor(diff, selection);
@@ -327,7 +342,7 @@ export function InspectorPanel({ selection, model, hiddenEdges, onToggleEdge, on
{change && }
- {selection.kind === 'entity' && }
+ {selection.kind === 'entity' && }
{selection.kind === 'field' && }
diff --git a/src/components/LayoutPanel.tsx b/src/components/LayoutPanel.tsx
new file mode 100644
index 0000000..ee1a972
--- /dev/null
+++ b/src/components/LayoutPanel.tsx
@@ -0,0 +1,181 @@
+import { useEffect, useState } from 'react';
+import {
+ STRUCTURE_VIEWER,
+ WRAPPER_DETAIL,
+ keyDetail,
+ keyText,
+ kindDetail,
+ kindFamily,
+ layoutSummary,
+ structureViewerUrl,
+ type DocumentTypeLayout,
+ type LayoutNode,
+} from '../model/layout';
+import { LayoutUnavailableError, loadDocumentTypeLayout } from '../sdk/layout';
+
+interface Props {
+ documentType: string;
+ contractJson: unknown;
+ onClose: () => void;
+}
+
+type State =
+ | { status: 'loading' }
+ | { status: 'ready'; layout: DocumentTypeLayout }
+ | { status: 'error'; message: string; unavailable: boolean };
+
+const FAMILIES: Array<[string, string]> = [
+ ['plain', 'plain tree'],
+ ['count', 'counts'],
+ ['sum', 'sums'],
+ ['countSum', 'counts and sums'],
+ ['indexed', 'ranked'],
+ ['value', 'value / reference'],
+];
+
+function Row({ node, depth, prefix }: { node: LayoutNode; depth: number; prefix?: string }) {
+ const [open, setOpen] = useState(depth < 8);
+ const hasChildren = node.children.length > 0;
+ return (
+
+
+
setOpen((o) => !o)}
+ disabled={!hasChildren}
+ aria-label={open ? 'Collapse' : 'Expand'}
+ >
+ {hasChildren ? (open ? '▾' : '▸') : '·'}
+
+ {prefix &&
{prefix} }
+
+ {keyText(node.key)}
+
+
+ {node.element}
+
+ {node.wrapper && (
+
+ in {node.wrapper}
+
+ )}
+ {node.rankedAxes.length > 0 && (
+
+ ranked by {node.rankedAxes.join(', ')}
+
+ )}
+ {node.role !== 'documentType' && node.indexes.length > 0 && (
+
{node.indexes.join(', ')}
+ )}
+
+ ↗
+
+
+ {node.notes.length > 0 && (
+
+ {node.notes.map((n) => (
+ {n.text}
+ ))}
+
+ )}
+ {node.alternative && (
+
+ )}
+ {hasChildren && open && (
+
+ {node.children.map((child, i) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function LayoutPanel({ documentType, contractJson, onClose }: Props) {
+ const [state, setState] = useState({ status: 'loading' });
+
+ useEffect(() => {
+ let alive = true;
+ setState({ status: 'loading' });
+ loadDocumentTypeLayout(contractJson, documentType)
+ .then((layout) => alive && setState({ status: 'ready', layout }))
+ .catch((err: unknown) => {
+ if (!alive) return;
+ setState({
+ status: 'error',
+ message: err instanceof Error ? err.message : String(err),
+ unavailable: err instanceof LayoutUnavailableError,
+ });
+ });
+ return () => {
+ alive = false;
+ };
+ }, [contractJson, documentType]);
+
+ const summary = state.status === 'ready' ? layoutSummary(state.layout) : undefined;
+
+ return (
+
+
e.stopPropagation()}
+ role="dialog"
+ aria-label={`GroveDB layout of ${documentType}`}
+ >
+
+
+ GroveDB layout of {documentType}
+
+
+ ✕
+
+
+
+ Every tree and element Drive writes for this document type, under{' '}
+ [64, contract id, 1, {documentType}], computed by Drive's own rules (
+ documentTypeLayout). ‹…› stands for one key per document or value. Each ↗ opens
+ that kind of layer in the{' '}
+
+ GroveDB structure viewer
+
+ .
+
+
+ {FAMILIES.map(([family, label]) => (
+
+ {label}
+
+ ))}
+
+
+ {state.status === 'loading' &&
Computing the layout (loading the SDK the first time)…
}
+ {state.status === 'error' && (
+
+ {state.unavailable ? state.message : `Could not compute the layout: ${state.message}`}
+
+ )}
+ {state.status === 'ready' && summary && (
+ <>
+
+ {summary.layers} layers · {summary.aggregates} counted or summed · {summary.ranked} ranked ·{' '}
+ {summary.wrapped} wrapped to contribute nothing
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/model/fixtures/document-type-layouts.json b/src/model/fixtures/document-type-layouts.json
new file mode 100644
index 0000000..c706963
--- /dev/null
+++ b/src/model/fixtures/document-type-layouts.json
@@ -0,0 +1,732 @@
+{
+ "marketplace:listing": {
+ "documentType": "listing",
+ "root": {
+ "key": {
+ "kind": "fixed",
+ "hex": "6c697374696e67",
+ "label": "listing"
+ },
+ "role": "documentType",
+ "structureNode": "contracts.contract.documents.document_type",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "byCategoryPrice",
+ "byShop",
+ "newListings"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "00",
+ "label": "PrimaryKey"
+ },
+ "role": "primaryKey",
+ "structureNode": "contracts.contract.documents.document_type.primary_key",
+ "element": "CountTree",
+ "rankedAxes": [],
+ "indexes": [],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "documentId"
+ },
+ "role": "document",
+ "structureNode": "contracts.contract.documents.document_type.primary_key.document",
+ "element": "Item",
+ "rankedAxes": [],
+ "indexes": [],
+ "notes": [],
+ "children": []
+ }
+ ]
+ },
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "246372656174656441742338363430302333363030",
+ "label": "$createdAt#86400#3600"
+ },
+ "role": "indexProperty",
+ "structureNode": "contracts.contract.documents.document_type.index_property",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "newListings"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "timeRangeBucket",
+ "property": "$createdAt",
+ "rangeSeconds": 86400,
+ "stepSeconds": 3600,
+ "phaseSeconds": 0
+ },
+ "role": "indexValue",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value",
+ "element": "CountTree",
+ "rankedAxes": [],
+ "indexes": [
+ "newListings"
+ ],
+ "notes": [
+ {
+ "code": "timeRangeOverlap",
+ "text": "a document lands in every window containing its time: up to 24"
+ }
+ ],
+ "children": [
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "00",
+ "label": "Members"
+ },
+ "role": "terminal",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.members",
+ "element": "CountTree",
+ "rankedAxes": [],
+ "indexes": [
+ "newListings"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "documentId"
+ },
+ "role": "member",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.members.member",
+ "element": "Reference",
+ "rankedAxes": [],
+ "indexes": [],
+ "notes": [],
+ "children": []
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "63617465676f7279",
+ "label": "category"
+ },
+ "role": "indexProperty",
+ "structureNode": "contracts.contract.documents.document_type.index_property",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "byCategoryPrice"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "propertyValue",
+ "property": "category"
+ },
+ "role": "indexValue",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "byCategoryPrice"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "7072696365",
+ "label": "price"
+ },
+ "role": "nextIndexProperty",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.next_property",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "byCategoryPrice"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "propertyValue",
+ "property": "price"
+ },
+ "role": "indexValue",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "byCategoryPrice"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "00",
+ "label": "Members"
+ },
+ "role": "terminal",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.members",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "byCategoryPrice"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "documentId"
+ },
+ "role": "member",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.members.member",
+ "element": "Reference",
+ "rankedAxes": [],
+ "indexes": [],
+ "notes": [],
+ "children": []
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "73686f704964",
+ "label": "shopId"
+ },
+ "role": "indexProperty",
+ "structureNode": "contracts.contract.documents.document_type.index_property",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "byShop"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "propertyValue",
+ "property": "shopId"
+ },
+ "role": "indexValue",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "byShop"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "24637265617465644174",
+ "label": "$createdAt"
+ },
+ "role": "nextIndexProperty",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.next_property",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "byShop"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "propertyValue",
+ "property": "$createdAt"
+ },
+ "role": "indexValue",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value",
+ "element": "CountTree",
+ "rankedAxes": [],
+ "indexes": [
+ "byShop"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "00",
+ "label": "Members"
+ },
+ "role": "terminal",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.members",
+ "element": "CountTree",
+ "rankedAxes": [],
+ "indexes": [
+ "byShop"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "documentId"
+ },
+ "role": "member",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.members.member",
+ "element": "Reference",
+ "rankedAxes": [],
+ "indexes": [],
+ "notes": [],
+ "children": []
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "yappr-likes:tip": {
+ "documentType": "tip",
+ "root": {
+ "key": {
+ "kind": "fixed",
+ "hex": "746970",
+ "label": "tip"
+ },
+ "role": "documentType",
+ "structureNode": "contracts.contract.documents.document_type",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "byPost",
+ "byTipperAmount"
+ ],
+ "notes": [
+ {
+ "code": "indexOnly",
+ "text": "indexOnly: documents are not stored whole; the index entries are the rows"
+ }
+ ],
+ "children": [
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "246f776e65724964",
+ "label": "$ownerId"
+ },
+ "role": "indexProperty",
+ "structureNode": "contracts.contract.documents.document_type.index_property",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "byTipperAmount"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "propertyValue",
+ "property": "$ownerId"
+ },
+ "role": "indexValue",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "byTipperAmount"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "616d6f756e74",
+ "label": "amount"
+ },
+ "role": "nextIndexProperty",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.next_property",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "byTipperAmount"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "propertyValue",
+ "property": "amount"
+ },
+ "role": "indexValue",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "byTipperAmount"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "00",
+ "label": "Members"
+ },
+ "role": "terminal",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.members",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "byTipperAmount"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "memberKey",
+ "components": [
+ "postId"
+ ]
+ },
+ "role": "member",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.members.member",
+ "element": "Item",
+ "rankedAxes": [],
+ "indexes": [],
+ "notes": [],
+ "children": []
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "706f73744964",
+ "label": "postId"
+ },
+ "role": "indexProperty",
+ "structureNode": "contracts.contract.documents.document_type.index_property",
+ "element": "ProvableCountProvableSumIndexedTree",
+ "rankedAxes": [
+ "count",
+ "sum"
+ ],
+ "indexes": [
+ "byPost"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "propertyValue",
+ "property": "postId"
+ },
+ "role": "indexValue",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value",
+ "element": "ProvableCountProvableSumTree",
+ "rankedAxes": [],
+ "indexes": [
+ "byPost"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "00",
+ "label": "Members"
+ },
+ "role": "terminal",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.members",
+ "element": "ProvableCountProvableSumTree",
+ "rankedAxes": [],
+ "indexes": [
+ "byPost"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "memberKey",
+ "components": [
+ "$ownerId"
+ ]
+ },
+ "role": "member",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.members.member",
+ "element": "ItemWithSumItem",
+ "rankedAxes": [],
+ "indexes": [],
+ "notes": [],
+ "children": []
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "dpns:domain": {
+ "documentType": "domain",
+ "root": {
+ "key": {
+ "kind": "fixed",
+ "hex": "646f6d61696e",
+ "label": "domain"
+ },
+ "role": "documentType",
+ "structureNode": "contracts.contract.documents.document_type",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "identityId",
+ "parentNameAndLabel"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "00",
+ "label": "PrimaryKey"
+ },
+ "role": "primaryKey",
+ "structureNode": "contracts.contract.documents.document_type.primary_key",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "documentId"
+ },
+ "role": "document",
+ "structureNode": "contracts.contract.documents.document_type.primary_key.document",
+ "element": "Item",
+ "rankedAxes": [],
+ "indexes": [],
+ "notes": [],
+ "children": []
+ }
+ ]
+ },
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "6e6f726d616c697a6564506172656e74446f6d61696e4e616d65",
+ "label": "normalizedParentDomainName"
+ },
+ "role": "indexProperty",
+ "structureNode": "contracts.contract.documents.document_type.index_property",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "parentNameAndLabel"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "propertyValue",
+ "property": "normalizedParentDomainName"
+ },
+ "role": "indexValue",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "parentNameAndLabel"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "6e6f726d616c697a65644c6162656c",
+ "label": "normalizedLabel"
+ },
+ "role": "nextIndexProperty",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.next_property",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "parentNameAndLabel"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "propertyValue",
+ "property": "normalizedLabel"
+ },
+ "role": "indexValue",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "parentNameAndLabel"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "00",
+ "label": "Unique reference"
+ },
+ "role": "terminal",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.members",
+ "element": "Reference",
+ "rankedAxes": [],
+ "indexes": [
+ "parentNameAndLabel"
+ ],
+ "notes": [],
+ "alternative": {
+ "when": "an indexed value of the document is null",
+ "node": {
+ "key": {
+ "kind": "fixed",
+ "hex": "00",
+ "label": "Members"
+ },
+ "role": "terminal",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.members",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "parentNameAndLabel"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "documentId"
+ },
+ "role": "member",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.members.member",
+ "element": "Reference",
+ "rankedAxes": [],
+ "indexes": [],
+ "notes": [],
+ "children": []
+ }
+ ]
+ }
+ },
+ "children": []
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "7265636f7264732e6964656e74697479",
+ "label": "records.identity"
+ },
+ "role": "indexProperty",
+ "structureNode": "contracts.contract.documents.document_type.index_property",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "identityId"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "propertyValue",
+ "property": "records.identity"
+ },
+ "role": "indexValue",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "identityId"
+ ],
+ "notes": [],
+ "children": [
+ {
+ "key": {
+ "kind": "fixed",
+ "hex": "00",
+ "label": "Members"
+ },
+ "role": "terminal",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.members",
+ "element": "Tree",
+ "rankedAxes": [],
+ "indexes": [
+ "identityId"
+ ],
+ "notes": [
+ {
+ "code": "notNullSearchable",
+ "text": "nullSearchable is false: a document whose indexed values are all null writes no entry here, though the value trees above are written with empty keys"
+ }
+ ],
+ "children": [
+ {
+ "key": {
+ "kind": "documentId"
+ },
+ "role": "member",
+ "structureNode": "contracts.contract.documents.document_type.index_property.value.members.member",
+ "element": "Reference",
+ "rankedAxes": [],
+ "indexes": [],
+ "notes": [],
+ "children": []
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ }
+}
diff --git a/src/model/layout.test.ts b/src/model/layout.test.ts
new file mode 100644
index 0000000..1f82799
--- /dev/null
+++ b/src/model/layout.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from 'vitest';
+import { keyText, kindFamily, layoutSummary, structureViewerUrl, walk, type DocumentTypeLayout, type LayoutNode } from './layout';
+import { contractJsonForSdk } from '../sdk/layout';
+import fixtures from './fixtures/document-type-layouts.json';
+
+// Layouts computed by `documentTypeLayout` (dashpay/platform#5153) for three
+// bundled examples.
+const layouts = fixtures as unknown as Record;
+const listing = layouts['marketplace:listing'];
+const tip = layouts['yappr-likes:tip'];
+const domain = layouts['dpns:domain'];
+
+function find(root: LayoutNode, test: (n: LayoutNode) => boolean): LayoutNode {
+ let found: LayoutNode | undefined;
+ walk(root, (n) => {
+ if (!found && test(n)) found = n;
+ });
+ if (!found) throw new Error('no such node');
+ return found;
+}
+
+describe('keyText', () => {
+ it('reads fixed keys by label and [0] keys with their index', () => {
+ expect(keyText(listing.root.key)).toBe('listing');
+ expect(keyText(listing.root.children[0].key)).toBe('[0] PrimaryKey');
+ });
+
+ it('reads families of keys in angle quotes', () => {
+ expect(keyText({ kind: 'documentId' })).toBe('‹document id›');
+ expect(keyText({ kind: 'propertyValue', property: 'shopId' })).toBe('‹shopId value›');
+ const window = find(listing.root, (n) => n.key.kind === 'timeRangeBucket');
+ expect(keyText(window.key)).toBe('‹$createdAt window: 1d every 1h›');
+ const members: string[] = [];
+ walk(tip.root, (n) => {
+ if (n.key.kind === 'memberKey') members.push(keyText(n.key));
+ });
+ expect(members).toEqual(['‹postId›', '‹$ownerId›']);
+ });
+});
+
+describe('kindFamily', () => {
+ it('colours trees by what they total', () => {
+ expect(kindFamily('Tree')).toBe('plain');
+ expect(kindFamily('CountTree')).toBe('count');
+ expect(kindFamily('ProvableSumTree')).toBe('sum');
+ expect(kindFamily('ProvableCountProvableSumTree')).toBe('countSum');
+ expect(kindFamily('ProvableCountProvableSumIndexedTree')).toBe('indexed');
+ expect(kindFamily('Reference')).toBe('value');
+ expect(kindFamily('ItemWithSumItem')).toBe('value');
+ });
+});
+
+describe('the fixtures', () => {
+ it('summarise what each layout counts, sums and ranks', () => {
+ expect(layoutSummary(listing)).toEqual({ layers: 19, aggregates: 5, ranked: 0, wrapped: 0 });
+ expect(layoutSummary(tip).ranked).toBe(1);
+ });
+
+ it('keep a unique index fallback reachable by walk', () => {
+ const unique = find(domain.root, (n) => n.alternative !== undefined);
+ expect(unique.element).toBe('Reference');
+ expect(unique.alternative?.node.children[0].key.kind).toBe('documentId');
+ });
+
+ it('link each layer to the structure viewer node it is an instance of', () => {
+ expect(structureViewerUrl(listing.root)).toBe(
+ 'https://dashpay.github.io/grovedb-structure-viewer/#/contracts.contract.documents.document_type',
+ );
+ });
+
+ it('carry the skip note only on optional properties of indexOnly types', () => {
+ walk(tip.root, (n) => {
+ expect(n.notes.map((note) => note.code)).not.toContain('skipIfAbsent');
+ });
+ });
+});
+
+describe('contractJsonForSdk', () => {
+ it('wraps a bare map of document schemas and fills in what the SDK needs', () => {
+ const json = contractJsonForSdk({ note: { type: 'object', properties: {} } });
+ expect(json.documentSchemas).toEqual({ note: { type: 'object', properties: {} } });
+ expect(json).toMatchObject({ $formatVersion: '1', version: 1 });
+ expect(typeof json.id).toBe('string');
+ expect(typeof json.ownerId).toBe('string');
+ });
+
+ it('keeps a contract as it is', () => {
+ const contract = { $formatVersion: '1', id: 'x', ownerId: 'y', version: 3, documentSchemas: { a: {} } };
+ expect(contractJsonForSdk(contract)).toEqual(contract);
+ });
+});
diff --git a/src/model/layout.ts b/src/model/layout.ts
new file mode 100644
index 0000000..8f62aa0
--- /dev/null
+++ b/src/model/layout.ts
@@ -0,0 +1,162 @@
+// The GroveDB layout of a document type, as `documentTypeLayout` in
+// @dashevo/evo-sdk returns it (computed by Drive, platform #5153), and the
+// helpers the layout panel draws it with.
+
+export interface LayoutKey {
+ kind: 'fixed' | 'documentId' | 'revisionTime' | 'propertyValue' | 'timeRangeBucket' | 'memberKey';
+ hex?: string;
+ label?: string;
+ property?: string;
+ rangeSeconds?: number;
+ stepSeconds?: number;
+ phaseSeconds?: number;
+ components?: string[];
+}
+
+export interface LayoutNode {
+ key: LayoutKey;
+ role: string;
+ /** The node of Drive's structure description this layer is an instance of. */
+ structureNode: string;
+ element: string;
+ /** Left out when the tree is not wrapped. */
+ wrapper?: string;
+ rankedAxes: string[];
+ indexes: string[];
+ notes: Array<{ code: string; text: string }>;
+ /** Left out when there is none. */
+ alternative?: { when: string; node: LayoutNode };
+ children: LayoutNode[];
+}
+
+export interface DocumentTypeLayout {
+ documentType: string;
+ root: LayoutNode;
+}
+
+export const STRUCTURE_VIEWER = 'https://dashpay.github.io/grovedb-structure-viewer/';
+
+/** The structure viewer page of the general description of this kind of layer. */
+export function structureViewerUrl(node: LayoutNode): string {
+ return `${STRUCTURE_VIEWER}#/${node.structureNode}`;
+}
+
+function duration(seconds: number | undefined): string {
+ if (seconds === undefined) return '?';
+ for (const [size, unit] of [
+ [86400, 'd'],
+ [3600, 'h'],
+ [60, 'm'],
+ ] as const) {
+ if (seconds >= size && seconds % size === 0) return `${seconds / size}${unit}`;
+ }
+ return `${seconds}s`;
+}
+
+/** How a key reads in the tree: a fixed key's label, or what the keys at this position are. */
+export function keyText(key: LayoutKey): string {
+ switch (key.kind) {
+ case 'fixed':
+ return key.hex === '00' ? `[0] ${key.label ?? ''}`.trim() : (key.label ?? `0x${key.hex}`);
+ case 'documentId':
+ return '‹document id›';
+ case 'revisionTime':
+ return '‹revision time›';
+ case 'propertyValue':
+ return `‹${key.property} value›`;
+ case 'timeRangeBucket':
+ return `‹${key.property} window: ${duration(key.rangeSeconds)} every ${duration(key.stepSeconds)}›`;
+ case 'memberKey':
+ return `‹${(key.components ?? []).join(' + ')}›`;
+ default:
+ return String((key as { kind: string }).kind);
+ }
+}
+
+/** What a key stands for, in a sentence (for the tooltip). */
+export function keyDetail(key: LayoutKey): string {
+ switch (key.kind) {
+ case 'fixed':
+ return `The fixed key 0x${key.hex}.`;
+ case 'documentId':
+ return 'One key per document: its 32 byte id.';
+ case 'revisionTime':
+ return 'One key per stored revision: its block time in milliseconds (u64, big endian, sign bit flipped).';
+ case 'propertyValue':
+ return `One key per distinct value of ${key.property}, serialized for ordering; an absent or null value is the empty key.`;
+ case 'timeRangeBucket':
+ return `One key per window start of ${key.property}: windows of ${duration(key.rangeSeconds)} starting every ${duration(key.stepSeconds)}${key.phaseSeconds ? `, shifted by ${duration(key.phaseSeconds)}` : ''}.`;
+ case 'memberKey':
+ return `One key per entry: the values of ${(key.components ?? []).join(', ')} concatenated (32 bytes for $ownerId).`;
+ default:
+ return '';
+ }
+}
+
+export type KindFamily = 'plain' | 'count' | 'sum' | 'countSum' | 'indexed' | 'value';
+
+/** The family of an element kind, for its colour. */
+export function kindFamily(element: string): KindFamily {
+ if (element.endsWith('IndexedTree')) return 'indexed';
+ if (!element.endsWith('Tree')) return 'value';
+ const counts = element.includes('Count');
+ const sums = element.includes('Sum');
+ if (counts && sums) return 'countSum';
+ if (counts) return 'count';
+ if (sums) return 'sum';
+ return 'plain';
+}
+
+/** What an element kind does, in a sentence. */
+export function kindDetail(element: string): string {
+ const provable = element.startsWith('Provable')
+ ? ' Provable: the total is part of each node hash, so proofs can carry it (and ranges of it).'
+ : '';
+ switch (kindFamily(element)) {
+ case 'plain':
+ return 'A plain tree: no totals.';
+ case 'count':
+ return `Keeps a count of its entries.${provable}`;
+ case 'sum':
+ return `Keeps a sum of its entries' values.${provable}`;
+ case 'countSum':
+ return `Keeps a count and a sum of its entries.${provable}`;
+ case 'indexed':
+ return 'A ranked tree: keeps its entries ordered by their totals, for "top K" queries.';
+ default:
+ if (element === 'Item') return 'A value (the document, or an indexOnly entry).';
+ if (element === 'ItemWithSumItem') return 'A value that also carries its sum to the tree above.';
+ if (element === 'Reference') return 'A reference to the document in the primary key tree.';
+ if (element === 'ReferenceWithSumItem') return 'A reference that also carries its sum to the tree above.';
+ return element;
+ }
+}
+
+export const WRAPPER_DETAIL: Record = {
+ NonCounted: 'Wrapped in NonCounted: the value tree above does not count this subtree.',
+ NotSummed: 'Wrapped in NotSummed: the value tree above does not sum this subtree.',
+ NotCountedOrSummed: 'Wrapped in NotCountedOrSummed: the value tree above neither counts nor sums this subtree.',
+};
+
+/** Every node in the layout, depth first (alternatives included). */
+export function walk(node: LayoutNode, visit: (node: LayoutNode, depth: number) => void, depth = 0): void {
+ visit(node, depth);
+ if (node.alternative) walk(node.alternative.node, visit, depth + 1);
+ for (const child of node.children) walk(child, visit, depth + 1);
+}
+
+/** Counts for the panel's summary line. */
+export function layoutSummary(layout: DocumentTypeLayout): { layers: number; aggregates: number; ranked: number; wrapped: number } {
+ let layers = 0;
+ let aggregates = 0;
+ let ranked = 0;
+ let wrapped = 0;
+ walk(layout.root, (node) => {
+ layers++;
+ const family = kindFamily(node.element);
+ if (family === 'count' || family === 'sum' || family === 'countSum') aggregates++;
+ if (family === 'indexed') ranked++;
+ if (node.wrapper) wrapped++;
+ });
+ return { layers, aggregates, ranked, wrapped };
+}
diff --git a/src/sdk/layout.ts b/src/sdk/layout.ts
new file mode 100644
index 0000000..2818478
--- /dev/null
+++ b/src/sdk/layout.ts
@@ -0,0 +1,50 @@
+// Ask the Evo SDK for a document type's GroveDB layout. Drive computes it
+// (`documentTypeLayout`, platform #5153) from the contract alone, so no
+// network is needed; the SDK chunk loads on first use.
+
+import type { DocumentTypeLayout } from '../model/layout';
+
+/** The SDK in use predates `documentTypeLayout`. */
+export class LayoutUnavailableError extends Error {}
+
+interface LayoutSdk {
+ ensureInitialized: () => Promise;
+ DataContract: { fromJSON: (json: unknown, fullValidation: boolean, platformVersion: unknown) => unknown };
+ PlatformVersion: { latest: () => unknown };
+ documentTypeLayout?: (contract: unknown, documentTypeName: string, platformVersion: unknown) => unknown;
+}
+
+const PLACEHOLDER_ID = '11111111111111111111111111111111';
+
+/**
+ * The contract JSON the SDK accepts: a pasted bare map of document schemas is
+ * wrapped, and a file without an id, owner or version gets placeholders (the
+ * layout does not depend on them).
+ */
+export function contractJsonForSdk(input: unknown): Record {
+ const obj = (input && typeof input === 'object' ? input : {}) as Record;
+ const schemas = obj.documentSchemas ?? obj.schemas ?? obj.documents;
+ const base: Record = schemas && typeof schemas === 'object' ? { ...obj, documentSchemas: schemas } : { documentSchemas: obj };
+ delete base.schemas;
+ delete base.documents;
+ return {
+ $formatVersion: '1',
+ version: 1,
+ ...base,
+ id: typeof base.id === 'string' ? base.id : PLACEHOLDER_ID,
+ ownerId: typeof base.ownerId === 'string' ? base.ownerId : PLACEHOLDER_ID,
+ };
+}
+
+export async function loadDocumentTypeLayout(contractJson: unknown, documentType: string): Promise {
+ const sdk = (await import('@dashevo/evo-sdk')) as unknown as LayoutSdk;
+ await sdk.ensureInitialized();
+ if (typeof sdk.documentTypeLayout !== 'function') {
+ throw new LayoutUnavailableError(
+ 'This build of the visualizer uses an @dashevo/evo-sdk without documentTypeLayout (added by dashpay/platform#5153). It appears with the next SDK release.',
+ );
+ }
+ const platformVersion = sdk.PlatformVersion.latest();
+ const contract = sdk.DataContract.fromJSON(contractJsonForSdk(contractJson), false, platformVersion);
+ return sdk.documentTypeLayout(contract, documentType, platformVersion) as DocumentTypeLayout;
+}
diff --git a/src/styles.css b/src/styles.css
index bc1e20e..6dcb83a 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -437,3 +437,37 @@ button { font: inherit; cursor: pointer; }
.cv-file-added { color: var(--diff-added); }
.cv-file-removed { color: var(--diff-removed); }
.cv-file-modified, .cv-file-renamed, .cv-file-changed { color: var(--diff-changed); }
+
+/* GroveDB layout panel */
+.cv-layout-button { margin: 8px 0 4px; border-radius: 6px; padding: 6px 12px; cursor: pointer; }
+.cv-layout { width: min(1000px, 96vw); max-height: 92vh; overflow-y: auto; }
+.cv-layout code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; background: var(--surface-2); padding: 0 3px; border-radius: 3px; }
+.cv-layout a { color: var(--accent); }
+.cv-layout-legend { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 10px; }
+.cv-layout-summary { font-size: 12.5px; color: var(--text-muted); margin: 6px 0; }
+.cv-layout-tree { list-style: none; margin: 0; padding-left: 18px; border-left: 1px dashed var(--border-strong); }
+.cv-layout-root { padding-left: 0; border-left: none; }
+.cv-layout-item { margin: 1px 0; }
+.cv-layout-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 2px 4px; border-radius: 4px; font-size: 12.5px; }
+.cv-layout-row:hover { background: var(--surface-2); }
+.cv-layout-caret { background: none; border: none; color: var(--text-muted); width: 16px; padding: 0; cursor: pointer; }
+.cv-layout-caret:disabled { cursor: default; }
+.cv-layout-prefix { font-size: 11.5px; color: var(--text-muted); font-style: italic; }
+.cv-layout-key { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
+.cv-layout-key.family { color: var(--text-muted); }
+.cv-layout-kind {
+ font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ padding: 0 5px; border-radius: 3px; border: 1px solid currentColor; white-space: nowrap;
+}
+.cv-kind-plain { color: var(--tone-storage); }
+.cv-kind-count { color: var(--ref-document); }
+.cv-kind-sum { color: var(--ref-identity); }
+.cv-kind-countSum { color: var(--ref-key); }
+.cv-kind-indexed { color: var(--ref-contract); }
+.cv-kind-value { color: var(--tone-neutral); border-style: dashed; }
+.cv-layout-wrapper { font-size: 11px; color: var(--diff-changed); border: 1px solid currentColor; border-radius: 3px; padding: 0 5px; }
+.cv-layout-ranked { font-size: 11px; color: var(--ref-contract); }
+.cv-layout-indexes { font-size: 11px; color: var(--text-muted); }
+.cv-layout-link { margin-left: auto; text-decoration: none; font-size: 12px; }
+.cv-layout-notes { margin: 0 0 2px 26px; padding: 0; list-style: none; font-size: 11.5px; color: var(--text-muted); }
+.cv-layout-notes li::before { content: '· '; }
From e7bcdfcbacdaa82bda079531197ead153f8f0412 Mon Sep 17 00:00:00 2001
From: Quantum Explorer
Date: Tue, 29 Sep 2026 03:33:08 +0700
Subject: [PATCH 3/4] test: refresh the layout fixtures from the merged
documentTypeLayout
dashpay/platform#5153 merged with a review fix: a unique index's null
fallback no longer repeats the terminal's indexes and notes.
Co-Authored-By: Claude Opus 5.5
---
src/model/fixtures/document-type-layouts.json | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/src/model/fixtures/document-type-layouts.json b/src/model/fixtures/document-type-layouts.json
index c706963..b0920ab 100644
--- a/src/model/fixtures/document-type-layouts.json
+++ b/src/model/fixtures/document-type-layouts.json
@@ -629,9 +629,7 @@
"structureNode": "contracts.contract.documents.document_type.index_property.value.members",
"element": "Tree",
"rankedAxes": [],
- "indexes": [
- "parentNameAndLabel"
- ],
+ "indexes": [],
"notes": [],
"children": [
{
From 9bbd3f55b72fcdfbb64e49c362e97691064d907f Mon Sep 17 00:00:00 2001
From: Quantum Explorer
Date: Tue, 29 Sep 2026 05:25:23 +0700
Subject: [PATCH 4/4] feat: show what a document costs, in Dash and dollars,
per index
Selecting a document type shows about what one of its documents costs;
Cost opens the breakdown, computed by Drive (documentCreateCost in
@dashevo/evo-sdk, dashpay/platform#5159): the first document with a set
of index values and a later one with the same values; the storage of
the document itself and of each index on its own (the part it shares
with other indexes, paid once, and its own); prepaid trees and a ttl
type's expiration entry; the processing (exact parts marked, the rest
estimated for a number of stored documents, a key type and a fee
increase); the contract's action fee, token cost and contest fund; and
the refund on delete. "Adjust the document" gives an on/off switch per
optional field and a length per variable-size one; fixed-size fields
need no input.
The Dash price comes from CoinGecko (Coinbase if that fails), kept five
minutes, and can be typed in. Feature-detected: with an SDK that
predates documentCreateCost the inspector says so. The layout and cost
loaders share one local SDK module.
Co-Authored-By: Claude Opus 5.5
---
README.md | 31 +-
src/App.tsx | 32 +-
src/components/CostLine.tsx | 68 +
src/components/CostPanel.tsx | 421 ++++
src/components/InspectorPanel.tsx | 51 +-
src/components/useDashPrice.ts | 31 +
src/model/cost.test.ts | 86 +
src/model/cost.ts | 145 ++
src/model/fixtures/document-create-costs.json | 2202 +++++++++++++++++
src/price.test.ts | 41 +
src/price.ts | 70 +
src/sdk/cost.ts | 23 +
src/sdk/layout.ts | 40 +-
src/sdk/local.ts | 52 +
src/styles.css | 31 +
15 files changed, 3279 insertions(+), 45 deletions(-)
create mode 100644 src/components/CostLine.tsx
create mode 100644 src/components/CostPanel.tsx
create mode 100644 src/components/useDashPrice.ts
create mode 100644 src/model/cost.test.ts
create mode 100644 src/model/cost.ts
create mode 100644 src/model/fixtures/document-create-costs.json
create mode 100644 src/price.test.ts
create mode 100644 src/price.ts
create mode 100644 src/sdk/cost.ts
create mode 100644 src/sdk/local.ts
diff --git a/README.md b/README.md
index 1e779fa..9e9f06b 100644
--- a/README.md
+++ b/README.md
@@ -84,6 +84,31 @@ The layout is computed by Drive's own rules (`documentTypeLayout` in `@dashevo/e
writes by a Drive test, and needs no network. It needs an SDK release that includes it; until
the dependency is bumped the panel says so.
+## What a document costs
+
+Selecting a document type shows about what creating one of its documents costs, in Dash and
+dollars; **Cost** (or *details*) opens the full breakdown:
+
+- two totals: the first document with a set of index values (it creates their trees) and a later
+ document with the same values (it adds only its own entries);
+- the storage, byte for byte: the document itself, and **each index on its own**, with the part it
+ shares with other indexes (a common prefix, paid once) and the part only it adds; trees prepaid
+ for other types' preallocated indexes; a `ttl` type's expiration entry;
+- the processing: the signature and identity fetch (exact) and the writes (estimated for a number
+ of stored documents you can change, with the signing key type and a fee increase);
+- what the contract charges: its action fee, a token cost, a contest's vote fund;
+- what deleting the document refunds;
+- **Adjust the document**: an on/off switch for each optional field and a length for each
+ variable-size one (strings, byte arrays, arrays). Fixed-size fields (identifiers, numbers,
+ booleans, dates) need no input. By default each variable field sits at the middle of its bounds,
+ the size Drive's own fee estimates assume, and each optional field is present.
+
+The numbers come from Drive (`documentCreateCost` in `@dashevo/evo-sdk`), which prices every
+element an insert writes with GroveDB's byte formulas and is held to the fees Drive charges by its
+tests; see [What a Document Costs](https://dashpay.github.io/platform/fees/document-cost.html).
+The Dash price comes from CoinGecko (Coinbase if that fails), and you can type your own. Like the
+layout, it needs an SDK release that includes it; until then the inspector says so.
+
## Compare two versions (contract updates)
**Compare** in the toolbar diagrams two versions of a contract at once: the union of both,
@@ -182,11 +207,14 @@ One-time: repo settings → **Pages → Source: GitHub Actions**.
src/
config.ts # contract id / example / link + network + view resolution (URL / localStorage / env)
urlSource.ts # ?url= and pasted links: GitHub blob -> raw, fetch + parse
+ price.ts # the Dash price in dollars (CoinGecko, then Coinbase) and a typed-in override
github.ts # a PR's changed JSON files at merge base and head (GitHub REST API)
sdk/
client.ts # Evo SDK trusted connection, memoised per network
contract.ts # any source (id, link, example) -> contract JSON -> ContractModel
+ local.ts # the SDK functions that run from a contract's JSON alone
layout.ts # documentTypeLayout through the SDK (feature-detected)
+ cost.ts # documentCreateCost through the SDK (feature-detected)
pool.ts # pooled connections, resettable without loading the SDK
examples/ # bundled example contracts + registry
model/
@@ -197,6 +225,7 @@ src/
describe.ts # keyword chips and plain-language descriptions
diff.ts # compare two versions: changes, statuses, the union model
layout.ts # a document type's GroveDB layout (from the SDK) and its display helpers
+ cost.ts # what a document costs (from the SDK) and its formatting helpers
updateRules.ts # each keyword's update rule, per the book, with its error code
types.ts
fixtures/ # the SDK's reading of the examples' references
@@ -208,7 +237,7 @@ src/
Canvas.tsx # React Flow canvas, legend, export/re-layout panel
selection.ts, exportImage.ts
components/ # Toolbar, InspectorPanel, ContractMetaPanel, PasteContractModal,
- # CompareModal, ChangesPanel, LayoutPanel
+ # CompareModal, ChangesPanel, LayoutPanel, CostPanel, CostLine
App.tsx, main.tsx, styles.css
scripts/
validate-examples.mjs # examples through DataContract.fromJSON(…, true, 14)
diff --git a/src/App.tsx b/src/App.tsx
index 0086360..5ae16cf 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -15,6 +15,7 @@ import { InspectorPanel } from './components/InspectorPanel';
import { PasteContractModal } from './components/PasteContractModal';
import { ContractMetaPanel } from './components/ContractMetaPanel';
import { ChangesPanel } from './components/ChangesPanel';
+import { CostPanel } from './components/CostPanel';
import { LayoutPanel } from './components/LayoutPanel';
import { CompareModal, type CompareSpec } from './components/CompareModal';
import { EXAMPLES, exampleId, exampleKey } from './examples';
@@ -73,6 +74,7 @@ export default function App() {
const [compareInitialPr, setCompareInitialPr] = useState(undefined);
const [focus, setFocus] = useState<{ id: string; n: number } | undefined>(undefined);
const [layoutView, setLayoutView] = useState<{ documentType: string; json: unknown } | null>(null);
+ const [costView, setCostView] = useState<{ documentType: string; json: unknown } | null>(null);
// The JSON of the contract on screen (both sides in compare mode), for the GroveDB layout panel.
const contractJsonRef = useRef(null);
const compareJsonRef = useRef<{ base: unknown; head: unknown } | null>(null);
@@ -305,18 +307,35 @@ export default function App() {
[load, network, devnetName],
);
- /** Open the GroveDB layout of a document type: the head's version in compare mode, else the base's. */
- const openLayout = useCallback((documentType: string) => {
+ /** The contract JSON of a document type: the head's version in compare mode, else the base's. */
+ const contractJsonFor = useCallback((documentType: string): unknown => {
const compare = compareJsonRef.current;
const d = diffRef.current;
- const json = compare && d
+ return compare && d
? d.head.entities.some((e) => e.name === documentType)
? compare.head
: compare.base
: contractJsonRef.current;
- if (json) setLayoutView({ documentType, json });
}, []);
+ /** Open the GroveDB layout of a document type. */
+ const openLayout = useCallback(
+ (documentType: string) => {
+ const json = contractJsonFor(documentType);
+ if (json) setLayoutView({ documentType, json });
+ },
+ [contractJsonFor],
+ );
+
+ /** Open what a document of a type costs. */
+ const openCost = useCallback(
+ (documentType: string) => {
+ const json = contractJsonFor(documentType);
+ if (json) setCostView({ documentType, json });
+ },
+ [contractJsonFor],
+ );
+
const onNetwork = useCallback((n: Network) => {
setNetwork(n);
resetConnections();
@@ -482,6 +501,8 @@ export default function App() {
onClose={() => setSelection(null)}
diff={diff ?? undefined}
onShowLayout={openLayout}
+ contractJsonFor={contractJsonFor}
+ onShowCost={openCost}
/>
)}
@@ -489,6 +510,9 @@ export default function App() {
{layoutView && (
setLayoutView(null)} />
)}
+ {costView && (
+ setCostView(null)} />
+ )}
{pasteOpen && (
setPasteOpen(false)} error={pasteError} />
)}
diff --git a/src/components/CostLine.tsx b/src/components/CostLine.tsx
new file mode 100644
index 0000000..4555ed8
--- /dev/null
+++ b/src/components/CostLine.tsx
@@ -0,0 +1,68 @@
+import { useEffect, useState } from 'react';
+import { formatCredits, type DocumentCreateCost } from '../model/cost';
+import { CostUnavailableError, loadDocumentCreateCost } from '../sdk/cost';
+import { useDashPrice } from './useDashPrice';
+
+type State =
+ | { status: 'loading' }
+ | { status: 'ready'; cost: DocumentCreateCost }
+ | { status: 'error'; message: string; unavailable: boolean };
+
+/**
+ * The approximate cost of one document of a type, for a document of middle
+ * sizes, with a button to the full breakdown.
+ */
+export function CostLine({
+ documentType,
+ contractJson,
+ onShowCost,
+}: {
+ documentType: string;
+ contractJson: unknown;
+ onShowCost: () => void;
+}) {
+ const [state, setState] = useState({ status: 'loading' });
+ const { usdPerDash } = useDashPrice();
+
+ useEffect(() => {
+ let alive = true;
+ setState({ status: 'loading' });
+ loadDocumentCreateCost(contractJson, documentType)
+ .then((cost) => alive && setState({ status: 'ready', cost }))
+ .catch((err: unknown) => {
+ if (!alive) return;
+ setState({
+ status: 'error',
+ message: err instanceof Error ? err.message : String(err),
+ unavailable: err instanceof CostUnavailableError,
+ });
+ });
+ return () => {
+ alive = false;
+ };
+ }, [contractJson, documentType]);
+
+ if (state.status === 'loading') return Estimating what a document costs…
;
+ if (state.status === 'error') {
+ return (
+
+ {state.unavailable ? 'The cost estimate appears with the next SDK release.' : 'No cost estimate for this type.'}
+
+ );
+ }
+ const { cost } = state;
+ return (
+
+ About {formatCredits(cost, cost.totalCredits.newValues, usdPerDash)} per document
+ {cost.totalCredits.knownValues < cost.totalCredits.newValues && (
+
+ {' '}
+ ({formatCredits(cost, cost.totalCredits.knownValues, usdPerDash)} once its index values are stored)
+
+ )}{' '}
+
+ details
+
+
+ );
+}
diff --git a/src/components/CostPanel.tsx b/src/components/CostPanel.tsx
new file mode 100644
index 0000000..c235d64
--- /dev/null
+++ b/src/components/CostPanel.tsx
@@ -0,0 +1,421 @@
+import { useEffect, useMemo, useState } from 'react';
+import {
+ SIGNATURE_KEY_TYPES,
+ adjustableFields,
+ bytesToCredits,
+ formatBytes,
+ formatCredits,
+ indexAlone,
+ type CostOptions,
+ type DocumentCreateCost,
+ type Scenarios,
+} from '../model/cost';
+import { CostUnavailableError, loadDocumentCreateCost } from '../sdk/cost';
+import { useDashPrice } from './useDashPrice';
+
+interface Props {
+ documentType: string;
+ contractJson: unknown;
+ onClose: () => void;
+}
+
+type State =
+ | { status: 'loading'; previous?: DocumentCreateCost }
+ | { status: 'ready'; cost: DocumentCreateCost }
+ | { status: 'error'; message: string; unavailable: boolean };
+
+const BOOK = 'https://dashpay.github.io/platform/fees/document-cost.html';
+
+function PriceLine({ price }: { price: ReturnType }) {
+ const [draft, setDraft] = useState(price.override === null ? '' : String(price.override));
+ const fetched = price.fetched;
+ return (
+
+
+ {price.override !== null
+ ? `1 DASH = $${price.override} (your price)`
+ : fetched
+ ? `1 DASH = $${fetched.usd.toLocaleString('en-US')} (${fetched.source}, ${fetched.at.toLocaleTimeString()})`
+ : 'Fetching the Dash price…'}
+
+
+ use my price $
+ {
+ setDraft(e.target.value);
+ const usd = Number(e.target.value);
+ price.setOverride(e.target.value === '' || !(usd > 0) ? null : usd);
+ }}
+ />
+
+
+ );
+}
+
+function Amount({ cost, credits, usd }: { cost: DocumentCreateCost; credits: number; usd: number | null }) {
+ return {formatCredits(cost, credits, usd)} ;
+}
+
+function BytesRow({
+ cost,
+ label,
+ detail,
+ bytes,
+ usd,
+}: {
+ cost: DocumentCreateCost;
+ label: React.ReactNode;
+ detail?: string;
+ bytes: Scenarios;
+ usd: number | null;
+}) {
+ return (
+
+
+ {label}
+ {detail && {detail}
}
+
+
+ {formatBytes(bytes.newValues)}
+
+
+
+ {formatBytes(bytes.knownValues)}
+
+
+
+ );
+}
+
+function Fields({
+ cost,
+ options,
+ onChange,
+}: {
+ cost: DocumentCreateCost;
+ options: CostOptions;
+ onChange: (fields: CostOptions['fields']) => void;
+}) {
+ const adjustable = adjustableFields(cost);
+ const fixed = cost.fields.length - adjustable.length;
+ const set = (path: string, change: { present?: boolean; length?: number }) =>
+ onChange({ ...options.fields, [path]: { ...options.fields?.[path], ...change } });
+ return (
+ <>
+ {adjustable.length === 0 && Every field has a fixed size: nothing to adjust.
}
+
+ {fixed > 0 && (
+
+ {fixed} field{fixed === 1 ? ' has' : 's have'} a fixed size (identifiers, numbers, booleans, dates) and need
+ no input.
+
+ )}
+ >
+ );
+}
+
+export function CostPanel({ documentType, contractJson, onClose }: Props) {
+ const [options, setOptions] = useState({});
+ const [state, setState] = useState({ status: 'loading' });
+ const price = useDashPrice();
+ const usd = price.usdPerDash;
+
+ useEffect(() => {
+ let alive = true;
+ setState((s) => ({ status: 'loading', previous: s.status === 'ready' ? s.cost : undefined }));
+ const timer = setTimeout(() => {
+ loadDocumentCreateCost(contractJson, documentType, options)
+ .then((cost) => alive && setState({ status: 'ready', cost }))
+ .catch((err: unknown) => {
+ if (!alive) return;
+ setState({
+ status: 'error',
+ message: err instanceof Error ? err.message : String(err),
+ unavailable: err instanceof CostUnavailableError,
+ });
+ });
+ }, 200);
+ return () => {
+ alive = false;
+ clearTimeout(timer);
+ };
+ }, [contractJson, documentType, options]);
+
+ const cost = state.status === 'ready' ? state.cost : state.status === 'loading' ? state.previous : undefined;
+ const indexes = useMemo(
+ () => (cost ? [...cost.indexes].sort((a, b) => indexAlone(b).newValues - indexAlone(a).newValues) : []),
+ [cost],
+ );
+
+ return (
+
+
e.stopPropagation()}
+ role="dialog"
+ aria-label={`What a ${documentType} costs`}
+ >
+
+
+ What a {documentType} document costs
+
+
+ ✕
+
+
+
+ Computed by Drive's own fee rules (documentCreateCost) from the contract, for a document of the
+ sizes set below. The storage is exact; processing is estimated.{' '}
+
+ How it is computed
+
+
+
+
+ {state.status === 'error' && (
+
+ {state.unavailable ? state.message : `Could not compute the cost: ${state.message}`}
+
+ )}
+ {!cost && state.status === 'loading' && (
+
Computing the cost (loading the SDK the first time)…
+ )}
+
+ {cost && (
+
+
+
+
First document with these index values
+
+
+
+
+ {formatBytes(cost.storage.bytes.newValues)} stored · the document is{' '}
+ {formatBytes(cost.documentBytes)}
+
+
+
+
A later document with the same values
+
+
+
+
+ {formatBytes(cost.storage.bytes.knownValues)} stored: only its own entries
+
+
+
+ {cost.contractCharges.some((c) => c.kind !== 'actionFee') && (
+
Plus what the contract charges besides credits, listed below.
+ )}
+
+
Storage
+
+
+
+
+ values new
+ values stored
+
+
+
+
+ {indexes.map((index) => {
+ const shared = index.sharedWith.length > 0;
+ return (
+
+ index {index.name} on its own
+ >
+ }
+ detail={
+ shared
+ ? `shared with ${index.sharedWith.join(', ')}: ${formatBytes(index.sharedBytes.newValues)} (${formatCredits(cost, bytesToCredits(cost, index.sharedBytes.newValues), usd)}, paid once) + its own ${formatBytes(index.ownBytes.newValues)} (${formatCredits(cost, bytesToCredits(cost, index.ownBytes.newValues), usd)})`
+ : undefined
+ }
+ bytes={indexAlone(index)}
+ usd={usd}
+ />
+ );
+ })}
+ {cost.storage.expirationBytes.newValues > 0 && (
+
+ )}
+ {cost.storage.preallocatedBytes.newValues > 0 && (
+
+ )}
+ All storage} bytes={cost.storage.bytes} usd={usd} />
+
+
+
+
Processing
+
+
+ {cost.processing.map((part) => (
+
+
+ {part.text} {part.exact ? 'exact' : 'estimated'}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ documents already stored
+ setOptions({ ...options, existingDocuments: Math.max(0, Number(e.target.value)) })}
+ />
+
+
+ signing key
+ setOptions({ ...options, signatureKeyType: e.target.value })}
+ >
+ {SIGNATURE_KEY_TYPES.map((type) => (
+ {type}
+ ))}
+
+
+
+ fee increase %
+ setOptions({ ...options, userFeeIncrease: Math.max(0, Number(e.target.value)) })}
+ />
+
+
+
+ {cost.contractCharges.length > 0 && (
+ <>
+
Charged by the contract
+
+ {cost.contractCharges.map((charge, i) => (
+
+ {charge.kind === 'actionFee' && (
+ <>
+ Action fee:{' '}
+ {' '}
+
+ ({charge.charged.owner.toLocaleString('en-US')} credits to the owner,{' '}
+ {charge.charged.moderators.toLocaleString('en-US')} to the moderators
+ {charge.pricing === 'feeMultiplier' ? ', scaled by the fee multiplier' : ''}; included in
+ the totals)
+
+ >
+ )}
+ {charge.kind === 'tokenCost' && (
+ <>
+ Token cost: {charge.amount.toLocaleString('en-US')} of token #{charge.tokenPosition}
+ {charge.tokenContractId ? ` of contract ${charge.tokenContractId}` : ''},{' '}
+ {charge.effect === 'burn' ? 'burned' : 'paid to the contract owner'}
+ {charge.optional ? ' (optional)' : ''}
+ >
+ )}
+ {charge.kind === 'contestFund' && (
+ <>
+ Contest fund: {' '}
+
+ when the value of {charge.index} is contested (doubling past
+ 250 contenders)
+
+ >
+ )}
+
+ ))}
+
+ >
+ )}
+
+ {cost.refund.sameEpoch && cost.refund.afterOneYear && (
+ <>
+
Refund when deleted
+
+ In the same epoch: · a year
+ later: (the storage fee,
+ less what the epochs passed were paid)
+
+ >
+ )}
+
+
Adjust the document
+
setOptions({ ...options, fields })} />
+
+ )}
+
+
+ );
+}
diff --git a/src/components/InspectorPanel.tsx b/src/components/InspectorPanel.tsx
index 1668298..fc91a8a 100644
--- a/src/components/InspectorPanel.tsx
+++ b/src/components/InspectorPanel.tsx
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react';
import type { Change, ContractDiff } from '../model/diff';
import { ChangeBody } from './ChangesPanel';
+import { CostLine } from './CostLine';
import type { Selection } from '../flow/selection';
import type { ContractModel, Entity, Field, RefExpr, RefTarget, Reference, Relationship } from '../model/types';
import { relationshipFields } from '../model/relationships';
@@ -27,6 +28,10 @@ interface Props {
diff?: ContractDiff;
/** Open the GroveDB layout panel for a document type. */
onShowLayout?: (documentType: string) => void;
+ /** The contract JSON a document type's cost is computed from. */
+ contractJsonFor?: (documentType: string) => unknown;
+ /** Open the cost panel for a document type. */
+ onShowCost?: (documentType: string) => void;
}
function changeFor(diff: ContractDiff | undefined, selection: Selection): Change | undefined {
@@ -254,11 +259,16 @@ function EntityView({
entity,
model,
onShowLayout,
+ contractJsonFor,
+ onShowCost,
}: {
entity: Entity;
model: ContractModel;
onShowLayout?: (documentType: string) => void;
+ contractJsonFor?: (documentType: string) => unknown;
+ onShowCost?: (documentType: string) => void;
}) {
+ const costJson = contractJsonFor?.(entity.name);
const chips = documentTypeChips(entity);
const shown = new Set(['ttl', 'indexOnly', 'documentsMutable', 'canBeDeleted', 'canBeDeletedByModerators', 'canBeDeletedByModeratorsFor', 'creationRestrictionMode', 'transferable', 'tradeMode', 'documentsKeepHistory', 'keepsTransferHistory', 'keepsPurchaseHistory', 'keepsPricingHistory', 'actionFees', 'tokenCost', 'documentsCountable', 'documentsSummable', 'documentsAverageable', 'rangeCountable', 'rangeSummable', 'rangeAverageable', 'signatureSecurityLevelRequirement', 'requiresIdentityEncryptionBoundedKey', 'requiresIdentityDecryptionBoundedKey']);
const rest = Object.fromEntries(Object.entries(entity.config).filter(([k]) => !shown.has(k)));
@@ -272,11 +282,21 @@ function EntityView({
{entity.indices.length} indexes · {outgoing} references out · {incoming} in
{entity.description && {entity.description}
}
- {onShowLayout && (
- onShowLayout(entity.name)}>
- GroveDB layout
-
+ {costJson != null && onShowCost && (
+ onShowCost(entity.name)} />
)}
+
+ {onShowLayout && (
+ onShowLayout(entity.name)}>
+ GroveDB layout
+
+ )}
+ {costJson != null && onShowCost && (
+ onShowCost(entity.name)}>
+ Cost
+
+ )}
+
{entity.typeReferences.map((r) => (
@@ -330,7 +350,18 @@ function RelationshipView({
);
}
-export function InspectorPanel({ selection, model, hiddenEdges, onToggleEdge, onOpenContract, onClose, diff, onShowLayout }: Props) {
+export function InspectorPanel({
+ selection,
+ model,
+ hiddenEdges,
+ onToggleEdge,
+ onOpenContract,
+ onClose,
+ diff,
+ onShowLayout,
+ contractJsonFor,
+ onShowCost,
+}: Props) {
if (!selection) return null;
const change = changeFor(diff, selection);
@@ -342,7 +373,15 @@ export function InspectorPanel({ selection, model, hiddenEdges, onToggleEdge, on
{change && }
- {selection.kind === 'entity' && }
+ {selection.kind === 'entity' && (
+
+ )}
{selection.kind === 'field' && }
diff --git a/src/components/useDashPrice.ts b/src/components/useDashPrice.ts
new file mode 100644
index 0000000..b09ff76
--- /dev/null
+++ b/src/components/useDashPrice.ts
@@ -0,0 +1,31 @@
+import { useCallback, useEffect, useState } from 'react';
+import { fetchDashPrice, readPriceOverride, writePriceOverride, type DashPrice } from '../price';
+
+/**
+ * The Dash price to show amounts in dollars with: the one the viewer typed
+ * in, else the fetched one (null while loading or when no source answers).
+ */
+export function useDashPrice(): {
+ usdPerDash: number | null;
+ fetched: DashPrice | null;
+ override: number | null;
+ setOverride: (usd: number | null) => void;
+} {
+ const [fetched, setFetched] = useState(null);
+ const [override, setOverrideState] = useState(() => readPriceOverride());
+
+ useEffect(() => {
+ let alive = true;
+ void fetchDashPrice().then((price) => alive && setFetched(price));
+ return () => {
+ alive = false;
+ };
+ }, []);
+
+ const setOverride = useCallback((usd: number | null) => {
+ writePriceOverride(usd);
+ setOverrideState(usd);
+ }, []);
+
+ return { usdPerDash: override ?? fetched?.usd ?? null, fetched, override, setOverride };
+}
diff --git a/src/model/cost.test.ts b/src/model/cost.test.ts
new file mode 100644
index 0000000..ad5d470
--- /dev/null
+++ b/src/model/cost.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from 'vitest';
+import {
+ adjustableFields,
+ bytesToCredits,
+ creditsToDash,
+ formatCredits,
+ formatDash,
+ formatUsd,
+ indexAlone,
+ type DocumentCreateCost,
+} from './cost';
+import fixtures from './fixtures/document-create-costs.json';
+
+// Costs computed by `documentCreateCost` (Drive) for bundled examples.
+const costs = fixtures as unknown as Record;
+const listing = costs['marketplace:listing'];
+const domain = costs['dpns:domain'];
+const tip = costs['yappr-likes:tip'];
+
+describe('formatting', () => {
+ it('shows Dash to three significant digits', () => {
+ expect(formatDash(0.00189234)).toBe('0.00189 DASH');
+ expect(formatDash(0.1)).toBe('0.1 DASH');
+ expect(formatDash(12.3456)).toBe('12.3 DASH');
+ });
+
+ it('shows dollars in cents, and small amounts to two significant digits', () => {
+ expect(formatUsd(0.1234)).toBe('$0.12');
+ expect(formatUsd(3.5)).toBe('$3.50');
+ expect(formatUsd(0.00123)).toBe('$0.0012');
+ expect(formatUsd(0)).toBe('$0');
+ });
+
+ it('converts credits at 100 billion a Dash', () => {
+ expect(creditsToDash(domain, 100_000_000_000)).toBe(1);
+ expect(formatCredits(domain, 10_000_000_000, 64.5)).toBe('0.1 DASH ($6.45)');
+ expect(formatCredits(domain, 10_000_000_000, null)).toBe('0.1 DASH');
+ });
+});
+
+describe('the fixtures', () => {
+ it('price storage at the rate they give', () => {
+ expect(domain.creditsPerByte).toBe(27_000);
+ expect(domain.storage.credits.newValues).toBe(bytesToCredits(domain, domain.storage.bytes.newValues));
+ });
+
+ it('price a document with a ttl by its lifetime, without a refund', () => {
+ expect(listing.creditsPerByte).toBeLessThan(27_000);
+ expect(listing.refund.sameEpoch?.newValues).toBe(0);
+ expect(listing.processing.map((p) => p.code)).toContain('ttlCleanup');
+ });
+
+ it('show a contested name with its contest fund', () => {
+ expect(domain.contractCharges).toContainEqual(
+ expect.objectContaining({ kind: 'contestFund', credits: 10_000_000_000 }),
+ );
+ });
+
+ it('cost an index on its own as its shared and own bytes', () => {
+ for (const index of listing.indexes) {
+ expect(indexAlone(index).newValues).toBe(index.sharedBytes.newValues + index.ownBytes.newValues);
+ }
+ // Every byte is primary storage, an index's (a shared layer once), a
+ // preallocation or an expiration entry.
+ const shared = new Map();
+ for (const element of listing.elements) {
+ if (element.indexes.length > 1 && !element.ephemeral) shared.set(element.path.join('/'), element.bytes);
+ }
+ const own = listing.indexes.reduce((sum, index) => sum + index.ownBytes.newValues, 0);
+ const sharedOnce = [...shared.values()].reduce((sum, bytes) => sum + bytes, 0);
+ expect(
+ listing.storage.primaryBytes.newValues +
+ own +
+ sharedOnce +
+ listing.storage.preallocatedBytes.newValues +
+ listing.storage.expirationBytes.newValues,
+ ).toBe(listing.storage.bytes.newValues);
+ });
+
+ it('offer controls for optional and variable-size fields only', () => {
+ const fields = adjustableFields(tip);
+ expect(fields.every((field) => field.optional || field.length !== undefined)).toBe(true);
+ expect(tip.fields.some((field) => field.kind === 'identifier' && !field.optional)).toBe(true);
+ expect(fields.some((field) => field.kind === 'identifier' && !field.optional)).toBe(false);
+ });
+});
diff --git a/src/model/cost.ts b/src/model/cost.ts
new file mode 100644
index 0000000..fbc6cef
--- /dev/null
+++ b/src/model/cost.ts
@@ -0,0 +1,145 @@
+// What creating a document of a type costs, as `documentCreateCost` in
+// @dashevo/evo-sdk returns it (computed by Drive), and the helpers the cost
+// panel shows it with.
+
+/**
+ * An amount under both storage scenarios: every index value new (the first
+ * document with these values creates their trees) and every value already
+ * stored (a later document with the same values adds only its own entries).
+ */
+export interface Scenarios {
+ newValues: number;
+ knownValues: number;
+}
+
+export type Scenario = keyof Scenarios;
+
+export interface CostField {
+ path: string;
+ kind: string;
+ optional: boolean;
+ present: boolean;
+ /** Only for a variable-size field. */
+ length?: number;
+ minLength?: number;
+ maxLength?: number;
+}
+
+export type ContractCharge =
+ | {
+ kind: 'actionFee';
+ pricing: 'feeMultiplier' | 'fixed';
+ declared: { owner: number; moderators: number };
+ charged: { owner: number; moderators: number };
+ }
+ | {
+ kind: 'tokenCost';
+ tokenPosition: number;
+ amount: number;
+ effect: 'transferToContractOwner' | 'burn';
+ gasFeesPaidBy: string;
+ optional: boolean;
+ tokenContractId?: string;
+ }
+ | { kind: 'contestFund'; index: string; credits: number };
+
+export interface DocumentCreateCost {
+ documentType: string;
+ assumptions: {
+ existingDocuments: number;
+ signatureKeyType: string;
+ userFeeIncrease: number;
+ feeMultiplierPermille: number;
+ contenders: number;
+ };
+ documentBytes: number;
+ creditsPerByte: number;
+ creditsPerDash: number;
+ storage: {
+ bytes: Scenarios;
+ credits: Scenarios;
+ primaryBytes: Scenarios;
+ preallocatedBytes: Scenarios;
+ expirationBytes: Scenarios;
+ };
+ indexes: Array<{ name: string; sharedWith: string[]; sharedBytes: Scenarios; ownBytes: Scenarios }>;
+ elements: Array<{
+ role: string;
+ path: string[];
+ bytes: number;
+ indexes: string[];
+ ifAbsent: boolean;
+ writtenWhenValuesKnown: boolean;
+ ephemeral: boolean;
+ expiration: boolean;
+ rankingAxis?: string;
+ referringType?: string;
+ }>;
+ processing: Array<{ code: string; text: string; credits: Scenarios; exact: boolean }>;
+ processingCredits: Scenarios;
+ contractCharges: ContractCharge[];
+ refund: { sameEpoch?: Scenarios; afterOneYear?: Scenarios };
+ totalCredits: Scenarios;
+ fields: CostField[];
+}
+
+/** What to ask `documentCreateCost` for. */
+export interface CostOptions {
+ fields?: Record;
+ existingDocuments?: number;
+ signatureKeyType?: string;
+ userFeeIncrease?: number;
+}
+
+export const SIGNATURE_KEY_TYPES = ['ECDSA_SECP256K1', 'ECDSA_HASH160', 'EDDSA_25519_HASH160', 'BLS12_381', 'BIP13_SCRIPT_HASH'];
+
+/** Storage bytes priced at the cost's rate. */
+export function bytesToCredits(cost: DocumentCreateCost, bytes: number): number {
+ return bytes * cost.creditsPerByte;
+}
+
+export function creditsToDash(cost: DocumentCreateCost, credits: number): number {
+ return credits / cost.creditsPerDash;
+}
+
+/** `sharedBytes + ownBytes`: what the index would cost on its own. */
+export function indexAlone(index: DocumentCreateCost['indexes'][number]): Scenarios {
+ return {
+ newValues: index.sharedBytes.newValues + index.ownBytes.newValues,
+ knownValues: index.sharedBytes.knownValues + index.ownBytes.knownValues,
+ };
+}
+
+/** Significant digits, without a long tail of zeros. */
+function significant(value: number, digits: number): string {
+ if (value === 0) return '0';
+ return Number(value.toPrecision(digits)).toLocaleString('en-US', { maximumSignificantDigits: digits });
+}
+
+/** A Dash amount: `0.00189 DASH`. */
+export function formatDash(dash: number): string {
+ return `${significant(dash, 3)} DASH`;
+}
+
+/** A dollar amount: cents above a cent, significant digits below. */
+export function formatUsd(usd: number): string {
+ if (usd === 0) return '$0';
+ if (usd >= 0.01) return `$${usd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
+ if (usd < 0.000001) return '<$0.000001';
+ return `$${significant(usd, 2)}`;
+}
+
+/** Credits in Dash, and in dollars when a price is known. */
+export function formatCredits(cost: DocumentCreateCost, credits: number, usdPerDash: number | null): string {
+ const dash = creditsToDash(cost, credits);
+ return usdPerDash === null ? formatDash(dash) : `${formatDash(dash)} (${formatUsd(dash * usdPerDash)})`;
+}
+
+export function formatBytes(bytes: number): string {
+ return `${bytes.toLocaleString('en-US')} B`;
+}
+
+/** The fields worth a control: an optional one (present or not) or a variable-size one (its length). */
+export function adjustableFields(cost: DocumentCreateCost): CostField[] {
+ return cost.fields.filter((field) => field.optional || field.length !== undefined);
+}
diff --git a/src/model/fixtures/document-create-costs.json b/src/model/fixtures/document-create-costs.json
new file mode 100644
index 0000000..0df9e47
--- /dev/null
+++ b/src/model/fixtures/document-create-costs.json
@@ -0,0 +1,2202 @@
+{
+ "marketplace:listing": {
+ "documentType": "listing",
+ "assumptions": {
+ "existingDocuments": 1000,
+ "signatureKeyType": "ECDSA_SECP256K1",
+ "userFeeIncrease": 0,
+ "feeMultiplierPermille": 1000,
+ "contenders": 0
+ },
+ "documentBytes": 309,
+ "creditsPerByte": 136,
+ "creditsPerDash": 100000000000,
+ "storage": {
+ "bytes": {
+ "newValues": 14980,
+ "knownValues": 7437
+ },
+ "credits": {
+ "newValues": 2037280,
+ "knownValues": 1011432
+ },
+ "primaryBytes": {
+ "newValues": 530,
+ "knownValues": 530
+ },
+ "preallocatedBytes": {
+ "newValues": 0,
+ "knownValues": 0
+ },
+ "expirationBytes": {
+ "newValues": 364,
+ "knownValues": 241
+ }
+ },
+ "indexes": [
+ {
+ "name": "byCategoryPrice",
+ "sharedWith": [],
+ "sharedBytes": {
+ "newValues": 0,
+ "knownValues": 0
+ },
+ "ownBytes": {
+ "newValues": 713,
+ "knownValues": 241
+ }
+ },
+ {
+ "name": "byShop",
+ "sharedWith": [],
+ "sharedBytes": {
+ "newValues": 0,
+ "knownValues": 0
+ },
+ "ownBytes": {
+ "newValues": 821,
+ "knownValues": 257
+ }
+ },
+ {
+ "name": "newListings",
+ "sharedWith": [],
+ "sharedBytes": {
+ "newValues": 0,
+ "knownValues": 0
+ },
+ "ownBytes": {
+ "newValues": 12552,
+ "knownValues": 6168
+ }
+ }
+ ],
+ "elements": [
+ {
+ "role": "document",
+ "path": [
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 530,
+ "indexes": [],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x80000197741ac180"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x80000197741ac180",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x80000197741ac180",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019773e3d300"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019773e3d300",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019773e3d300",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019773ace480"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019773ace480",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019773ace480",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001977375f600"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001977375f600",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001977375f600",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x80000197733f0780"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x80000197733f0780",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x80000197733f0780",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019773081900"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019773081900",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019773081900",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019772d12a80"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019772d12a80",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019772d12a80",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x80000197729a3c00"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x80000197729a3c00",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x80000197729a3c00",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019772634d80"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019772634d80",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019772634d80",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x80000197722c5f00"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x80000197722c5f00",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x80000197722c5f00",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019771f57080"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019771f57080",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019771f57080",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019771be8200"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019771be8200",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019771be8200",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019771879380"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019771879380",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019771879380",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001977150a500"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001977150a500",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001977150a500",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001977119b680"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001977119b680",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001977119b680",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019770e2c800"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019770e2c800",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019770e2c800",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019770abd980"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019770abd980",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019770abd980",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001977074eb00"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001977074eb00",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001977074eb00",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x80000197703dfc80"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x80000197703dfc80",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x80000197703dfc80",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019770070e00"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019770070e00",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x8000019770070e00",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001976fd01f80"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001976fd01f80",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001976fd01f80",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001976f993100"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001976f993100",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001976f993100",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001976f624280"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001976f624280",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001976f624280",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001976f2b5400"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001976f2b5400",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$createdAt#86400#3600",
+ "0x800001976f2b5400",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "newListings"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "category",
+ "aaaaaaaa"
+ ],
+ "bytes": 123,
+ "indexes": [
+ "byCategoryPrice"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "nextIndexProperty",
+ "path": [
+ "category",
+ "aaaaaaaa",
+ "price"
+ ],
+ "bytes": 117,
+ "indexes": [
+ "byCategoryPrice"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "category",
+ "aaaaaaaa",
+ "price",
+ "0x8000000000000001"
+ ],
+ "bytes": 123,
+ "indexes": [
+ "byCategoryPrice"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "category",
+ "aaaaaaaa",
+ "price",
+ "0x8000000000000001",
+ "0x00"
+ ],
+ "bytes": 109,
+ "indexes": [
+ "byCategoryPrice"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "category",
+ "aaaaaaaa",
+ "price",
+ "0x8000000000000001",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 241,
+ "indexes": [
+ "byCategoryPrice"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "shopId",
+ "0x0101010101010101010101010101010101010101010101010101010101010101"
+ ],
+ "bytes": 171,
+ "indexes": [
+ "byShop"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "nextIndexProperty",
+ "path": [
+ "shopId",
+ "0x0101010101010101010101010101010101010101010101010101010101010101",
+ "$createdAt"
+ ],
+ "bytes": 127,
+ "indexes": [
+ "byShop"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "shopId",
+ "0x0101010101010101010101010101010101010101010101010101010101010101",
+ "$createdAt",
+ "0x800001977420dc00"
+ ],
+ "bytes": 132,
+ "indexes": [
+ "byShop"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "shopId",
+ "0x0101010101010101010101010101010101010101010101010101010101010101",
+ "$createdAt",
+ "0x800001977420dc00",
+ "0x00"
+ ],
+ "bytes": 134,
+ "indexes": [
+ "byShop"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "shopId",
+ "0x0101010101010101010101010101010101010101010101010101010101010101",
+ "$createdAt",
+ "0x800001977420dc00",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "byShop"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "0x000001980e9fa400"
+ ],
+ "bytes": 123,
+ "indexes": [],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": true
+ },
+ {
+ "role": "member",
+ "path": [
+ "0x000001980e9fa400",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 241,
+ "indexes": [],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": true
+ }
+ ],
+ "processing": [
+ {
+ "code": "signature",
+ "text": "verifying the transition's ECDSA_SECP256K1 signature",
+ "credits": {
+ "newValues": 15000,
+ "knownValues": 15000
+ },
+ "exact": true
+ },
+ {
+ "code": "identity",
+ "text": "fetching the signing key and the balance of the identity",
+ "credits": {
+ "newValues": 18000,
+ "knownValues": 18000
+ },
+ "exact": true
+ },
+ {
+ "code": "writes",
+ "text": "writing the elements: seeks, hashing and rewriting the paths to them in trees of about 1000 entries",
+ "credits": {
+ "newValues": 12073680,
+ "knownValues": 16819520
+ },
+ "exact": false
+ },
+ {
+ "code": "checks",
+ "text": "checking the document does not exist yet and bumping the identity's nonce for the contract",
+ "credits": {
+ "newValues": 70400,
+ "knownValues": 70400
+ },
+ "exact": false
+ },
+ {
+ "code": "ttlCleanup",
+ "text": "the deletion of the document when its ttl runs out, prepaid",
+ "credits": {
+ "newValues": 12529780,
+ "knownValues": 12529780
+ },
+ "exact": true
+ }
+ ],
+ "processingCredits": {
+ "newValues": 24706860,
+ "knownValues": 29452700
+ },
+ "contractCharges": [
+ {
+ "kind": "actionFee",
+ "pricing": "feeMultiplier",
+ "declared": {
+ "owner": 100000,
+ "moderators": 50000
+ },
+ "charged": {
+ "owner": 100000,
+ "moderators": 50000
+ }
+ }
+ ],
+ "refund": {
+ "sameEpoch": {
+ "newValues": 0,
+ "knownValues": 0
+ },
+ "afterOneYear": {
+ "newValues": 0,
+ "knownValues": 0
+ }
+ },
+ "totalCredits": {
+ "newValues": 26894140,
+ "knownValues": 30614132
+ },
+ "fields": [
+ {
+ "path": "shopId",
+ "kind": "identifier",
+ "optional": false,
+ "present": true
+ },
+ {
+ "path": "title",
+ "kind": "string",
+ "optional": false,
+ "present": true,
+ "length": 60,
+ "minLength": 1,
+ "maxLength": 120
+ },
+ {
+ "path": "category",
+ "kind": "string",
+ "optional": false,
+ "present": true,
+ "length": 8,
+ "minLength": 0,
+ "maxLength": 16
+ },
+ {
+ "path": "price",
+ "kind": "integer",
+ "optional": false,
+ "present": true
+ },
+ {
+ "path": "salePrice",
+ "kind": "integer",
+ "optional": true,
+ "present": true
+ },
+ {
+ "path": "tags",
+ "kind": "array",
+ "optional": true,
+ "present": true,
+ "length": 4,
+ "minLength": 0,
+ "maxLength": 8
+ },
+ {
+ "path": "sku",
+ "kind": "string",
+ "optional": true,
+ "present": true,
+ "length": 16,
+ "minLength": 0,
+ "maxLength": 32
+ },
+ {
+ "path": "photoHash",
+ "kind": "byteArray",
+ "optional": false,
+ "present": true
+ }
+ ]
+ },
+ "dpns:domain": {
+ "documentType": "domain",
+ "assumptions": {
+ "existingDocuments": 1000,
+ "signatureKeyType": "ECDSA_SECP256K1",
+ "userFeeIncrease": 0,
+ "feeMultiplierPermille": 1000,
+ "contenders": 0
+ },
+ "documentBytes": 295,
+ "creditsPerByte": 27000,
+ "creditsPerDash": 100000000000,
+ "storage": {
+ "bytes": {
+ "newValues": 1785,
+ "knownValues": 1199
+ },
+ "credits": {
+ "newValues": 48195000,
+ "knownValues": 32373000
+ },
+ "primaryBytes": {
+ "newValues": 536,
+ "knownValues": 536
+ },
+ "preallocatedBytes": {
+ "newValues": 0,
+ "knownValues": 0
+ },
+ "expirationBytes": {
+ "newValues": 0,
+ "knownValues": 0
+ }
+ },
+ "indexes": [
+ {
+ "name": "identityId",
+ "sharedWith": [],
+ "sharedBytes": {
+ "newValues": 0,
+ "knownValues": 0
+ },
+ "ownBytes": {
+ "newValues": 558,
+ "knownValues": 278
+ }
+ },
+ {
+ "name": "parentNameAndLabel",
+ "sharedWith": [],
+ "sharedBytes": {
+ "newValues": 0,
+ "knownValues": 0
+ },
+ "ownBytes": {
+ "newValues": 691,
+ "knownValues": 385
+ }
+ }
+ ],
+ "elements": [
+ {
+ "role": "document",
+ "path": [
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 536,
+ "indexes": [],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "normalizedParentDomainName",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ ],
+ "bytes": 169,
+ "indexes": [
+ "parentNameAndLabel"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "nextIndexProperty",
+ "path": [
+ "normalizedParentDomainName",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "normalizedLabel"
+ ],
+ "bytes": 137,
+ "indexes": [
+ "parentNameAndLabel"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "normalizedParentDomainName",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "normalizedLabel",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ ],
+ "bytes": 169,
+ "indexes": [
+ "parentNameAndLabel"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "normalizedParentDomainName",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "normalizedLabel",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "0x00"
+ ],
+ "bytes": 216,
+ "indexes": [
+ "parentNameAndLabel"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "records.identity",
+ "0x0101010101010101010101010101010101010101010101010101010101010101"
+ ],
+ "bytes": 171,
+ "indexes": [
+ "identityId"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "records.identity",
+ "0x0101010101010101010101010101010101010101010101010101010101010101",
+ "0x00"
+ ],
+ "bytes": 109,
+ "indexes": [
+ "identityId"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "records.identity",
+ "0x0101010101010101010101010101010101010101010101010101010101010101",
+ "0x00",
+ "0x0202020202020202020202020202020202020202020202020202020202020202"
+ ],
+ "bytes": 278,
+ "indexes": [
+ "identityId"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ }
+ ],
+ "processing": [
+ {
+ "code": "signature",
+ "text": "verifying the transition's ECDSA_SECP256K1 signature",
+ "credits": {
+ "newValues": 15000,
+ "knownValues": 15000
+ },
+ "exact": true
+ },
+ {
+ "code": "identity",
+ "text": "fetching the signing key and the balance of the identity",
+ "credits": {
+ "newValues": 18000,
+ "knownValues": 18000
+ },
+ "exact": true
+ },
+ {
+ "code": "writes",
+ "text": "writing the elements: seeks, hashing and rewriting the paths to them in trees of about 1000 entries",
+ "credits": {
+ "newValues": 5238100,
+ "knownValues": 5780440
+ },
+ "exact": false
+ },
+ {
+ "code": "checks",
+ "text": "checking the document does not exist yet and bumping the identity's nonce for the contract",
+ "credits": {
+ "newValues": 70400,
+ "knownValues": 70400
+ },
+ "exact": false
+ }
+ ],
+ "processingCredits": {
+ "newValues": 5341500,
+ "knownValues": 5883840
+ },
+ "contractCharges": [
+ {
+ "kind": "contestFund",
+ "index": "parentNameAndLabel",
+ "credits": 10000000000
+ }
+ ],
+ "refund": {
+ "sameEpoch": {
+ "newValues": 27774318,
+ "knownValues": 27774318
+ },
+ "afterOneYear": {
+ "newValues": 26385228,
+ "knownValues": 26385228
+ }
+ },
+ "totalCredits": {
+ "newValues": 53536500,
+ "knownValues": 38256840
+ },
+ "fields": [
+ {
+ "path": "label",
+ "kind": "string",
+ "optional": false,
+ "present": true,
+ "length": 33,
+ "minLength": 3,
+ "maxLength": 63
+ },
+ {
+ "path": "normalizedLabel",
+ "kind": "string",
+ "optional": false,
+ "present": true,
+ "length": 31,
+ "minLength": 0,
+ "maxLength": 63
+ },
+ {
+ "path": "parentDomainName",
+ "kind": "string",
+ "optional": true,
+ "present": true,
+ "length": 31,
+ "minLength": 0,
+ "maxLength": 63
+ },
+ {
+ "path": "normalizedParentDomainName",
+ "kind": "string",
+ "optional": false,
+ "present": true,
+ "length": 31,
+ "minLength": 0,
+ "maxLength": 63
+ },
+ {
+ "path": "preorderSalt",
+ "kind": "byteArray",
+ "optional": false,
+ "present": true
+ },
+ {
+ "path": "records",
+ "kind": "object",
+ "optional": false,
+ "present": true
+ },
+ {
+ "path": "records.identity",
+ "kind": "identifier",
+ "optional": true,
+ "present": true
+ },
+ {
+ "path": "subdomainRules",
+ "kind": "object",
+ "optional": false,
+ "present": true
+ },
+ {
+ "path": "subdomainRules.allowSubdomains",
+ "kind": "boolean",
+ "optional": false,
+ "present": true
+ }
+ ]
+ },
+ "yappr-likes:tip": {
+ "documentType": "tip",
+ "assumptions": {
+ "existingDocuments": 1000,
+ "signatureKeyType": "ECDSA_SECP256K1",
+ "userFeeIncrease": 0,
+ "feeMultiplierPermille": 1000,
+ "contenders": 0
+ },
+ "documentBytes": 107,
+ "creditsPerByte": 27000,
+ "creditsPerDash": 100000000000,
+ "storage": {
+ "bytes": {
+ "newValues": 2273,
+ "knownValues": 583
+ },
+ "credits": {
+ "newValues": 61371000,
+ "knownValues": 15741000
+ },
+ "primaryBytes": {
+ "newValues": 0,
+ "knownValues": 0
+ },
+ "preallocatedBytes": {
+ "newValues": 0,
+ "knownValues": 0
+ },
+ "expirationBytes": {
+ "newValues": 0,
+ "knownValues": 0
+ }
+ },
+ "indexes": [
+ {
+ "name": "byPost",
+ "sharedWith": [],
+ "sharedBytes": {
+ "newValues": 0,
+ "knownValues": 0
+ },
+ "ownBytes": {
+ "newValues": 1336,
+ "knownValues": 312
+ }
+ },
+ {
+ "name": "byTipperAmount",
+ "sharedWith": [],
+ "sharedBytes": {
+ "newValues": 0,
+ "knownValues": 0
+ },
+ "ownBytes": {
+ "newValues": 937,
+ "knownValues": 271
+ }
+ }
+ ],
+ "elements": [
+ {
+ "role": "indexValue",
+ "path": [
+ "$ownerId",
+ "0x0101010101010101010101010101010101010101010101010101010101010101"
+ ],
+ "bytes": 207,
+ "indexes": [
+ "byTipperAmount"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "nextIndexProperty",
+ "path": [
+ "$ownerId",
+ "0x0101010101010101010101010101010101010101010101010101010101010101",
+ "amount"
+ ],
+ "bytes": 155,
+ "indexes": [
+ "byTipperAmount"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "$ownerId",
+ "0x0101010101010101010101010101010101010101010101010101010101010101",
+ "amount",
+ "0x8000000000000001"
+ ],
+ "bytes": 159,
+ "indexes": [
+ "byTipperAmount"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "$ownerId",
+ "0x0101010101010101010101010101010101010101010101010101010101010101",
+ "amount",
+ "0x8000000000000001",
+ "0x00"
+ ],
+ "bytes": 145,
+ "indexes": [
+ "byTipperAmount"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "$ownerId",
+ "0x0101010101010101010101010101010101010101010101010101010101010101",
+ "amount",
+ "0x8000000000000001",
+ "0x00",
+ "0x0101010101010101010101010101010101010101010101010101010101010101"
+ ],
+ "bytes": 271,
+ "indexes": [
+ "byTipperAmount"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "postId",
+ "0x0101010101010101010101010101010101010101010101010101010101010101"
+ ],
+ "bytes": 257,
+ "indexes": [
+ "byPost"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "postId",
+ "0x0101010101010101010101010101010101010101010101010101010101010101",
+ "0xff00",
+ "0x00000000000000000101010101010101010101010101010101010101010101010101010101010101"
+ ],
+ "bytes": 286,
+ "indexes": [
+ "byPost"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false,
+ "rankingAxis": "count"
+ },
+ {
+ "role": "indexValue",
+ "path": [
+ "postId",
+ "0x0101010101010101010101010101010101010101010101010101010101010101",
+ "0xff01",
+ "0x00000000000000000101010101010101010101010101010101010101010101010101010101010101"
+ ],
+ "bytes": 286,
+ "indexes": [
+ "byPost"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false,
+ "rankingAxis": "sum"
+ },
+ {
+ "role": "terminal",
+ "path": [
+ "postId",
+ "0x0101010101010101010101010101010101010101010101010101010101010101",
+ "0x00"
+ ],
+ "bytes": 195,
+ "indexes": [
+ "byPost"
+ ],
+ "ifAbsent": true,
+ "writtenWhenValuesKnown": false,
+ "ephemeral": false,
+ "expiration": false
+ },
+ {
+ "role": "member",
+ "path": [
+ "postId",
+ "0x0101010101010101010101010101010101010101010101010101010101010101",
+ "0x00",
+ "0x0101010101010101010101010101010101010101010101010101010101010101"
+ ],
+ "bytes": 312,
+ "indexes": [
+ "byPost"
+ ],
+ "ifAbsent": false,
+ "writtenWhenValuesKnown": true,
+ "ephemeral": false,
+ "expiration": false
+ }
+ ],
+ "processing": [
+ {
+ "code": "signature",
+ "text": "verifying the transition's ECDSA_SECP256K1 signature",
+ "credits": {
+ "newValues": 15000,
+ "knownValues": 15000
+ },
+ "exact": true
+ },
+ {
+ "code": "identity",
+ "text": "fetching the signing key and the balance of the identity",
+ "credits": {
+ "newValues": 18000,
+ "knownValues": 18000
+ },
+ "exact": true
+ },
+ {
+ "code": "writes",
+ "text": "writing the elements: seeks, hashing and rewriting the paths to them in trees of about 1000 entries",
+ "credits": {
+ "newValues": 5957020,
+ "knownValues": 4250520
+ },
+ "exact": false
+ },
+ {
+ "code": "checks",
+ "text": "checking the document does not exist yet and bumping the identity's nonce for the contract",
+ "credits": {
+ "newValues": 70400,
+ "knownValues": 70400
+ },
+ "exact": false
+ }
+ ],
+ "processingCredits": {
+ "newValues": 6060420,
+ "knownValues": 4353920
+ },
+ "contractCharges": [],
+ "refund": {
+ "sameEpoch": {
+ "newValues": 61293287,
+ "knownValues": 15720404
+ },
+ "afterOneYear": {
+ "newValues": 58227835,
+ "knownValues": 14934151
+ }
+ },
+ "totalCredits": {
+ "newValues": 67431420,
+ "knownValues": 20094920
+ },
+ "fields": [
+ {
+ "path": "postId",
+ "kind": "identifier",
+ "optional": false,
+ "present": true
+ },
+ {
+ "path": "amount",
+ "kind": "integer",
+ "optional": false,
+ "present": true
+ }
+ ]
+ }
+}
diff --git a/src/price.test.ts b/src/price.test.ts
new file mode 100644
index 0000000..0384c82
--- /dev/null
+++ b/src/price.test.ts
@@ -0,0 +1,41 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { clearPriceCache, fetchDashPrice } from './price';
+
+function answer(body: unknown, ok = true): Response {
+ return { ok, json: async () => body } as unknown as Response;
+}
+
+afterEach(() => clearPriceCache());
+
+describe('fetchDashPrice', () => {
+ it('reads the CoinGecko price', async () => {
+ const price = await fetchDashPrice(async () => answer({ dash: { usd: 64.5 } }));
+ expect(price).toMatchObject({ usd: 64.5, source: 'CoinGecko' });
+ });
+
+ it('falls back to Coinbase when CoinGecko fails', async () => {
+ const seen: string[] = [];
+ const price = await fetchDashPrice(async (url) => {
+ seen.push(String(url));
+ if (String(url).includes('coingecko')) throw new Error('rate limited');
+ return answer({ data: { amount: '64.53', base: 'DASH', currency: 'USD' } });
+ });
+ expect(price).toMatchObject({ usd: 64.53, source: 'Coinbase' });
+ expect(seen).toHaveLength(2);
+ });
+
+ it('gives up without a price when no source answers with one', async () => {
+ expect(await fetchDashPrice(async () => answer({}, false))).toBeNull();
+ });
+
+ it('keeps a fetched price for a while', async () => {
+ let calls = 0;
+ const fetcher = async () => {
+ calls += 1;
+ return answer({ dash: { usd: 70 } });
+ };
+ await fetchDashPrice(fetcher);
+ await fetchDashPrice(fetcher);
+ expect(calls).toBe(1);
+ });
+});
diff --git a/src/price.ts b/src/price.ts
new file mode 100644
index 0000000..799852e
--- /dev/null
+++ b/src/price.ts
@@ -0,0 +1,70 @@
+// The Dash price in US dollars, from public price APIs (both allow requests
+// from any origin): CoinGecko first, Coinbase if it fails. Kept for five
+// minutes. A price the viewer types in overrides it and is remembered in this
+// browser.
+
+export interface DashPrice {
+ usd: number;
+ source: string;
+ at: Date;
+}
+
+const SOURCES: Array<{ name: string; url: string; read: (body: unknown) => unknown }> = [
+ {
+ name: 'CoinGecko',
+ url: 'https://api.coingecko.com/api/v3/simple/price?ids=dash&vs_currencies=usd',
+ read: (body) => (body as { dash?: { usd?: unknown } })?.dash?.usd,
+ },
+ {
+ name: 'Coinbase',
+ url: 'https://api.coinbase.com/v2/prices/DASH-USD/spot',
+ read: (body) => Number((body as { data?: { amount?: unknown } })?.data?.amount),
+ },
+];
+
+const KEEP_MS = 5 * 60 * 1000;
+const OVERRIDE_KEY = 'cv-dash-usd-override';
+
+let cached: DashPrice | null = null;
+
+/** The price from the first source that answers with one, or null. */
+export async function fetchDashPrice(fetcher: typeof fetch = fetch): Promise {
+ if (cached && Date.now() - cached.at.getTime() < KEEP_MS) return cached;
+ for (const source of SOURCES) {
+ try {
+ const response = await fetcher(source.url);
+ if (!response.ok) continue;
+ const usd = source.read(await response.json());
+ if (typeof usd === 'number' && Number.isFinite(usd) && usd > 0) {
+ cached = { usd, source: source.name, at: new Date() };
+ return cached;
+ }
+ } catch {
+ // try the next source
+ }
+ }
+ return null;
+}
+
+/** Forget the kept price (for tests). */
+export function clearPriceCache(): void {
+ cached = null;
+}
+
+export function readPriceOverride(): number | null {
+ try {
+ const value = Number(localStorage.getItem(OVERRIDE_KEY));
+ return Number.isFinite(value) && value > 0 ? value : null;
+ } catch {
+ return null;
+ }
+}
+
+export function writePriceOverride(usd: number | null): void {
+ try {
+ if (usd === null) localStorage.removeItem(OVERRIDE_KEY);
+ else localStorage.setItem(OVERRIDE_KEY, String(usd));
+ } catch {
+ // not remembered in this browser
+ }
+}
diff --git a/src/sdk/cost.ts b/src/sdk/cost.ts
new file mode 100644
index 0000000..0c7e8d5
--- /dev/null
+++ b/src/sdk/cost.ts
@@ -0,0 +1,23 @@
+// Ask the Evo SDK what creating a document of a type costs. Drive computes it
+// (`documentCreateCost`) from the contract alone, so no network is needed.
+
+import type { CostOptions, DocumentCreateCost } from '../model/cost';
+import { loadLocalSdk, localContract } from './local';
+
+/** The SDK in use predates `documentCreateCost`. */
+export class CostUnavailableError extends Error {}
+
+export async function loadDocumentCreateCost(
+ contractJson: unknown,
+ documentType: string,
+ options: CostOptions = {},
+): Promise {
+ const sdk = await loadLocalSdk();
+ if (typeof sdk.documentCreateCost !== 'function') {
+ throw new CostUnavailableError(
+ 'This build of the visualizer uses an @dashevo/evo-sdk without documentCreateCost. The cost estimate appears with the next SDK release.',
+ );
+ }
+ const { contract, platformVersion } = localContract(sdk, contractJson);
+ return sdk.documentCreateCost(contract, documentType, options, platformVersion) as DocumentCreateCost;
+}
diff --git a/src/sdk/layout.ts b/src/sdk/layout.ts
index 2818478..321dc8e 100644
--- a/src/sdk/layout.ts
+++ b/src/sdk/layout.ts
@@ -1,50 +1,22 @@
// Ask the Evo SDK for a document type's GroveDB layout. Drive computes it
// (`documentTypeLayout`, platform #5153) from the contract alone, so no
-// network is needed; the SDK chunk loads on first use.
+// network is needed.
import type { DocumentTypeLayout } from '../model/layout';
+import { loadLocalSdk, localContract } from './local';
+
+export { contractJsonForSdk } from './local';
/** The SDK in use predates `documentTypeLayout`. */
export class LayoutUnavailableError extends Error {}
-interface LayoutSdk {
- ensureInitialized: () => Promise;
- DataContract: { fromJSON: (json: unknown, fullValidation: boolean, platformVersion: unknown) => unknown };
- PlatformVersion: { latest: () => unknown };
- documentTypeLayout?: (contract: unknown, documentTypeName: string, platformVersion: unknown) => unknown;
-}
-
-const PLACEHOLDER_ID = '11111111111111111111111111111111';
-
-/**
- * The contract JSON the SDK accepts: a pasted bare map of document schemas is
- * wrapped, and a file without an id, owner or version gets placeholders (the
- * layout does not depend on them).
- */
-export function contractJsonForSdk(input: unknown): Record {
- const obj = (input && typeof input === 'object' ? input : {}) as Record;
- const schemas = obj.documentSchemas ?? obj.schemas ?? obj.documents;
- const base: Record = schemas && typeof schemas === 'object' ? { ...obj, documentSchemas: schemas } : { documentSchemas: obj };
- delete base.schemas;
- delete base.documents;
- return {
- $formatVersion: '1',
- version: 1,
- ...base,
- id: typeof base.id === 'string' ? base.id : PLACEHOLDER_ID,
- ownerId: typeof base.ownerId === 'string' ? base.ownerId : PLACEHOLDER_ID,
- };
-}
-
export async function loadDocumentTypeLayout(contractJson: unknown, documentType: string): Promise {
- const sdk = (await import('@dashevo/evo-sdk')) as unknown as LayoutSdk;
- await sdk.ensureInitialized();
+ const sdk = await loadLocalSdk();
if (typeof sdk.documentTypeLayout !== 'function') {
throw new LayoutUnavailableError(
'This build of the visualizer uses an @dashevo/evo-sdk without documentTypeLayout (added by dashpay/platform#5153). It appears with the next SDK release.',
);
}
- const platformVersion = sdk.PlatformVersion.latest();
- const contract = sdk.DataContract.fromJSON(contractJsonForSdk(contractJson), false, platformVersion);
+ const { contract, platformVersion } = localContract(sdk, contractJson);
return sdk.documentTypeLayout(contract, documentType, platformVersion) as DocumentTypeLayout;
}
diff --git a/src/sdk/local.ts b/src/sdk/local.ts
new file mode 100644
index 0000000..533eb60
--- /dev/null
+++ b/src/sdk/local.ts
@@ -0,0 +1,52 @@
+// The Evo SDK functions that run locally, from a contract's JSON alone:
+// Drive's `documentTypeLayout` and `documentCreateCost`. The SDK chunk loads
+// on first use; a function the installed SDK predates is reported as missing.
+
+export interface LocalSdk {
+ ensureInitialized: () => Promise;
+ DataContract: { fromJSON: (json: unknown, fullValidation: boolean, platformVersion: unknown) => unknown };
+ PlatformVersion: { latest: () => unknown };
+ documentTypeLayout?: (contract: unknown, documentTypeName: string, platformVersion: unknown) => unknown;
+ documentCreateCost?: (
+ contract: unknown,
+ documentTypeName: string,
+ options: unknown,
+ platformVersion: unknown,
+ ) => unknown;
+}
+
+const PLACEHOLDER_ID = '11111111111111111111111111111111';
+
+/**
+ * The contract JSON the SDK accepts: a pasted bare map of document schemas is
+ * wrapped, and a file without an id, owner or version gets placeholders (what
+ * the local functions compute does not depend on them).
+ */
+export function contractJsonForSdk(input: unknown): Record {
+ const obj = (input && typeof input === 'object' ? input : {}) as Record;
+ const schemas = obj.documentSchemas ?? obj.schemas ?? obj.documents;
+ const base: Record =
+ schemas && typeof schemas === 'object' ? { ...obj, documentSchemas: schemas } : { documentSchemas: obj };
+ delete base.schemas;
+ delete base.documents;
+ return {
+ $formatVersion: '1',
+ version: 1,
+ ...base,
+ id: typeof base.id === 'string' ? base.id : PLACEHOLDER_ID,
+ ownerId: typeof base.ownerId === 'string' ? base.ownerId : PLACEHOLDER_ID,
+ };
+}
+
+export async function loadLocalSdk(): Promise {
+ const sdk = (await import('@dashevo/evo-sdk')) as unknown as LocalSdk;
+ await sdk.ensureInitialized();
+ return sdk;
+}
+
+/** The contract in the SDK's form, with the latest platform version. */
+export function localContract(sdk: LocalSdk, contractJson: unknown): { contract: unknown; platformVersion: unknown } {
+ const platformVersion = sdk.PlatformVersion.latest();
+ const contract = sdk.DataContract.fromJSON(contractJsonForSdk(contractJson), false, platformVersion);
+ return { contract, platformVersion };
+}
diff --git a/src/styles.css b/src/styles.css
index 6dcb83a..715bc23 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -471,3 +471,34 @@ button { font: inherit; cursor: pointer; }
.cv-layout-link { margin-left: auto; text-decoration: none; font-size: 12px; }
.cv-layout-notes { margin: 0 0 2px 26px; padding: 0; list-style: none; font-size: 11.5px; color: var(--text-muted); }
.cv-layout-notes li::before { content: '· '; }
+
+/* What a document costs */
+.cv-entity-actions { display: flex; gap: 8px; flex-wrap: wrap; }
+.cv-cost-line { font-size: 13px; margin: 8px 0 2px; }
+.cv-link-button { background: none; border: none; color: var(--accent); padding: 0; cursor: pointer; font: inherit; text-decoration: underline; }
+.cv-cost h3 { font-size: 13.5px; margin: 16px 0 6px; }
+.cv-cost .cv-modal-head h2 { padding-right: 32px; }
+.cv-cost-price { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; font-size: 12.5px; margin: 6px 0 10px; }
+.cv-cost-price input { width: 90px; margin-left: 4px; background: var(--bg); border: 1px solid var(--border-strong); border-radius: 4px; color: var(--text); padding: 2px 6px; }
+.cv-cost-totals { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 10px; }
+.cv-cost-totals > div { background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; }
+.cv-cost-totals strong { font-size: 16px; display: block; margin: 4px 0; }
+.cv-cost-detail { font-size: 11.5px; }
+.cv-cost-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
+.cv-cost-table th { text-align: left; font-weight: 500; color: var(--text-muted); padding: 2px 6px; }
+.cv-cost-table td { vertical-align: top; padding: 5px 6px; border-top: 1px solid var(--border); }
+.cv-cost-table td:not(:first-child), .cv-cost-table th:not(:first-child) { text-align: right; white-space: nowrap; }
+.cv-cost-tag { font-size: 10.5px; color: var(--text-muted); border: 1px solid var(--border-strong); border-radius: 3px; padding: 0 4px; margin-left: 4px; }
+.cv-cost-assumptions { display: flex; flex-wrap: wrap; gap: 14px; font-size: 12px; margin-top: 8px; }
+.cv-cost-assumptions input, .cv-cost-assumptions select, .cv-cost-fields input[type='number'] { margin-left: 6px; background: var(--bg); border: 1px solid var(--border-strong); border-radius: 4px; color: var(--text); padding: 2px 6px; width: 90px; }
+.cv-cost-assumptions select { width: auto; }
+.cv-cost-charges { margin: 0; padding-left: 18px; font-size: 12.5px; }
+.cv-cost-fields { list-style: none; margin: 0; padding: 0; font-size: 12.5px; }
+.cv-cost-fields li { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 5px 0; border-top: 1px solid var(--border); }
+.cv-cost-length { display: flex; align-items: center; gap: 6px; }
+.cv-cost-length input[type='range'] { width: 140px; }
+.cv-cost-stale { opacity: 0.6; transition: opacity 0.15s; }
+@media (max-width: 640px) {
+ .cv-cost-table td:not(:first-child), .cv-cost-table th:not(:first-child) { white-space: normal; }
+ .cv-cost-length input[type='range'] { width: 100px; }
+}