Upgrading Ecosystem

The schema migrations Ecosystem ships that nothing applies for you — the UTXO status conversion that prevents a withdrawal double-spend, the Scylla order columns, and the order to run them in.

12 min readUpdated 6 August 2026upgrade, migration, utxo, scylladb, withdrawals

Updating Ecosystem is the core update chain plus a handful of one-time schema scripts that nothing runs for you. pnpm updator applies the Sequelize schema sync and the seeders; it does not run these. Extracting the release files and restarting does not run them either.

The cost of skipping them is not cosmetic. The one that matters converts ecosystem_utxo.status from a boolean into a three-state ENUM, and the middle state — LOCKED — is what stops a Bitcoin-family withdrawal that crashed mid-broadcast from having its inputs selected again by the next withdrawal. Without it the platform can broadcast a second transaction spending coins that are already gone.

The boolean-to-ENUM conversion must happen while the column is still a boolean. pnpm updator boots the backend to let Sequelize apply the new schema, and the model now declares ENUM('UNSPENT','LOCKED','SPENT'). A direct tinyint-to-ENUM CHANGE COLUMN is rejected under STRICT_TRANS_TABLES (0 and 1 are not members of the target ENUM) and coerces the values where strict mode is off. Either way the existing spent/unspent state of every recorded output is lost or the sync fails. Take a database backup, then run the :before step first.

What actually has to be applied by hand

Script Store Wired into a pnpm script What only it can do
migration-ecosys06-broadcast-hash-lock.mjs MySQL Yes — db:migrate:6.4.9:before The data-preserving boolean-to-ENUM conversion, and the transaction_ledger_applied guard table
migration-ecosys-09-worst-case-hold.mjs ScyllaDB No — run it by path Two audit columns on the orders table in the trading keyspace

Both live in backend/scripts/. Both read the project-root .env regardless of the directory you run them from, both are dry-run by default, and both are idempotent — re-running after a successful apply is a reported no-op.

--apply is the only thing that writes. Without it the script connects, inspects, prints a report of every change it would make, and exits.

Before you start

    • A MySQL dump you have actually verified — /admin/system/database/backup
    • A ScyllaDB snapshot (nodetool snapshot), because nothing in the product backs it up
    • A copy of .env off the box — see Updating for why this one is unrecoverable
    • Shell access to the project root; none of this has an admin-panel equivalent
    • The withdrawal queue quiet — no PROCESSING UTXO withdrawals in flight

The last item is worth a minute. The ECOSYS-06 script rewrites the status column of every row in ecosystem_utxo. Doing that while a withdrawal is choosing inputs is asking for a torn read of the one table that decides which coins are spendable. Stop the backend, or at minimum stop the process that owns the matching engine and the withdrawal queue.

The safe sequence

  1. Back up. MySQL, ScyllaDB, .env. The UTXO conversion has no reverse script.

  2. Stop the platform.

    pnpm stop

    This puts the maintenance server on the frontend and backend ports and removes the backend, frontend and cron PM2 apps. Port 4001 is left free on purpose — the migration boot later in the chain needs it.

  3. Preview the MySQL migrations. Read the report before you write anything.

    pnpm db:migrate:6.4.9:before

    Every line is prefixed with what it would do: [ALTER], [ADD], [CREATE], [SKIP] or [WARN]. A run that ends Schema already up to date — nothing to change. means this install has had them applied before.

  4. Apply them.

    pnpm db:migrate:6.4.9:before:apply
  5. Run the normal update chain. This is where the Sequelize schema sync, the seeders and the frontend rebuild happen.

    pnpm updator
  6. Apply the ScyllaDB migration. It is not in any pnpm script, so it goes by path. Dry run first.

    node backend/scripts/migration-ecosys-09-worst-case-hold.mjs
    node backend/scripts/migration-ecosys-09-worst-case-hold.mjs --apply
  7. Restart both backend processes, so the models pick up the new schema. Both scripts print this instruction themselves at the end of an apply run.

    pm2 restart backend cron

    A default deployment runs backend/dist/index.js as two PM2 apps — backend with CRON_MODE=off and cron with CRON_MODE=only on port 4001, both defined in production.config.js. pm2 restart backend alone leaves the scheduler running on the pre-migration model metadata, and the scheduler is the process that runs the ECOSYS-06 boot recovery pass. pnpm restart restarts everything if you would rather not name apps.

