Skip to main content
This guide walks through the two main CDR flows:
  • uploadCDR / accessCDR for small secrets stored directly on-chain
  • uploadFile / downloadFile for larger encrypted files stored off-chain

Prerequisites

  • CDR SDK setup complete with WASM initialized and client created

What Runs On-Chain vs Off-Chain

OperationSends transaction?What happens
observer.getGlobalPubKey()NoPure RPC read of DKG state
uploadCDR()Yes, 2 txsLocal TDH2 encryption plus allocate() and write()
uploadFile()Yes, 2 txs + storage uploadLocal AES encryption, storage upload, then allocate() and write()
accessCDR()Yes, 1 txread() on-chain, then off-chain partial collection and local combination
downloadFile()Yes, 1 tx + storage downloadaccessCDR() plus encrypted file download and local AES decryption

Encrypt a Secret

The diagram below shows the on-chain secret flow: allocate a vault, encrypt the secret locally with TDH2, and write the ciphertext to the vault.
CDR encryption flow showing vault allocation, local encryption, and writing the ciphertext to the vault
The high-level uploadCDR method handles vault allocation, encryption, and writing in a single call.
import { initWasm } from "@piplabs/cdr-sdk";
import { encodeAbiParameters } from "viem";

await initWasm();

// Assumes `client` and `walletClient` are already created (see Setup)
const { uploader, observer } = client;
const walletAddress = walletClient.account!.address;
const OWNER_CONDITION = "0x4C9bFC96d7092b590D497A191826C3dA2277c34B";
const ownerConditionData = encodeAbiParameters(
  [{ type: "address" }],
  [walletAddress],
);

// Pure read: fetch the DKG global public key
const globalPubKey = await observer.getGlobalPubKey();

// Encode your secret as bytes
const secret = "my confidential data";
const dataKey = new TextEncoder().encode(secret);

// Sends 2 transactions: allocate + write
const { uuid, ciphertext, txHashes } = await uploader.uploadCDR({
  dataKey,
  globalPubKey,
  updatable: false,
  writeConditionAddr: OWNER_CONDITION,
  readConditionAddr: OWNER_CONDITION,
  writeConditionData: ownerConditionData,
  readConditionData: ownerConditionData,
  accessAuxData: "0x",
});

console.log(`Vault created with UUID: ${uuid}`);
console.log(`Allocate tx: ${txHashes.allocate}`);
console.log(`Write tx: ${txHashes.write}`);
uploadCDR automatically queries the allocate and write fees. You can skip those queries by passing allocateFeeOverride and writeFeeOverride.
The value of the transaction must be exactly the same as the fee.
dataKey is the historical parameter name. In uploadCDR() it can be any secret bytes, not just a cryptographic key.
Vault encrypted data is limited to 1024 bytes on Aeneid (maxEncryptedDataSize). TDH2 adds overhead, so the maximum plaintext is smaller. For larger content, use uploadFile() so only a small {cid, key} payload is written to the vault.

Decrypt a Secret

Decryption requires submitting a read request on-chain, collecting partial decryptions from validators, and combining them client-side.
CDR decryption flow showing ephemeral key generation, access control check, partial decryptions from validators, and client-side combination
const { consumer } = client;

// Sends 1 transaction, then collects partials and combines them locally
const { dataKey, txHash } = await consumer.accessCDR({
  uuid,
  accessAuxData: "0x",
  timeoutMs: 120_000, // wait up to 2 minutes for validators
});

const secret = new TextDecoder().decode(dataKey);
console.log(`Read tx: ${txHash}`);
console.log(`Decrypted secret: ${secret}`);
In v0.1.1, accessCDR() can auto-generate the ephemeral keypair and auto-query globalPubKey / threshold when you omit them.
The timeout of the request on the server side is 200 blocks, which is approximately 7 minutes. If you’re not able to collect enough partials within this timeout, try another read request.
import { secp256k1 } from "@noble/curves/secp256k1";
import { toHex } from "viem";

const { consumer, observer } = client;

const globalPubKey = await observer.getGlobalPubKey();
const threshold = await observer.getThreshold();

const recipientPrivKey = secp256k1.utils.randomPrivateKey();
const requesterPubKey = toHex(
secp256k1.getPublicKey(recipientPrivKey, false),
);

const { dataKey, txHash } = await consumer.accessCDR({
  uuid,
  accessAuxData: "0x",
  requesterPubKey,
  recipientPrivKey,
  globalPubKey,
  threshold,
  timeoutMs: 120_000,
});

console.log(`Read tx: ${txHash}`);
console.log(new TextDecoder().decode(dataKey));

Encrypt and Download a File

CDR encryption flow showing vault allocation, local encryption, and writing the encrypted key plus data URL to the vault
CDR decryption flow showing ephemeral key generation, access control check, partial decryptions from validators, and client-side combination
Use the file workflow when the encrypted payload should live off-chain and only the encrypted file key plus pointer should be stored in the vault. Upload happens once by the data owner. Download happens later by an authorized reader who recovers the vault payload and then decrypts the stored file.
import { HeliaProvider } from "@piplabs/cdr-sdk";
import { readFile, writeFile } from "node:fs/promises";
import { createHelia } from "helia";
import { unixfs } from "@helia/unixfs";
import { CID } from "multiformats/cid";
import { encodeAbiParameters } from "viem";

const walletAddress = walletClient.account!.address;
const OWNER_CONDITION = "0x4C9bFC96d7092b590D497A191826C3dA2277c34B";
const ownerConditionData = encodeAbiParameters(
  [{ type: "address" }],
  [walletAddress],
);

