Resolve a DIG URN to its data through the protocol, node-first. A Rust crate and
a @dignetwork/dig-urn-resolver wasm/npm package. First consumer: Sage wallet NFT
images.
This is THE canonical, project-wide client-side URN→data resolver (the #668
convergence: hub, the extension, dig-sdk, dig-dns and other consumers converge on
it). It sits strictly upstream of dig-node: it is a client that talks to a
dig-node over the wire (the node /s/ + /health surface, else the rpc.dig.net
gateway) — dig-node does all the heavy lifting (store sync, serve, decrypt, chain
anchoring) and never depends on this crate. Consume it from Rust (dig-urn-resolver)
or JS/wasm (@dignetwork/dig-urn-resolver); do not reimplement URN resolution
elsewhere.
Given a URN of the form
urn:dig:chia:<store_id>[:<root>]/<resource_key>[?salt=<hex>]
it returns the resource's bytes + content type, following the canonical §5.3 ladder
— explicit override > dig.local > localhost:9778 > rpc.dig.net — using the
first tier that responds:
- node tier —
GET /s/<storeId>[:<root>]/<path>: a local dig-node decrypts + verifies server-side under a loopback trust boundary and returns plaintext. - rpc tier —
dig.getContent: a blind fetch of opaque ciphertext + inclusion proofs from the untrusted public gateway, verified against the chain-anchored root and decrypted client-side (fail-closed).
All read-crypto (URN canonicalization + retrieval-key derivation, merkle inclusion
verify, AES-256-GCM-SIV open) is reused verbatim from digstore-core — the same
functions the browser read-crypto and the on-chain crates share — so this crate can
never skew from the canonical crypto.
- Node trust is loopback-only. The crypto-free node
/s/path is trusted ONLY for an asserted-loopback host (127.0.0.0/8/::1/localhost, ordig.localiff it resolves to loopback) — it is the user's own machine. ANY other host, including an explicit override pointed at a remote host, uses the client-VERIFIED rpc path. On the node path the response MUST carryX-Dig-Verified: true, else the bytes are rejected fail-closed. (Defeats a remote/override host and a LAN mDNS spoof of.localserving unverified bytes as trusted.) - The rpc trust root comes from the URN, not the gateway. Over the untrusted
gateway only a root-pinned URN is accepted; a rootless URN is rejected
(
RootRequired) because its root would otherwise come from the same gateway serving the content. (Rootless is fine over a loopback node — the local node is the trust anchor. Root-pinned + private/salted stores are unaffected.)
| Outcome | Meaning | Bytes |
|---|---|---|
Success |
verified, decrypted content | the real content |
IntegrityFailure |
bytes fetched but failed merkle/decrypt verify (tampered / decoy / wrong root) — a hard, fail-closed security failure | never the unverified bytes; renders a branded "Integrity Verification Failed" page |
Unreachable |
every tier down; nothing fetched — friendly + retryable | a branded "DIG Network unreachable" + Connect-to-Node page |
A malformed URN, a not-found resource, and a reachable rpc protocol error are hard
errors (ResolveError).
use dig_urn_resolver::{native, ResolveOutcome};
#[tokio::main]
async fn main() {
match native::resolve("urn:dig:chia:<store>:<root>/img/logo.png").await.unwrap() {
ResolveOutcome::Success(data) => { /* data.bytes, data.content_type */ }
ResolveOutcome::IntegrityFailure => { /* security failure — do not show */ }
ResolveOutcome::Unreachable => { /* network down — offer "connect a node" */ }
}
}Inject a custom transport (or an explicit endpoint) via Resolver::with_options.
The front-door API is the branded, well-typed DigNetwork client:
import init, { DigNetwork } from "@dignetwork/dig-urn-resolver";
await init();
const dig = new DigNetwork(); // all defaults
// or pass an options object — set only what you need, no undefined placeholders:
// const dig = new DigNetwork({ endpoint, connectUrl, cachePath });
// The NFT-image case — a URL for <img src>, working with no dig-node running:
img.src = await dig.resolveImageUrl(nftDataUri);
// Or the typed form (real outcome + MIME, not an image):
const { outcome, bytes, contentType } = await dig.resolve(nftDataUri);
// outcome ∈ "success" | "integrity_failure" | "unreachable"resolveImageUrl ALWAYS returns a usable <img> URL and never throws for a normal
failure: the real verified image on success, else a branded DIG error image
(embedded PNG data: URI) matching the failure — integrity failure, network
unreachable, not found, invalid URN, or a generic error. On an integrity failure it
is the STATIC branded placeholder — never the unverified bytes as the image. (The
HTML error pages via resolve().render() stay for the webview/navigation case.)
Low-level free functions (resolve, resolveObjectUrl) remain available.
URNs are content-addressed → immutable → cacheable, so results are cached as an additive layer in front of resolve that never weakens fail-closed:
- Only VERIFIED
Successbytes are cached — never an integrity/unreachable/ not-found failure (a cached failure would block recovery). - Keyed by the content-addressed identity
storeId:root:resourceKey:saltwith the CONCRETE resolved root — a root-pinned URN is immutable; a rootless URN is cached under the root the resolve produced (X-Dig-Root), never a stale URN→bytes mapping. - Memory (default, bounded LRU): process-trusted (only holds what this process verified this run) — a hit skips re-verification.
- Disk (optional
cachePath; native Rust AND Node.js): UNTRUSTED — it stores the verifiable artifacts (ciphertext + proof + chunk lengths), and a disk hit is re-verified against the URN's root before use, so a tampered on-disk file FAILS verification →IntegrityFailure(never served). Filenames areSHA-256(identity)(no path-traversal). Ignored clientside in the browser (no filesystem); the memory cache still applies there.
Persistent disk caching is a native (Rust) capability — pass a cache_path in
ResolveOptions, and verified results survive across process runs (a long-lived
on-disk cache under the given directory). Because URNs are immutable + content-
addressed, a later run re-serves a cached asset without a network round-trip, and
every disk hit is still re-verified against the URN's root before use:
use dig_urn_resolver::{native, ResolveOptions, ResolveOutcome};
#[tokio::main]
async fn main() {
let options = ResolveOptions {
cache_path: Some("/var/cache/dig-urn".into()), // long-lived disk cache
..Default::default()
};
match native::resolve_with("urn:dig:chia:<store>:<root>/img/logo.png", options)
.await
.unwrap()
{
ResolveOutcome::Success(data) => { /* served from disk on a later run */ }
ResolveOutcome::IntegrityFailure => { /* re-verify failed — never served */ }
ResolveOutcome::Unreachable => { /* network down — offer "connect a node" */ }
}
}In the @dignetwork/dig-urn-resolver wasm package the DigNetwork constructor takes
an options object with the same cachePath field, and it is fully functional under
Node.js — the Node build injects Node's fs, so verified results persist to the
given directory and are re-verified on read, exactly like the native crate:
// Node.js service (CommonJS) — a persistent, cross-run disk cache
const { DigNetwork } = require("@dignetwork/dig-urn-resolver");
// Set ONLY the field you need — no undefined placeholders.
const dig = new DigNetwork({ cachePath: "/var/cache/dig-urn" });
// First run fetches + verifies; a later run (same cachePath) re-serves from disk.
const img = await dig.resolveImageUrl("urn:dig:chia:<store>:<root>/img/logo.png");The options object accepts { endpoint?, connectUrl?, cachePath? } (all optional);
the generated TypeScript exports a DigNetworkOptions interface with those named
fields.
Clientside (browser) cachePath is a harmless no-op — a browser has no
filesystem, so the wasm build never wires fs there and the bounded in-memory cache
applies instead (a same-process hit still skips re-verification). The persistent disk
cache is available to native Rust consumers AND to the Node.js build; only the browser
falls back to memory-only.
The @dignetwork/dig-urn-resolver package is dual-target — one package that
imports cleanly in both:
// Browser bundler (Vite / webpack) — ESM
import init, { DigNetwork } from "@dignetwork/dig-urn-resolver";
await init();
// Node.js service — CommonJS
const { DigNetwork } = require("@dignetwork/dig-urn-resolver");The branded DigNetwork API, the three fail-closed outcomes, and the MIME are
IDENTICAL in both; only env-specific plumbing differs, and it degrades gracefully —
never a hard-fail in either environment:
- Ladder — the
dig.local/localhost/healthprobe works in Node; in a browser it's often CORS-blocked. The probe is caught/timed-out, so the ladder simply falls through to the verifiedrpc.dig.nettier (never to unverified bytes). - Cache — the disk
cachePathworks natively AND under Node.js (the Node build injectsfs); in a browser there is no filesystem, socachePathis a harmless no-op and the bounded in-memory cache applies. - Image URL —
resolveImageUrlreturns ablob:URL whereURL.createObjectURLexists (browser) and adata:URL otherwise (Node.js) — always usable, never throws.
contentType is present on every resolve result: the store's stored
Content-Type on the node path, else inferred from the URN path extension / magic
bytes. A bare-store or trailing-slash URN resolves to the §8.5 default view
index.html.
The /health + /s/ probes hit dig.local/localhost from the app origin and may
be CORS-blocked in a desktop webview; the rpc.dig.net fallback (Access-Control- Allow-Origin: *) always works, so a resolve succeeds node-absent. Add the endpoints
to the app's connect-src CSP. For the browser node path to read the verification
attestation, the node must send Access-Control-Expose-Headers: X-Dig-Verified
(a missing header fails closed) — see #669.
cargo run --example sage_nft_image
Resolves a root-pinned NFT-image URN to displayable bytes with no dig-node running
(rpc fallback), the exact byte path resolveObjectUrl wraps for <img src>.
GPL-2.0-only (inherited from the reused digstore-core read-crypto).