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 && ( + + )} {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 ( +
  • +
    + + {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) + + )}{' '} + +

    + ); +} 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…'} + + +
    + ); +} + +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.

    } +
      + {adjustable.map((field) => ( +
    • + {field.path} + {field.kind} + {field.optional && ( + + )} + {field.length !== undefined && field.present && ( + + )} +
    • + ))} +
    + {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

    + + + + + + + + + + {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} /> + +
    + values newvalues stored
    + +

    Processing

    + + + {cost.processing.map((part) => ( + + + + + + ))} + +
    + {part.text} {part.exact ? 'exact' : 'estimated'} + + + + +
    +
    + + + +
    + + {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 && ( - + {costJson != null && onShowCost && ( + onShowCost(entity.name)} /> )} +
    + {onShowLayout && ( + + )} + {costJson != null && onShowCost && ( + + )} +
    {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; } +}