Build a Storage Anchor

By AnchorFactory ·

A storage anchor lets Keeta accounts store and retrieve data off-chain, authenticated by wallet signatures. Data is tied to account identity — no API keys, no OAuth, just cryptographic proof of ownership.

What You'll Build

A REST API that stores encrypted key-value data per account:

Endpoint

Method

Purpose

/store

POST

Store a value for a key (signed request)

/retrieve

POST

Retrieve a value by key (signed request)

/delete

POST

Delete a key (signed request)

/list

POST

List all keys for an account (signed request)

Every request is authenticated via VerifySignedData — only the account owner can read or write their data.

Prerequisites

  • Node.js 18+

  • A Keeta account seed (for the anchor's own identity)

  • Familiarity with Wallet Signatures

Project Setup

mkdir my-storage-anchor && cd my-storage-anchor
npm init -y
npm install express @keetanetwork/keetanet-client better-sqlite3

Set your project to ESM (required for the anchor SDK):

// package.json
{
  "type": "module",
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js"
  }
}
# .env
ANCHOR_SEED=your_hex_seed_here
PORT=3000

Step 1: Database Setup

Storage anchors need a persistence layer. We'll use SQLite for simplicity — swap it for PostgreSQL, MongoDB, or any other database in production.

// src/db.js
import Database from 'better-sqlite3';

const db = new Database('storage.db');

db.exec(`
  CREATE TABLE IF NOT EXISTS entries (
    account TEXT NOT NULL,
    key TEXT NOT NULL,
    value BLOB NOT NULL,
    created_at INTEGER NOT NULL DEFAULT (unixepoch()),
    updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
    PRIMARY KEY (account, key)
  )
`);

export function setEntry(account, key, value) {
  const stmt = db.prepare(`
    INSERT INTO entries (account, key, value, updated_at)
    VALUES (?, ?, ?, unixepoch())
    ON CONFLICT(account, key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
  `);
  stmt.run(account, key, value);
}

export function getEntry(account, key) {
  const stmt = db.prepare('SELECT value FROM entries WHERE account = ? AND key = ?');
  const row = stmt.get(account, key);
  return row ? row.value : null;
}

export function deleteEntry(account, key) {
  const stmt = db.prepare('DELETE FROM entries WHERE account = ? AND key = ?');
  const result = stmt.run(account, key);
  return result.changes > 0;
}

export function listKeys(account) {
  const stmt = db.prepare('SELECT key, created_at, updated_at FROM entries WHERE account = ?');
  return stmt.all(account);
}

Step 2: Authentication Middleware

Every request must prove the caller owns the account. We use the VerifySignedData pattern from the anchor SDK.

// src/auth.js
const KeetaNet = await import('@keetanetwork/keetanet-client');
const { VerifySignedData } = await import('@keetanetwork/anchor/lib/utils/signing.js');

const MAX_AGE_MS = 60_000; // signatures valid for 60 seconds

export async function verifyRequest(req, res, next) {
  const { account: address, signedData, ...fields } = req.body;

  if (!address || !signedData) {
    return res.status(400).json({ error: 'Missing account or signedData' });
  }

  try {
    // Reconstruct account from public key (verify-only, no private key)
    const account = KeetaNet.default.lib.Account.fromPublicKeyString(address);

    // Build the fields array in the same order the client signed them
    const fieldValues = Object.entries(fields)
      .sort(([a], [b]) => a.localeCompare(b))
      .map(([, v]) => String(v));

    const valid = await VerifySignedData(account, fieldValues, signedData);
    if (!valid) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // Check timestamp freshness
    const age = Date.now() - signedData.timestamp;
    if (age > MAX_AGE_MS) {
      return res.status(401).json({ error: 'Signature expired' });
    }

    req.verifiedAccount = address;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Verification failed', detail: err.message });
  }
}

Step 3: Server and Routes

// src/index.js
import express from 'express';
import { verifyRequest } from './auth.js';
import { setEntry, getEntry, deleteEntry, listKeys } from './db.js';

const app = express();
app.use(express.json({ limit: '1mb' }));

// Health check (no auth)
app.get('/health', (req, res) => {
  res.json({ status: 'ok', service: 'storage-anchor' });
});

// Store a value
app.post('/store', verifyRequest, (req, res) => {
  const { key, value } = req.body;

  if (!key || value === undefined) {
    return res.status(400).json({ error: 'Missing key or value' });
  }

  if (key.length > 256) {
    return res.status(400).json({ error: 'Key too long (max 256 chars)' });
  }

  const encoded = typeof value === 'string' ? value : JSON.stringify(value);
  if (Buffer.byteLength(encoded) > 512_000) {
    return res.status(400).json({ error: 'Value too large (max 512 KB)' });
  }

  setEntry(req.verifiedAccount, key, encoded);
  res.json({ ok: true, key });
});