The core release also ships an :after pair — pnpm db:migrate:6.4.9:after and :after:apply — which drops old indexes, re-points foreign keys and backfills method status. Those are core concerns rather than Ecosystem ones, but they run in the same window and are documented in the Core v6.4.9 patch notes.

What db:migrate:6.4.9:before runs

The command is a chain of three scripts, and only the first is Ecosystem's. They run in order and && stops the chain on the first failure.

Script Table What it does
migration-ecosys06-broadcast-hash-lock.mjs ecosystem_utxo, transaction The UTXO status ENUM, lockedTxId, the guard table and two indexes
deduplicate-kyc-applications.mjs kyc_application Soft-deletes duplicate applications so the new UNIQUE(userId, levelId) index can be created
dedupe-nft-sale-hashes.mjs nft_sale Soft-deletes duplicate transactionHash rows, then adds the unique index

The second and third exist for the same structural reason as the first: the models now declare unique indexes that sync({alter:true}) cannot create while duplicate rows are present, so the sync fails and the update stops. If you do not run KYC or the NFT Marketplace those two are no-ops — they check information_schema and report nothing to do.

ECOSYS-06, in detail

This is the migration that touches money. Four changes, each guarded by an information_schema existence check so a second run changes nothing.

ecosystem_utxo.status becomes an ENUM

BOOLEAN becomes ENUM('UNSPENT','LOCKED','SPENT') NOT NULL DEFAULT 'UNSPENT', with the existing data preserved: 0 becomes UNSPENT, 1 becomes SPENT.

Value Meaning
UNSPENT Available for selection into a withdrawal
LOCKED Reserved for an in-flight broadcast. Not re-selectable
SPENT Confirmed consumed on-chain

The conversion does not go straight from tinyint to ENUM. It snapshots the raw 0/1 values into a temporary status_backup column, widens status to VARCHAR(20), rewrites every value to a valid label, and only then tightens the column to the ENUM — because a direct cast is rejected in strict mode. The recovery path is driven by whether status_backup still exists rather than by the column type, so a crash at any point of the conversion is fully recovered by re-running the script.

Why LOCKED is not optional

UTXO withdrawals in backend/src/api/(ext)/ecosystem/utils/utxo.ts sign and broadcast the transaction before the database reservation commits. That ordering is deliberate — the coins have to be on-chain before the platform can honestly record them as gone — but it leaves a window.

Crash inside that window with only a boolean column and two things are true at once: the selected inputs are still marked spendable, so the next withdrawal can select them again and broadcast a second transaction spending coins that have already left; and the broadcast hash is nowhere, so there is nothing to check the chain against and no idempotent retry.

The fix needs both new pieces of schema:

  • ecosystem_utxo.status = 'LOCKED' plus ecosystem_utxo.lockedTxId (VARCHAR(191)), written in the same committed transaction as the broadcast hash. lockedTxId carries the broadcast txid that reserved the input, so promoting LOCKED to SPENT is scoped to one withdrawal and two concurrent withdrawals on the same wallet cannot release each other's inputs.
  • transaction.txHashPending (VARCHAR(191)), written outside the database transaction, before the network call, so a rollback cannot erase it.

At boot the withdrawal queue's recovery pass reads those columns and decides per row:

Row state Recovery action
PENDING Re-enqueue — normal retry
PROCESSING with a trxId Already broadcast. Promote to COMPLETED, never re-broadcast, promote its LOCKED inputs to SPENT
PROCESSING, no trxId, has txHashPending, hash found on-chain Promote to COMPLETED with that hash as trxId, settle the inputs it consumed
PROCESSING, no trxId, older than 5 minutes, UTXO chain, nothing on-chain after 30 minutes Revert to PENDING, clear txHashPending, retry
PROCESSING, no trxId, non-UTXO chain Left alone and logged for manual review — only UTXO handlers persist a pre-broadcast intent, so a broadcast cannot be ruled out

