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 |
|---|---|---|
| POST | Store a value for a key (signed request) |
| POST | Retrieve a value by key (signed request) |
| POST | Delete a key (signed request) |
| 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-sqlite3Set 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=3000Step 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