// Retrieve a value
app.post('/retrieve', verifyRequest, (req, res) => {
  const { key } = req.body;

  if (!key) {
    return res.status(400).json({ error: 'Missing key' });
  }

  const value = getEntry(req.verifiedAccount, key);
  if (value === null) {
    return res.status(404).json({ error: 'Key not found' });
  }

  // Try to parse as JSON, fall back to string
  try {
    res.json({ key, value: JSON.parse(value) });
  } catch {
    res.json({ key, value });
  }
});

// Delete a key
app.post('/delete', verifyRequest, (req, res) => {
  const { key } = req.body;

  if (!key) {
    return res.status(400).json({ error: 'Missing key' });
  }

  const deleted = deleteEntry(req.verifiedAccount, key);
  res.json({ ok: true, deleted });
});

// List all keys
app.post('/list', verifyRequest, (req, res) => {
  const keys = listKeys(req.verifiedAccount);
  res.json({ keys });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Storage anchor running on port ${PORT}`));

Step 4: Client Usage

Here's how a client interacts with the storage anchor:

import KeetaNet from '@keetanetwork/keetanet-client';
const { SignData } = await import('@keetanetwork/anchor/lib/utils/signing.js');

const seed = Buffer.from(process.env.USER_SEED, 'hex');
const account = KeetaNet.lib.Account.fromSeed(seed, 0);
const address = account.publicKeyString.get();

// Store a value
async function store(key, value) {
  const fields = [key, JSON.stringify(value)];
  const signedData = await SignData(account, fields);

  const res = await fetch('http://localhost:3000/store', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ account: address, key, value, signedData }),
  });
  return res.json();
}

// Retrieve a value
async function retrieve(key) {
  const fields = [key];
  const signedData = await SignData(account, fields);

  const res = await fetch('http://localhost:3000/retrieve', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ account: address, key, signedData }),
  });
  return res.json();
}

// Example
await store('profile', { name: 'Alice', avatar: 'https://example.com/pic.png' });
const { value } = await retrieve('profile');
console.log(value.name); // "Alice"

Adding Encryption

For sensitive data, encrypt values before storing. The anchor stores opaque blobs — it can't read the contents.

// Client-side encryption using the account's keypair
import { webcrypto } from 'node:crypto';

async function encryptForStorage(data, encryptionKey) {
  const iv = webcrypto.getRandomValues(new Uint8Array(12));
  const encoded = new TextEncoder().encode(JSON.stringify(data));

  const ciphertext = await webcrypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    encryptionKey,
    encoded
  );

  // Combine IV + ciphertext for storage
  const combined = new Uint8Array(iv.length + ciphertext.byteLength);
  combined.set(iv);
  combined.set(new Uint8Array(ciphertext), iv.length);

  return Buffer.from(combined).toString('base64');
}

Adding Size Quotas

In production, you'll want per-account storage limits:

// Add to db.js
export function getAccountUsage(account) {
  const stmt = db.prepare(
    'SELECT COUNT(*) as count, COALESCE(SUM(LENGTH(value)), 0) as bytes FROM entries WHERE account = ?'
  );
  return stmt.get(account);
}

// Add to the /store route
const usage = getAccountUsage(req.verifiedAccount);
const MAX_KEYS = 1000;
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB per account

if (usage.count >= MAX_KEYS) {
  return res.status(429).json({ error: 'Key limit reached' });
}
if (usage.bytes + Buffer.byteLength(encoded) > MAX_BYTES) {
  return res.status(429).json({ error: 'Storage quota exceeded' });
}

Publishing Metadata

Register your storage anchor on-chain so the Keeta resolver can discover it. See Publishing Metadata for the full guide.

Your metadata should advertise the storage service category:

{
  "version": 1,
  "services": {
    "storage": {
      "providers": [{
        "name": "My Storage Anchor",
        "endpoint": "https://my-storage.example.com",
        "operations": ["store", "retrieve", "delete", "list"],
        "limits": {
          "maxValueSize": 512000,
          "maxKeysPerAccount": 1000,
          "maxTotalBytes": 10485760
        }
      }]
    }
  }
}

Revenue Model

Storage anchors can monetize via:

Model

Description

Free tier + paid

Free up to 1 MB, charge per-GB after

Per-request

Small fee per store/retrieve operation

Subscription

Monthly fee for premium storage limits

Freemium

Free storage with paid features (versioning, backups)

See Anchor Business Models for more strategies.

Next Steps

  • Add versioning — keep previous values, allow rollback

  • Add public data — some keys could be readable without authentication (like profile data)

  • Add shared access — allow an account to grant read access to specific keys for other accounts

  • Deploy and register on-chain

More in Anchor Guides · All documentation