TypeScript primitives for building on Vana — smart-contract bindings, ECIES encryption, storage providers, and a shared isomorphic platform layer.
Heads up — minimal scaffold. As of
3.xthe SDK has been pared down to the primitives the new Vana protocol architecture builds on. The previous high-level API (Vana(...)factory,vana.permissions,vana.data, subgraph queries, personal-server client, DLP rewards) is not part of this release. If you need that surface, pin to@opendatalabs/vana-sdk@^2.3.0or check out thelegacy-pre-unificationtag.
getContractController, getContractInfo,
getAbi, getContractAddress, plus the CONTRACTS and VanaContract
registries auto-generated from on-chain discovery.vanaMainnet, mokshaTestnet (alias moksha),
getChainConfig, getAllChains, plus the lower-level viem chains map.VanaStorage (default, talks to storage.vana.org),
R2Storage, StorageManager, IpfsStorage, PinataStorage,
GoogleDriveStorage, DropboxStorage, CallbackStorage.@opendatalabs/vana-sdk/server,
@opendatalabs/vana-sdk/react, and @opendatalabs/vana-sdk/session-relay
for Vana-operated app handoff flows. These are integration helpers, not
protocol-core modules.NodePlatformAdapter and BrowserPlatformAdapter
with a shared VanaPlatformAdapter interface, plus detection helpers
(detectPlatform, isPlatformSupported, createPlatformAdapter,
createPlatformAdapterSafe).dataSchema.schema.json and
grantFile.schema.json, shipped under dist/schemas/.npm install @opendatalabs/vana-sdk viem
The SDK ships separate browser and Node bundles. Pick the entry point that matches your runtime:
// Browser / web app
import { BrowserPlatformAdapter } from "@opendatalabs/vana-sdk/browser";
// Node.js / server
import { NodePlatformAdapter } from "@opendatalabs/vana-sdk/node";
The bare @opendatalabs/vana-sdk import intentionally throws — it forces a
deliberate platform choice instead of accidentally pulling Node-only code
into a browser bundle (or vice versa).
import { getContractController } from "@opendatalabs/vana-sdk/node";
import { createPublicClient, http } from "viem";
import { mokshaTestnet } from "@opendatalabs/vana-sdk/node";
const client = createPublicClient({
chain: mokshaTestnet,
transport: http(),
});
const dataRegistry = getContractController("DataRegistry" as const, client);
const fileCount = await dataRegistry.read.filesCount();
import { NodeECIESProvider } from "@opendatalabs/vana-sdk/node";
const ecies = new NodeECIESProvider();
const encrypted = await ecies.encrypt(recipientPublicKey, payload);
const decrypted = await ecies.decrypt(recipientPrivateKey, encrypted);
The browser entry exposes the same surface as BrowserECIESProvider.
import { StorageManager, PinataStorage } from "@opendatalabs/vana-sdk/node";
const storage = new StorageManager();
storage.register(
"pinata",
new PinataStorage({ jwt: process.env.PINATA_JWT! }),
true, // mark as default
);
const result = await storage.upload(myBlob, "report.json");
console.log(result.url);
Set network when writing to Vana Storage for a specific Vana network. The SDK
resolves the network to its chain ID and uploads through chain-scoped routes
(/v1/chains/{chainId}/blobs/...) so data for different chains never collides.
import { createVanaStorageProvider } from "@opendatalabs/vana-sdk/node";
const storage = createVanaStorageProvider({
endpoint: "https://storage.vana.org",
network: "moksha",
signer: {
address: account.address,
signMessage: (msg) => account.signMessage({ message: msg }),
},
});
const result = await storage.upload(
myBlob,
"instagram.profile/2026-05-08T20:00:00.000Z",
);
Network-configured providers reject legacy blob URLs and URLs scoped to a
different chain. If you need a custom or future protocol network that this SDK
does not know yet, pass chainId explicitly.
Request user-approved data, read it from the user's Personal Server, and pay
for the read — without the browser ever seeing your app private key or choosing
scopes. Your backend owns the Data Portability controller
(@opendatalabs/vana-sdk/server); your frontend drives a two-tab approval flow with a React hook
(@opendatalabs/vana-sdk/react).
How it fits together. Access requests are created through the Vana Account access-request API; the Personal Server read uses Web3Signed auth; and payment settles on a
402through the DPv2 escrow surface (protocol/escrow), where the controller signs aGenericPaymentwith your app key. You can inject your ownaccessRequestClientto target a custom deployment, andescrowconfig to wire the escrow gateway.
Use network: "moksha" to keep production app/API URLs while running escrow and
chain-aware defaults against Moksha. env: "dev" remains for Vana's internal dev
deployment and switches deployment URLs.
// lib/vana.ts
import { createDirectDataController } from "@opendatalabs/vana-sdk/server";
import { createEscrowGatewayClient } from "@opendatalabs/vana-sdk/node";
export const vana = createDirectDataController({
env: process.env.VANA_ENV === "dev" ? "dev" : "production",
network: process.env.VANA_NETWORK === "moksha" ? "moksha" : "mainnet",
appPrivateKey: process.env.VANA_APP_PRIVATE_KEY!,
app: {
id: "spotify-taste",
name: "Spotify Taste",
homepageUrl: process.env.VANA_APP_URL!,
},
source: "spotify",
scopes: ["spotify.savedTracks"],
// Settle paid reads through the DPv2 escrow gateway. The controller signs the
// GenericPayment with your app key; you supply the gateway client + contract.
escrow: {
client: createEscrowGatewayClient(process.env.VANA_DP_RPC_URL!),
escrowContract: process.env.VANA_ESCROW_CONTRACT! as `0x${string}`,
},
});
// The app's on-chain address — fund and inspect this in the Builder activity
// report. (`vana.getAppIdentity()` also returns the configured id/name/homepage.)
console.log(vana.getAppAddress()); // 0x...
Wire it to three routes — your backend chooses the source and scopes, owns the
private key, and handles 402 Payment Required:
// POST /api/vana/request
const request = await vana.createAccessRequest({
returnUrl: `${process.env.VANA_APP_URL}/connect/return`,
});
// -> {
// requestId: "dcr_...",
// approvalUrl: "https://app.vana.org/...",
// appAddress: "0x...",
// network: "mainnet",
// expiresAt: "...",
// mobileContinuationUrl?: "https://open.vana.org/continue#<ticket>",
// }
// GET /api/vana/status?requestId=...
const status = await vana.getAccessRequestStatus(requestId);
// -> { status: "approved", personalServerUrl, grantId, scope }
// GET /api/vana/data?requestId=...
const result = await vana.readApprovedData({ requestId });
// -> {
// scope: "spotify.savedTracks",
// data: ...,
// payment?: { // present only when this read settled a payment
// amount, asset, paymentNonce, paidAt,
// breakdown: { registrationFee, dataAccessFee, registrationPaid },
// },
// }
readApprovedData hides the payment flow for normal builders. If the Personal
Server returns 402 Payment Required, the controller settles the grant through
the escrow gateway and retries, attaching a payment receipt so you can inspect
the amount, asset, and fee breakdown. If escrow is not configured (or the read
still requires payment afterward), it throws PaymentRequiredError carrying the
amount and asset owed.
"use client";
import { useDirectVanaConnect } from "@opendatalabs/vana-sdk/react";
export function ConnectSpotifyButton() {
const connect = useDirectVanaConnect({
createRequest: () =>
fetch("/api/vana/request", { method: "POST" }).then((r) => r.json()),
getStatus: (requestId) =>
fetch(`/api/vana/status?requestId=${encodeURIComponent(requestId)}`).then(
(r) => r.json(),
),
readResult: (requestId) =>
fetch(`/api/vana/data?requestId=${encodeURIComponent(requestId)}`).then(
(r) => r.json(),
),
});
return (
<button
disabled={connect.state.type !== "idle"}
onClick={connect.start}
type="button"
>
{connect.state.type === "idle" ? "Connect Spotify" : "Connecting..."}
</button>
);
}
The hook calls createRequest, opens the Vana destination, polls getStatus
until the request is approved, then calls readResult. Destination choice stays
inside the SDK, and it owns only the small mobile-versus-desktop split — it never
infers whether Vana is installed. Desktop browsers and light requests open the
HTTPS approvalUrl in a popup (state.type === "awaiting_approval"). Builders
should not add user-agent branches, app-install checks, deep-link construction,
or store-link logic.
If the popup is blocked, state.popupBlocked is true; render the HTTPS
state.request.approvalUrl as the universal manual "Open approval" link. Polling
continues either way, so a manual open still drives the flow to completion.
A deep Direct request on a mobile browser instead enters
state.type === "ready_to_open" and exposes a plain HTTPS
state.mobileContinuationUrl (https://open[-dev].vana.org/continue#<ticket>).
Because DCR creation is asynchronous, the SDK does not launch it
automatically — the original tap can no longer be trusted to retain iOS user
activation. Render it as an ordinary primary link the user taps themselves:
<a href={state.mobileContinuationUrl} target="_blank" rel="noreferrer">
Open Vana
</a>
Verified links (iOS Universal Links / Android App Links) deliver this URL to Vana Mobile; if Vana is absent the same URL loads its web install/recovery fallback. Polling continues in the originating tab, and the URL's short-lived ticket may rotate to a fresh value between polls. This is capability routing, not an assertion that the native app is installed.
The SDK owns no persistence. If the originating mobile tab is reloaded, evicted, or replaced, the flow does not recover: the user restarts and creates a new DCR, and the abandoned DCR expires. This restart-on-tab-loss behavior is an accepted first-release tradeoff — do not build caller-side resume storage against it.
Server-side create calls accept an optional idempotencyKey. The default HTTP
client generates a fresh key for every create, because one shared controller
serves many users and identical-looking creates are still independent requests.
Retrying a create whose response was lost is therefore the caller's decision:
pass the same explicit idempotencyKey on the retry to avoid a duplicate DCR.
react is an optional peer dependency. The underlying
createDirectConnectFlow store is also exported for non-React frontends.
When testing with realistic exports, use the public fixture catalog in
vana-com/data-connectors. Keep
the payload in a file or raw URL and point your app or agent at that location.
Do not paste large JSON into the terminal.
The controller can run against local test data by injecting an
accessRequestClient that returns an approved request and a
personalServerFetch that loads the sample payload, while the rest of your app
still calls readApprovedData.
See examples/vana-app for a runnable Next.js Vana
app. It includes the route handlers, return page, and React connect button from
this flow, defaults to sample-data mode using vana-com/data-connectors, and
can be switched to live protocol mode with environment variables.
A builder that holds a write-grant (a grant whose scope entries carry the
write: prefix, e.g. write:coach.summary; see formatScopeEntry) can write
records into the user's Personal Server. The SDK owns the handshake and the
signatures; the same API works from a backend (viem privateKeyToAccount) and
from a browser (viem WalletClient).
import { privateKeyToAccount } from "viem/accounts";
import {
openWriteSession,
writeData,
getLineage,
deriveDataPointId,
} from "@opendatalabs/vana-sdk";
const signer = privateKeyToAccount(process.env.BUILDER_KEY as `0x${string}`);
// 1. Open a session: Web3Signed handshake carrying the write-grant id.
const session = await openWriteSession({
personalServerUrl: "https://ps.example.com",
signer,
grantId: writeGrantId,
});
// 2. Write a record (compact JSON, signed proof in X-Vana-Write-Signature).
await writeData({ session, scope: "coach.notes", data: { note: "hello" } });
// 3. Write a derivative: name the data points it was computed from. Given as
// { ownerAddress, scope } the SDK derives the ids and checks the naming
// rule before signing; bare ids (deriveDataPointId) work too.
await writeData({
session,
scope: "coach.summary",
data: { summary: "..." },
lineage: [{ ownerAddress, scope: "chatgpt.conversations" }],
});
// 4. Walk the lineage: Personal Server by scope, or gateway by data point id
// (optionally `version: N` for a specific version).
const graph = await getLineage({
personalServerUrl: "https://ps.example.com",
scope: "coach.summary",
grantId: readGrantId,
signer,
});
const viaGateway = await getLineage({
gatewayUrl: "https://dp-rpc.vana.org",
dataPointId: deriveDataPointId(ownerAddress, "coach.summary"),
grantId: readGrantId,
signer,
});
writePersonalServerData({ personalServerUrl, signer, grantId, scope, data })
does steps 1 and 2 in one call and returns the session for reuse.
What the SDK does for you:
POST /v1/write/session with a Web3Signed proof (the grant id is a
signed claim) and keeps the short-lived bearer in session.POST /v1/data/:scope with the bearer and X-Vana-Write-Signature, a
second Web3Signed proof over the stored representation: the compact JSON
body for JSON writes, the $binary record (binaryWriteSignedBytes) for
binary: { bytes, contentType, filename } writes. The grant id is a signed
claim on that proof too.retry) sign a
fresh proof per attempt; an HTTP error is never retried.lineage becomes the record's top-level lineage field (JSON writes) or
the lineage field of X-Vana-Metadata (binary writes), so it is inside
the signed bytes either way; ids are lowercased, the server validates them
and mirrors them to $lineage. lineage: [] is an explicit root statement
and is sent as such; absent or null makes no statement. Sending
$writtenBy, $lineage, or your own lineage field is refused before any
request./v1/data/<id lowercase>/lineage[/:version] on the gateway,
/v1/data/:scope/lineage[/:version] on the Personal Server; the version is
a path segment, never a query), with the grant as the signed grantId
claim, so a captured signature cannot be replayed for another view. The
gateway answers a uniform 404 for an unknown id and for a signer it will
not serve.Rules on derivatives, checked by the server and (where the SDK has the
information) by the client before anything is signed: sources are data points
of the same owner (a deleted source is still a valid one, and comes back with
its deletedAt; one that no longer resolves comes back with version: "0"),
at most 256, distinct, never the record's own id, and the derived scope must
not share its first dot-segment with any source scope (a grant on chatgpt.*
must not read chatgpt.summary), so put derivatives in your app's own
namespace (assertDerivedScopeNaming is exported). A grant on a derived scope
confers nothing on its sources, and the other way round: the pipeline needs a
read grant on the sources and a write grant on the derived scope.
Errors are typed: WriteSessionError (handshake refused), WriteUnauthorizedError
(401), WriteForbiddenError (403), WriteConflictError (409),
WriteLineageError (any LINEAGE_* rejection: 422 LINEAGE_SOURCE_UNKNOWN
with details.unknown, 400 LINEAGE_INVALID /
LINEAGE_SCOPE_UNDER_SOURCE_PREFIX, 502 LINEAGE_SOURCE_LOOKUP_FAILED),
WriteRejectedError (other), WriteSessionExpiredError, WriteTransportError,
WriteRequestError, and LineageReadError for lineage reads. Each carries the
server's status, errorCode and details. Lineage entries the caller holds
no grant for come back as exactly { redacted: true } (narrow with
isRedactedLineageNode): no id, scope or version, because the id is
keccak256(owner, scope) and a grantee who knows the owner could recover the
scope from it; order and count are preserved, so a redacted node is identified
by its position. The SDK refuses a view whose redacted node carries anything
else. The gateway's proof over the served view is passed through.
| Network | Chain ID | RPC URL |
|---|---|---|
| Vana Mainnet | 1480 | https://rpc.vana.org |
| Moksha Testnet | 14800 | https://rpc.moksha.vana.org |
The ECIES implementation under src/crypto/ecies/ was audited by HashCloak
in October 2025; the report is in audits/.