A lightweight indexer and cache node for the FreezeDry Protocol — on-chain art storage on Solana.
Nodes scan the Solana blockchain for FREEZEDRY: pointer memos, fetch the associated chunk data, and serve reconstructed artwork blobs over HTTP. The chain is the source of truth; nodes are a discovery and caching layer.
Full app: freezedry.art — managed inscriptions, NFT minting, and fast hydration.
- Node.js v18+ — nodejs.org
- Helius API key — Free at helius.dev (sign up → Dashboard → API Keys → copy)
Writer/marketplace nodes also need:
- Solana CLI (optional) —
sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)"for wallet management - Public domain + HTTPS — for peer network participation (e.g.
node.yourdomain.comwith reverse proxy)
git clone https://github.com/freezedry-protocol/freezedry-node.git
cd freezedry-node
npm run setup
npm startThe setup wizard walks you through role selection, Helius key, wallet, and node identity. It generates .env with safe defaults, a secure WEBHOOK_SECRET, and peer network bootstrap.
git clone https://github.com/freezedry-protocol/freezedry-node.git
cd freezedry-node
npm install
cp .env.example .env
# Edit .env — at minimum set HELIUS_API_KEY and generate WEBHOOK_SECRET:
# openssl rand -hex 32
npm start| Variable | Required | What it is | Where to get it |
|---|---|---|---|
HELIUS_API_KEY |
Yes | Solana RPC access | helius.dev (free tier works) |
WEBHOOK_SECRET |
Yes | Auth for write endpoints | openssl rand -hex 32 (setup.sh generates this) |
IDENTITY_KEYPAIR |
For peers | Ed25519 identity key (JSON array) | setup.sh generates one |
HOT_WALLET_KEYPAIR |
For writer | Solana keypair for TX signing | setup.sh generates one |
NODE_URL |
For peers | Your node's public https URL | Your domain + reverse proxy |
NODE_ENDPOINT |
For peers | IP:port for domain-free nodes | Your public IP + port (e.g. 203.0.113.5:3100) |
Legacy: WALLET_KEYPAIR still works — used for both identity and hot wallet if the separate keys aren't set.
After npm start, you should see:
FreezeDry Node (my-freezedry-node) listening on :3100
Indexer: starting (poll every 120s, wallet: 6ao3hnvK...)
Indexer: seeded N artworks from registry
Check health:
curl http://localhost:3100/health
# {"status":"ok","indexed":{"artworks":19,"complete":19},"peers":2,"identityPubkey":"AbCd...","displayName":"Brave Tiger"}The node seeds existing artworks from the registry on startup, then begins scanning the chain for new ones. Blobs are fetched from peers first (free, instant HTTP) before falling back to chain reads.
| Role | What it does | Helius plan | Wallet needed? |
|---|---|---|---|
| reader | Index chain + serve artwork to peers | Free works | No |
| writer | Accept inscription jobs, earn fees | Developer+ | Yes (funded) |
| both | Reader + writer (default) | Developer+ | Yes (funded) |
Reader-only is the simplest way to help the network. No wallet, no SOL, just a Helius key.
Writer economics: Your wallet needs SOL as working capital to send memo transactions. You get reimbursed from the job escrow: 5,000 lamports/chunk (covers TX costs) plus 40% of the margin as profit. Start with ~0.1 SOL for testing, ~1 SOL for production.
Solana Chain Your Node Peers / CDN
| | |
|--- FREEZEDRY: pointer ------>| discover artwork |
|--- chunk memos ------------->| fetch & cache chunks |
| | |
| |<-- GET /artwork/:hash ----| metadata
| |<-- GET /blob/:hash -------| cached blob (peers only)
| |<-- GET /verify/:hash -----| SHA-256 proof
Discovery: The indexer polls for the configured SERVER_WALLET's memo transactions, looking for FREEZEDRY: pointers. Each pointer contains a hash, chunk count, and blob size. Paginated — handles artworks with thousands of chunks.
Caching: Once a pointer is found, the node fetches all chunk transactions (paginated beyond API limits), strips memo headers, and stores the raw data in SQLite.
Peer Sync: Before reading from chain, the node tries peers first. Peer blob downloads are instant HTTP — no RPC credits needed. All peer-to-peer requests are authenticated with ed25519 signed messages.
Serving: Peers request blobs via HTTP. Only complete blobs are served — partial data is never sent.
| Endpoint | Method | Returns |
|---|---|---|
/health |
GET | Node status, indexed artwork count, peer count |
/artwork/:hash |
GET | Artwork metadata (dimensions, mode, chunk count, complete status) |
/artworks?limit=50&offset=0 |
GET | List indexed artworks |
/verify/:hash |
GET | SHA-256 verification of stored blob |
| Endpoint | Method | How to access |
|---|---|---|
/blob/:hash |
GET | Ed25519 signed identity headers (X-FD-Identity, X-FD-Signature, X-FD-Message) |
/sync/list |
GET | Same — lists available artworks for sync |
/sync/chunks/:hash |
GET | Same — base64 blob for peer sync |
/nodes |
GET | Same — list known peers (gossip discovery) |
| Endpoint | Method | Description |
|---|---|---|
/ingest |
POST | Push artwork metadata (coordinator → node) |
/webhook/helius |
POST | Receive real-time Helius webhook pushes |
| Endpoint | Method | Description |
|---|---|---|
/sync/announce |
POST | Register a peer node URL (must be https, public IP, reachable) |
Nodes discover each other and sync blobs without using RPC credits. All peer communication is authenticated with ed25519 signed messages — no shared secrets.
Each node has two keypairs:
| Key | Purpose | Needs SOL? |
|---|---|---|
| Identity key | Peer authentication, reputation, display name | No |
| Hot wallet | Signs Solana memo TXs, pays fees, earns escrow | Yes (writer only) |
Separate keys = separate risk. If the hot wallet is compromised, identity and reputation are untouched.
# In .env — choose one connectivity method:
# Option A: Domain (requires HTTPS reverse proxy)
NODE_URL=https://node.yourdomain.com
# Option B: IP:port (no domain needed — simplest setup)
NODE_ENDPOINT=203.0.113.5:3100
# Optional: bootstrap peers (otherwise discovered via coordinator)
PEER_NODES=https://peer1.example.com,http://198.51.100.10:3100Every peer-to-peer request includes three HTTP headers:
| Header | Contents |
|---|---|
X-FD-Identity |
Node's public key (base58) |
X-FD-Message |
FreezeDry:peer:{action}:{timestamp}:{nonce} |
X-FD-Signature |
Ed25519 signature of the message |
The receiving node verifies:
- Signature validity — only the private key holder could have signed this
- Timestamp freshness — must be within 5 minutes (prevents replay of old messages)
- Nonce uniqueness — random nonce prevents exact replay within the freshness window
- Known identity — the signing pubkey must be a registered peer
Your Node Peer Node
| |
|--- POST /sync/announce --------->| signed identity + endpoint
| (peer verifies signature)
|<-- POST /sync/announce ----------| signed identity + endpoint (bidirectional)
| |
|--- GET /blob/:hash ------------->| complete blob (signed request)
| (peer sync — no RPC needed) |
- Announce — Node sends its identity pubkey, endpoint, and signed auth headers. Receiving node verifies the ed25519 signature matches the claimed identity
- Bidirectional — Your node announces back automatically
- Parallel fill — When filling incomplete artworks, tries peers first (instant HTTP). Falls back to chain reads only if no peer has the data
- Gossip — Every ~20 minutes, nodes exchange peer lists to discover new nodes
- Coordinator — Nodes also register with
freezedry.artfor centralized discovery (optional, bootstrapping convenience)
Each node gets a deterministic display name from its identity pubkey (SHA-256 hash → adjective + animal). Example: Brave Tiger, Silent Falcon. These are cosmetic — the pubkey is the real identity.
- Ed25519 identity auth: All peer requests require cryptographic proof of identity. No shared passwords.
- Nonce replay protection: Each signed message includes a random nonce. Replayed messages are rejected.
- SSRF protection: Private IPs (
10.x,192.168.x,169.254.x,127.x,::1),.internal/.localhostnames, and IPv6 private ranges blocked - HTTP IP-only: Plain HTTP allowed only for raw public IPv4 addresses (prevents DNS rebinding)
- Rate limiting: 10 announce requests/min per IP
- Peer-gated data: Blob data requires signed identity from a known peer — no unauthenticated scraping
- Complete blobs only: Partial/incomplete data is never served to peers
- Minimal exposure:
/healthreturns status + counts + identity pubkey. No memory, uptime, keys, or internal details
The node auto-detects your Helius plan on startup:
- Free key: Uses standard RPC (
getSignaturesForAddress+getTransaction). Works fine, slightly slower. - Paid key (Developer+): Uses Enhanced API. ~50x cheaper in credits, faster indexing.
Override with USE_ENHANCED_API=true|false in .env if needed.
freezedry-node/
src/
server.js — Fastify HTTP server + endpoints
indexer.js — Chain scanner + peer sync + gossip
db.js — SQLite storage (better-sqlite3, WAL mode)
config.js — Protocol constants
wallet.js — Two-wallet keypair loader (identity + hot wallet)
crypto-auth.js — Ed25519 signing + verification for peer auth
display-name.js — Deterministic display names from identity pubkey
scripts/
setup.sh — Interactive setup wizard (two-wallet generation)
register.js — Manual PDA registration
.env.example — Configuration template
Database: SQLite via better-sqlite3 with WAL mode for concurrent reads. Created automatically on first run. This is a cache — delete it to re-index from chain.
Dependencies: 3 runtime deps: fastify, better-sqlite3, @solana/web3.js (optional — reader-only nodes work without it).
server {
listen 443 ssl;
server_name node.yourdomain.com;
location / {
proxy_pass http://127.0.0.1:3100;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Forward identity auth headers for peer-to-peer communication
proxy_set_header X-FD-Identity $http_x_fd_identity;
proxy_set_header X-FD-Signature $http_x_fd_signature;
proxy_set_header X-FD-Message $http_x_fd_message;
}
}[Unit]
Description=FreezeDry Node
After=network.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/freezedry-node
ExecStart=/usr/bin/node src/server.js
Restart=on-failure
MemoryMax=512M
MemoryHigh=400M
[Install]
WantedBy=multi-user.targetsudo systemctl enable freezedry-node
sudo systemctl start freezedry-nodedocker compose up -d
docker compose logs -fInstead of polling every 2 minutes, configure a Helius webhook for instant indexing:
- Go to Helius Dashboard > Webhooks
- Create webhook watching the
SERVER_WALLETaddress - Set URL to
https://node.yourdomain.com/webhook/helius - Set auth header to your
WEBHOOK_SECRET - Select "Enhanced" format
| Problem | Fix |
|---|---|
better-sqlite3 build fails |
Install build tools: apt install python3 make g++ (or use Docker) |
| Port 3100 already in use | Change PORT in .env or stop the other process |
| 0 artworks after startup | Check HELIUS_API_KEY is valid. The node seeds from registry first, then scans chain. |
| Node can't find peers | Ensure PEER_NODES is set (setup.sh adds defaults). Check network connectivity. |
| Peer auth fails | Check IDENTITY_KEYPAIR is set. Verify identity pubkey matches what the peer expects. |
| Registration fails | Ensure NODE_URL or NODE_ENDPOINT is publicly reachable. The coordinator verifies your signature. |
| No display name | Set IDENTITY_KEYPAIR — display names derive from the identity pubkey. |
| High credit usage | Lower POLL_INTERVAL (default 1hr is safe). Keep CHAIN_FILL=false. See docs/rpc-budget.md. |
- Free Tools — RPC calculator, standalone inscriber, embed widget, and more
- freezedry-protocol — SDK packages + Anchor programs
- freezedry.art — Full app with managed infrastructure
MIT