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.
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
.envoff 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
PROCESSINGUTXO 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
-
Back up. MySQL, ScyllaDB,
.env. The UTXO conversion has no reverse script. -
Stop the platform.
pnpm stopThis puts the maintenance server on the frontend and backend ports and removes the
backend,frontendandcronPM2 apps. Port 4001 is left free on purpose — the migration boot later in the chain needs it. -
Preview the MySQL migrations. Read the report before you write anything.
pnpm db:migrate:6.4.9:beforeEvery line is prefixed with what it would do:
[ALTER],[ADD],[CREATE],[SKIP]or[WARN]. A run that endsSchema already up to date — nothing to change.means this install has had them applied before. -
Apply them.
pnpm db:migrate:6.4.9:before:apply -
Run the normal update chain. This is where the Sequelize schema sync, the seeders and the frontend rebuild happen.
pnpm updator -
Apply the ScyllaDB migration. It is not in any
pnpmscript, 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 -
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 cronA default deployment runs
backend/dist/index.jsas two PM2 apps —backendwithCRON_MODE=offandcronwithCRON_MODE=onlyon port 4001, both defined inproduction.config.js.pm2 restart backendalone 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 restartrestarts 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'plusecosystem_utxo.lockedTxId(VARCHAR(191)), written in the same committed transaction as the broadcast hash.lockedTxIdcarries the broadcast txid that reserved the input, so promotingLOCKEDtoSPENTis 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_lockedonecosystem_utxo (status, walletId)— the scan ofLOCKEDoutputs during recovery and validation.idx_status_trxid_recoveryontransaction (status, trxId, createdAt)— the scan for broadcastPROCESSINGrows 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/srcchanges nothing on a production box. The running process is loadingdist. A source-only patch — a hotfix pasted into a.tsfile, a merge that touched onlysrc— is invisible untildistis rebuilt withpnpm bundle. - A release replaces
distwholesale. Any local change you made to it is gone.updator:migraterefuses to start and halts the update ifbackend/dist/index.jsis missing, telling you to restore it or runpnpm 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.
Related
- Installing Ecosystem — ScyllaDB, the vault key, first boot
- Updating — the core update chain, link by link
- Operations — the repair scripts and what backups do not cover
- Environment reference — every variable the addon reads
- Troubleshooting — symptom-first diagnosis