// Pure read
const globalPubKey = await client.observer.getGlobalPubKey();

const helia = await createHelia();
const storage = new HeliaProvider({
  helia,
  unixfs: unixfs(helia),
  CID: (s) => CID.parse(s),
});

const sourceFile = await readFile("./example.pdf");

// Off-chain upload + 2 on-chain transactions
const { uuid, cid } = await client.uploader.uploadFile({
  content: new Uint8Array(sourceFile),
  storageProvider: storage,
  globalPubKey,
  updatable: false,
  writeConditionAddr: OWNER_CONDITION,
  readConditionAddr: OWNER_CONDITION,
  writeConditionData: ownerConditionData,
  readConditionData: ownerConditionData,
  accessAuxData: "0x",
});

// 1 on-chain read transaction + off-chain download + local AES decryption
const { content, txHash } = await client.consumer.downloadFile({
  uuid,
  accessAuxData: "0x",
  storageProvider: storage,
  timeoutMs: 120_000,
});

console.log(`Stored at CID: ${cid}`);
console.log(`Read tx: ${txHash}`);
await writeFile("./example.decrypted.pdf", Buffer.from(content));
console.log("Decrypted file written to ./example.decrypted.pdf");
HeliaProvider is the only storage backend fully tested on Aeneid in the current release, and it requires Node.js 22+.
uploadFile() and downloadFile() work with raw file bytes. In a browser, start from a File object and convert it with new Uint8Array(await file.arrayBuffer()).

Storage Providers

The encrypted-file workflow supports four storage backends:
  • HeliaProvider for in-process IPFS. This is the best starting point for development and the only backend fully tested on Aeneid so far.
  • GatewayProvider for an external IPFS HTTP API plus a gateway URL.
  • StorachaProvider for Storacha / web3.storage.
  • SynapseProvider for Filecoin-backed storage via Synapse.
If you use HeliaProvider, pass the CID.parse function into the constructor as shown above to avoid class mismatches.

Step-by-Step (Low-Level)

If you need more control over the process, you can call each step individually.
These snippets continue from the variables in the examples above: walletClient, globalPubKey, threshold, requesterPubKey, recipientPrivKey, and dataKey.

Encrypt (Low-Level)

import { uuidToLabel } from "@piplabs/cdr-sdk";
import { encodeAbiParameters, toHex } from "viem";

const walletAddress = walletClient.account!.address;
const OWNER_CONDITION = "0x4C9bFC96d7092b590D497A191826C3dA2277c34B";
const ownerConditionData = encodeAbiParameters(
  [{ type: "address" }],
  [walletAddress],
);

// On-chain transaction: allocate a vault
const { txHash: allocateTx, uuid } = await uploader.allocate({
  updatable: false,
  writeConditionAddr: OWNER_CONDITION,
  readConditionAddr: OWNER_CONDITION,
  writeConditionData: ownerConditionData,
  readConditionData: ownerConditionData,
});

// Local: derive the label from the UUID
const label = uuidToLabel(uuid);

// Local: TDH2 encrypt the secret
const ciphertext = await uploader.encryptDataKey({
  dataKey,
  globalPubKey,
  label,
});

// On-chain transaction: write encrypted data to the vault
const { txHash: writeTx } = await uploader.write({
  uuid,
  accessAuxData: "0x",
  encryptedData: toHex(ciphertext.raw),
});

Decrypt (Low-Level)

import { uuidToLabel } from "@piplabs/cdr-sdk";

// On-chain transaction: submit read request
const { txHash: readTx } = await consumer.read({
  uuid,
  accessAuxData: "0x",
  requesterPubKey,
});

// Get the block number for polling
const receipt = await publicClient.getTransactionReceipt({ hash: readTx });

// Off-chain/network: collect partial decryptions from validators
const partials = await consumer.collectPartials({
  uuid,
  minPartials: threshold,
  fromBlock: receipt.blockNumber,
  timeoutMs: 120_000,
  requesterPubKey, // required if you use dkgSource: "cosmos-abci"
});

// Pure read: fetch the vault ciphertext
const label = uuidToLabel(uuid);
const vault = await observer.getVault(uuid);

// Local: decrypt each partial, then combine them
const recoveredDataKey = await consumer.decryptDataKey({
  ciphertext: {
    raw: Uint8Array.from(Buffer.from(vault.encryptedData.slice(2), "hex")),
    label,
  },
  partials,
  recipientPrivKey,
  globalPubKey,
  label,
  threshold,
});

Query DKG State

You can query DKG state and fees without a wallet or WASM initialization:
import { createPublicClient, http } from "viem";
import { CDRClient } from "@piplabs/cdr-sdk";

const publicClient = createPublicClient({
  transport: http("https://aeneid.storyrpc.io"),
});
const client = new CDRClient({ network: "testnet", publicClient });

const threshold = await client.observer.getOperationalThreshold();
console.log("Operational threshold:", threshold);

const [allocateFee, writeFee, readFee] = await Promise.all([
  client.observer.getAllocateFee(),
  client.observer.getWriteFee(),
  client.observer.getReadFee(),
]);
console.log(
  `Fees — allocate: ${allocateFee}, write: ${writeFee}, read: ${readFee}`,
);

// Query a specific vault
const vault = await client.observer.getVault(1);
console.log("Vault:", vault);

Understanding Fees

Each CDR operation has an on-chain fee:
OperationFee QueryDescription
Allocateobserver.getAllocateFee()One-time cost to create a vault
Writeobserver.getWriteFee()Cost per write to a vault
Readobserver.getReadFee()Cost per read/decryption request
Fees are paid in native tokens (wei) and are sent as msg.value with each transaction.