txHashPending is declared on the transaction model, so the schema sync would eventually add it. status as an ENUM is the piece the sync cannot get right, and LOCKED is meaningless without it.

transaction_ledger_applied

A bridge table the script creates, with UNIQUE(transactionId, walletId, currency, chain), intended so a private-ledger decrement can be applied at most once per combination — a duplicate would become a constraint violation instead of a silent double-spend.

That guard is not in force today. Nothing in the product writes to or reads this table: searching backend/src, backend/models, backend/dist and the frontend for transaction_ledger_applied finds the migration script and nothing else. Like the ECOSYS-09 columns below, it is schema laid down ahead of the code that will use it — creating it costs nothing, and it is not the reason ECOSYS-06 is urgent. The UTXO status ENUM is.

No Sequelize model declares this table either, which means the schema sync will neither create it nor repair it. If this script did not create it, it does not exist on your install.

The foreign key to transaction(id) is only added when the parent table is present, and the script reads the parent column's exact type, character set and collation before creating the child. On this codebase transaction.id is char(36) / utf8mb4_bin rather than the table default, and a mismatch makes InnoDB reject the constraint with errno 150.

Two indexes

  • idx_status_wallet_locked on ecosystem_utxo (status, walletId) — the scan of LOCKED outputs during recovery and validation.
  • idx_status_trxid_recovery on transaction (status, trxId, createdAt) — the scan for broadcast PROCESSING rows at boot.

Both are declared on their models as well, so the sync would create them too. The script adds them ahead of it so the recovery pass is fast on its first run.

If the ecosystem_utxo table is absent — Ecosystem not installed — the script reports [SKIP] for the UTXO steps and carries on with the rest. It is safe on a core-only install.

ECOSYS-09, and what it is not

Ecosystem orders do not live in MySQL. There is no ecosystemOrder table. Orders, candles, the order book, the trade tape, the open-orders index and stop orders are all in ScyllaDB, in the keyspace named by SCYLLA_KEYSPACE (trading by default). Any migration touching an order is CQL, not SQL, and information_schema has nothing to say about it.

migration-ecosys-09-worst-case-hold.mjs adds two columns to <keyspace>.orders:

Column CQL type Purpose
maxSlippageBasisPoints INT Slippage tolerance in basis points — 500 is 5%
actualFillPrice DECIMAL Average fill price recorded after settlement

Both identifiers are created quoted, so they keep their camel case the way the rest of the orders schema does.

The worst-case market-BUY hold itself is a runtime change in backend/src/api/(ext)/ecosystem/utils/placeOrder.ts — it walks the book to compute the hold and needs no schema at all. A search of the backend finds no reader or writer of either column outside the migration script itself. Applying it costs nothing and makes the schema match the plan; skipping it does not break an order. Treat ECOSYS-06 as the one that matters.

The backend connects using SCYLLA_CONNECT_POINTS. This script — like fix-ecosystem-orders.mjs, fix-ecosystem-candles.mjs and reconcile-eco-inorder.mjs — reads SCYLLA_HOST and falls back to localhost. SCYLLA_HOST is not in .env.example and the platform never reads it. If your Scylla node is not on the app box, set SCYLLA_HOST in .env before running, or the script will fail to connect to a node that is perfectly healthy. It reads SCYLLA_KEYSPACE, SCYLLA_DATACENTER, SCYLLA_USERNAME and SCYLLA_PASSWORD the same way the backend does.

If the orders table does not exist in the keyspace the script prints [ABORT] and changes nothing — that means the Ecosystem Scylla schema has never been initialised, which is a boot problem, not a migration problem. See Install.

What the backend applies for you, and when

The Scylla client creates its keyspaces, tables and materialized views on first connection, and runs a small column migration list on every boot — the runMigrations() function in backend/src/api/(ext)/ecosystem/utils/scylla/client.ts. That list is per keyspace: marketMakerId, botId and walletType on the trading orders table; reduceOnly, positionId and isTaker on the futures one. Each statement is an ALTER TABLE ... ADD whose "already exists" error is swallowed, which is what makes it safe to run on every start.

