Blockchain-state backups

The three API-only backup routes, the per-chain schedule rows they write, what the cron actually captures to disk, and why this is not a restore path for your platform data.

5 min readUpdated 6 August 2026backup, cron, schedule, disaster-recovery, chains

NFT blockchain-state backups have three routes, three permission keys and a cron job, and no screen anywhere in the admin panel. There is nothing to click, nothing that reports a failure to you, and nothing that tells you a schedule exists. This page is the interface.

A backup here is a compressed snapshot of NFT rows and settings, written to the backend's local disk. It is not a database dump, it is not off-site, and there is no route that restores it. Nothing about this feature replaces Backup and restore, which is the real disaster-recovery story for the platform.

The three routes

Creates or replaces the schedule for one chain
Lists every schedule, or one chain's
Removes a chain's schedule

Three separate permission keys, none of which is implied by access.nft.admin. An operator who can create a schedule cannot necessarily list or delete it.

The payload

POST takes:

Field Required Accepted Rejected with
chain Yes ETH, BSC, POLYGON — nothing else 400, "Invalid chain. Must be one of: ETH, BSC, POLYGON"
schedule Yes HOURLY, DAILY, WEEKLY, MONTHLY 400, "Invalid schedule…"
backupType Yes FULL or INCREMENTAL 400, "Invalid backup type. Must be FULL or INCREMENTAL"
includeDisputes No boolean, default false
enabled No boolean, default true
runImmediately No boolean, default false

runImmediately fires a backup about a second after the response, in the background. It reports nothing back — success and failure both land only in the backend log, under NFT.

DELETE takes { "chain": "ETH" } and 404s if no schedule exists for it.

Where a schedule lives

There is no backup-schedule table. Each schedule is a row in the platform settings table, keyed nft_backup_schedule_<chain>, whose value is a JSON document:

{
  "chain": "ETH",
  "schedule": "DAILY",
  "backupType": "FULL",
  "includeDisputes": false,
  "enabled": true,
  "lastRun": "2026-08-05T02:14:09.412Z",
  "nextRun": "2026-08-06T02:14:09.412Z",
  "createdAt": "2026-07-30T09:00:00.000Z"
}

Two things follow from the key shape:

  • One schedule per chain, at most. Posting a second schedule for ETH overwrites the first — there is no error and no history.
  • nextRun is the whole scheduler. The cron acts when now >= nextRun, and writes the next stamp from lastRun plus the period. Editing the row by hand changes when the next backup fires; setting enabled to false stops it without deleting it.

The cron is the only scheduling authority

processNFTBackups is registered in the nft category and polls every 15 minutes. That is finer than HOURLY, the tightest schedule the route offers, so a due backup fires within a quarter of an hour of its nextRun rather than a full period late.

It reads every nft_backup_schedule_% row, skips the disabled ones, and runs those whose nextRun has passed — FULL calls a full capture, anything else calls the incremental one.

The backup module used to also keep an in-process setInterval for each schedule. Its initialiser was exported and called from nowhere, so a schedule created through the admin route only ever ran while that one process stayed up and never survived a restart. Wiring it up alongside the cron would have been worse — every backup would have run twice.

The cron is now the single authority. If you are reading an older note that tells you to start the scheduler after creating a schedule, ignore it: creating the settings row is the scheduling.

Per-schedule failures are isolated on purpose — one bad chain must not cost the others their backup — and are logged individually. A run in which every due schedule failed throws, so the job's status goes to failed with the error rather than quietly reporting success.

What a backup actually contains

A FULL backup collects, for the whole marketplace and not just the named chain:

  • every nft_collection row (the "contracts" list);
  • every nft_token row;
  • every nft_listing row;
  • every nft_dispute row, only when includeDisputes is true;
  • every settings key beginning nft, plus total sale volume, the active listing count and the token count;
  • the last seven days of gas_history for that chain.

The block number of the chain at capture time goes in the metadata, which is where the "blockchain state" name comes from — the rows are the platform's record of the chain, not a re-read of it.

Two entries are always empty, and knowing that saves an investigation: transactions is deliberately not collected (NFT movements live in the core transaction ledger), and gasHistory is empty on every install because nothing writes that table.

An INCREMENTAL backup takes only rows changed since the previous backup's timestamp; with no previous backup on disk it falls back to a full one.

What it is not

  • Not your users, wallets, balances, sales or offers. nft_sale, nft_offer, nft_bid, nft_activity and every core table are outside it.
  • Not a restore path. The service has a restore method, and no route, screen or job calls it. Recovering from one of these files means a developer and a plan, not a button.
  • Not off-site. S3 upload is stubbed out — the client is set to null even when AWS credentials are present, so every backup is local disk only.

Where the files land

Setting Default Effect
BACKUP_PATH <backend working directory>/backups/nft Where the .gz files are written. Created if missing
BACKUP_ENCRYPTION_KEY unset When set, the JSON is AES-256-GCM encrypted before compression. A hex key — the file is unreadable without it, and there is no recovery if you lose it

Filenames are nft-backup-<chain>-<full|incremental>-<block>-<timestamp>.gz. Only a full backup carries a block number there: <block> is block-<n> for a full capture, and the literal latest for an incremental one, which never passes a block number. So an incremental file on disk reads nft-backup-ETH-incremental-latest-<timestamp>.gz — there is no -incremental-block-… to search for.

Retention is 30 files per chain, pruned oldest-first by modification time after each successful backup, with one exception: the newest full backup is never deleted, because an incremental is a delta with nothing to apply it to otherwise.

Disk is your responsibility. A daily full backup of a large marketplace on three chains is three growing files a day against a 30-file ceiling per chain, and nothing on the platform warns you about the volume.

How to tell it ran

There is no backup record in the database. The service writes one only if a systemBackup model exists, and this build has none, so nothing is written to any table — including nft_metadata_backup, which exists in the schema and is written by nothing in this build.

That leaves three ways to confirm a run:

  1. The cron console/admin/system/cron, the processNFTBackups row. Status, last run, last error and the live log frames all key off the exact job name, so this is the authoritative surface.

  2. The files on disk — list BACKUP_PATH (or backups/nft) and check the newest timestamp for the chain you care about.

  3. The schedule rowGET /api/admin/nft/backup/schedule returns each config with its lastRun and nextRun. A lastRun of null on a schedule older than its period means it has never fired.

A run that logs success has written a compressed file it has never read back. Restore-testing is not automated anywhere in this product. If these backups matter to you, decompress one by hand on a schedule of your own and confirm it parses — an encrypted backup whose key has drifted looks exactly like a healthy one until the day you need it.