The ECOSYS-09 columns are not in that list. Nothing adds them at boot.

There is a second-order trap here. pnpm updator does not apply the Scylla schema either. Its migration step runs backend/dist/index.js directly with CRON_MODE=off and BICRYPTO_SCHEMA_SYNC_ONLY=true, and that flag skips the entire Extensions boot phase — no matching engine, no futures matcher, no Scylla initialisation. That is deliberate: booting the whole server to migrate a schema once reached the 7.7 GB heap cap on a large install and made it impossible to update. The consequence for you is that every Scylla-side change, including runMigrations(), lands on the first normal backend start after the update, not during it.

backend/dist is what production runs

backend/dist is tracked in the repository, ships pre-built inside every release package, and is the file PM2 actually executes — production.config.js and production.backend.config.js both point at ./backend/dist/index.js. The update chain never runs build:backend for exactly that reason.

Two things follow:

  • Editing backend/src changes nothing on a production box. The running process is loading dist. A source-only patch — a hotfix pasted into a .ts file, a merge that touched only src — is invisible until dist is rebuilt with pnpm bundle.
  • A release replaces dist wholesale. Any local change you made to it is gone. updator:migrate refuses to start and halts the update if backend/dist/index.js is missing, telling you to restore it or run pnpm bundle.

Verifying

The MySQL side, from a shell:

SHOW COLUMNS FROM ecosystem_utxo LIKE 'status';
SHOW COLUMNS FROM ecosystem_utxo LIKE 'lockedTxId';
SHOW INDEX FROM ecosystem_utxo WHERE Key_name = 'idx_status_wallet_locked';
SHOW TABLES LIKE 'transaction_ledger_applied';

The Scylla side:

cqlsh -u bicrypto -p '…' -e "DESCRIBE TABLE trading.orders;"

Re-running either script with no --apply is the cheapest check of all — a correctly migrated install reports Schema already up to date — nothing to change.

Ecosystem → UTXO Management at /admin/ecosystem/utxo still renders the status column with a boolean renderer, and every non-empty string is truthy to it — so a SPENT or LOCKED output shows as a green "Available" badge in the table. The view dialog reads the real ENUM value and labels it Unspent, Locked or Spent, with lockedTxId shown when the row has one. Open the row; do not read the badge. This is a display bug in the list column, not a sign the migration mis-converted your data — verify with SHOW COLUMNS above.

When it goes wrong

[WARN] 'ecosystem_utxo.status' is '<type>', not boolean or enum — leaving untouched for manual review. The script deliberately refuses to guess. Someone has altered the column outside the product. Decide what the values mean, convert them to UNSPENT / LOCKED / SPENT by hand, then set the column to ENUM('UNSPENT','LOCKED','SPENT') NOT NULL DEFAULT 'UNSPENT' and re-run to confirm it reports a skip.

Re-run it. The conversion's recovery is keyed on whether the temporary status_backup column still exists, not on the column's current type, so the script resumes correctly from the tinyint, the VARCHAR intermediate or the finished ENUM. It reports [FIX] rather than [ALTER] when it is recovering.

That is the schema sync trying to create one of the new unique indexes over rows that are still duplicated — almost always kyc_application or nft_sale. You ran pnpm updator before the :before step. Bring the site back up on the current code with pnpm start, run pnpm db:migrate:6.4.9:before:apply, then run pnpm updator again.

Check SCYLLA_HOST — see the warning above. The script does not read SCYLLA_CONNECT_POINTS, so a multi-node or remote setup that the platform connects to perfectly well will send this script to localhost.

The recovery pass runs at boot on the process that schedules — the cron PM2 app, not the web backend. It is invoked from the scheduler's own startup, so a web process running CRON_MODE=off never runs it, which is the default. Read pm2 logs cron for WITHDRAW lines; pm2 logs backend will not have them. If the cron app is stopped or crash-looping, neither the boot pass nor the two scheduled recovery jobs are running at all — ecosystemWithdrawRecon every 5 minutes and processPendingEcoWithdrawals every 30, both described in Operations.