Bicrypto 6.6.3
11 August 2026
This release has upgrade notes. Read them before updating — they describe behaviour changes that need your attention.
Core v6.6.3
Release Date: August 11, 2026 Tags: SECURITY, PRIVACY, ADMIN, ACCOUNTS, PERMISSIONS, API-KEYS, TWO-FACTOR, RATE-LIMITS, PROXY, GEO, DEPOSITS, WITHDRAWALS, PAYMENTS, GATEWAYS, TRADING, FEES, WALLETS, RELIABILITY, BUG-FIXES, EXTENSIONS, PERFORMANCE, DATABASE, INDEXES, CACHING, ANALYTICS, KYC, WEBSOCKETS, CRON, NOTIFICATIONS, UPLOADS, SPOT, BINARY, INVESTMENT, STAKING, P2P, AFFILIATE, MIGRATION
Overview
A correctness release, and a large one. Every engine that touches money was audited by people briefed to assume it was wrong, every customer-facing figure was compared to the ledger behind it, and every gate that decides who may move money was tested by trying to walk around it. Two hundred and thirty-two findings came back, and the ones that moved money are fixed here — including a card gateway that could be credited for a payment nobody made, an admin delete that destroyed customer investments, and an API key that could withdraw without holding the permission to.
Almost nothing here is cosmetic and several items need you to go and look at your own records afterwards. Read Upgrade Notes before you update — it is ordered with the money first — and set aside time for the one-off data repairs at the end of it.
Alongside that: two-factor recovery codes are no longer stored in readable form, the whole site no longer shares one rate-limit allowance behind a reverse proxy, a performance pass makes the backend answer the same questions with far less work, and every icon in the product now ships with the installation instead of being fetched from a third party as each page loads.
Update Instructions
Download Core and every add-on you own first, then build once at the end.
pnpm updator
pnpm db:migrate:6.6.3 # reports what it would change
pnpm db:migrate:6.6.3:apply # writes itTake Core's download first and each add-on's after it — an add-on ahead of Core will not load its routes — but run pnpm updator once, at the very end, when every download is in place. It stops the platform, installs dependencies, migrates, seeds, rebuilds the frontend and starts up again, and that whole sequence has to see the finished tree. Running it between add-ons rebuilds against a half-updated install, costs you the build time again for each one, and leaves the site down for the sum of them.
Nineteen add-on releases are dated alongside this one and every one of them depends on Core v6.6.3: Ecosystem, Futures Trading, P2P Trading, Staking, ICO Launchpad, Forex Investment, AI Investments, MLM & Affiliate System, E-commerce, FAQ & Knowledge Base, NFT Marketplace, Copy Trading, AI Market Maker, AI Support Agent, Payment Gateway, Algo Trading Bots, Binary Trading AI Engine, Forex & Multi-Asset Trading and Hummingbot Connector.
The two migration lines are the 6.6.3 data repairs, and they are not run by pnpm updator — they are deliberately yours to time. The first prints what it would change and writes nothing; run it, read it, then run the second. Neither alters your schema, both are safe to run twice, and what they repair is described under Upgrade Notes.
Check your market fee rates afterwards — see Upgrade Notes.
If you run Apache, one line goes in your vhost and one module gets enabled. Both are in Upgrade Notes, and without them your visitors keep sharing a single rate-limit allowance. The full corrected vhost is at mashdiv.com/docs/bicrypto/install/apache — it has been rewritten for this release, so compare it against yours rather than adding the one line blind. The nginx equivalent is at mashdiv.com/docs/bicrypto/install/nginx.
The first start after this update builds six database indexes, so on a large installation it takes longer than usual. Let it finish.
Highlights
What an audit of every money engine found
Earlier in this release the futures engine was read by a team briefed to assume the arithmetic was wrong and to prove it. It found nine defects, so the same treatment was given to every other engine that holds customer funds: fifteen areas, four separate lines of attack each, sixty independent readers, and every finding put to two sceptics who had not made it. Two hundred and thirty-two findings came back, a hundred and seventy-six of them distinct.
Then the fixes were audited. Fourteen people wrote them and fourteen more read the resulting changes rather than the reports, and that stage returned seven repairs that closed one defect and opened another — one of them would have refused every trading bot's exit order, including its stop-loss, and another added a confirmation step that no screen in the product could answer. Twelve findings were shown to be wrong before anything was changed at all. That review pass is the reason a change of this size is in a release note rather than in a hotfix a week later.
One shape repeats through nearly all of it, and it says where to look next: the arithmetic that decides money lived in files that cannot be loaded without a database and a live wallet service, so nothing ever tested it. Every repair here lifted the decision out — how much of a payment is still refundable, what a losing contract owes, which slice of a partly-filled order has not been paid for yet — into something that can be checked with two numbers and no infrastructure.
Why one wrong fee looked like three different bugs
The report that started this was a trader insisting the platform charged makers three times what it charged takers. It did not, and it never had. Three separate screens were each getting the same underlying fact wrong in a different way, which is why the story changed every time more evidence arrived.
A market order fills in one shot; a resting order fills in pieces. Every symptom that looked like "makers are charged more" was really "orders that fill in pieces are displayed wrong" — the panel's arithmetic happened to come out right whenever an order filled completely, which is every market order and almost no resting one. A market order that fills in pieces against a thin book was affected exactly as badly, and a maker order that filled in one go was not affected at all.
Underneath that, the Est. Fee line named a rate that was not the market's — in two different ways. On the Pro terminal it came from a table written into the page and never touched the market at all: 0.100% on every spot and ecosystem market, a tenth of what a market left on the platform's default charges. On the standard ticket it did read the market, but a resting limit order was always quoted the taker rate, and a market publishing no rates of its own fell back to that same 0.100% constant. And in wallet history, the row recording a fill was classified by a label that two different operations both write, one meaning funds arrived and the other meaning funds left. None of them touched a balance; all of them told the customer something untrue about one.
The same answers, computed with far less work
Separately from the fee work, the backend was measured and then read. Each finding was checked back against the code before anything was written, and each edit was reviewed for behaviour change before it shipped — because a performance pass that quietly moves a number is worse than a slow platform.
Almost all of it turned out to be three shapes repeating. Counting in memory what the database can count: the KYC dashboards read every application row — each carrying the applicant's stored form data — to produce four totals. One query per row where one query would do: the withdraw currency picker asked once per wallet, the daily profit-and-loss job asked twice per customer, the exchange fee report scanned the whole market list once per order. Repeating work that had already been done: the deposit-address screen downloaded an exchange's entire currency catalogue three times in one request, the spot deposit watcher re-tested its credentials on every tick, and the notifications socket re-read a customer's whole history on every inbound frame.
So this is twenty-three changes and six database indexes, and with a single exception every one of them puts the same figures on the same screens. The exception is a real defect found on the way: a customer holding open orders on more than one market could stop receiving live updates on some of them. That one is under Fixed; the rest of the pass is under Changed, because nothing else changed an answer.
"Too many accounts" was never about accounts
An operator created a staff account, could not get into the admin panel with it, deleted it, and tried again — at which point registration stopped working for everybody, with a message about too many accounts from their address. Three unrelated faults were stacked behind that one sentence, and each hid the next.
The admin panel refused them because a role that is created rather than seeded holds no permissions at all, and nothing in the product ever grants any. The screen they got said "You need to be authenticated to access this area" — which was false; they were signed in, and the only button on the page was Sign in. That wording is what made deleting the account look like a reasonable next step.
Deleting it did not free the email address, because customer deletion keeps the row. Re-registering therefore hit the database's uniqueness constraint and came back as a server error naming a SQL index. And the signup allowance — three per hour, deliberately strict, deliberately refusing rather than allowing when it cannot check — was counting attempts rather than accounts, so three failures closed the door for an hour.
Then the last one: behind a reverse proxy, every visitor arrives wearing the proxy's address. Three per hour for the whole installation. The product's own documentation said to fix that by setting TRUST_PROXY, and on the deployment the documentation calls the default — Apache — that setting made the address forgeable, because Apache appends the real client to whatever the caller sent and the platform read the first entry rather than the last. The install guide for Apache had never carried a line about it.
So: the address is now worked out from the connection rather than from a setting, the list is read from the end a proxy writes rather than the end a caller writes, and there is nothing to configure for a proxy on the same machine. Four other places that were each answering "who is this?" their own way — including a payment API-key allowlist and the address written into a customer's own activity log — now ask the same code as the rate limiter.
Every icon on the page came from somebody else's server
141 files drew their icons through a component that resolves a glyph by fetching its set from a third-party CDN when the page loads. Three things followed from that, and none of them were anybody's decision.
An operator behind a firewall served pages with no icons at all — and could not fix it from their own egress rules, because the request is made by each visitor's browser, not by their server. Every one of those visitors contacted a company the operator had never chosen, on every page. And it was the slow path regardless: nothing could begin loading until the page had downloaded and started up, and only then a cold connection to an origin that cannot share the one already open, so every glyph arrived after the page around it. The clearest case was the button spinner — the one control whose entire job is to say this is working waited on a network round trip to appear, and behind a firewall never appeared, leaving the button looking dead.
The tell that this needed fixing at the root rather than one screen at a time: three files in the product had already worked it out and could only fix themselves. One avoided the component because a glyph first seen there "costs an extra round trip to an external CDN"; another had grown its own 198-entry translation table so admin metric tiles would stop all drawing the same symbol; a third mapped names from "icon sets this app no longer bundles". Three authors saw the whole problem, and each could only reach their own file, while 141 others drifted the other way. Nothing ever failed, so nothing ever stopped it.
Every glyph is now part of the installation, drawn from the page itself with no request to anyone. The brand marks were already sitting in the installation for the footer, so those are drawn from the same files rather than a second copy.
Upgrade Notes
A card gateway could be credited for a payment nobody made
The PayFast deposit route credited the amount the caller supplied, and it only verified the provider's signature when a signature happened to be present and a passphrase happened to be configured. Omitting the field skipped verification entirely.
Nobody had to know that. The shipped deposit screen fired the completion call when the PayFast popup merely closed, announcing the payment as complete — so a customer who opened PayFast, changed their mind and shut the window was credited in full. The route now refuses to run without a passphrase, demands a valid signature, asks PayFast itself whether the payment happened, and credits the figure the platform recorded rather than the one it was sent.
- If you have ever had PayFast enabled, reconcile your PayFast deposits against your PayFast account. Any deposit credited without a matching settlement was created this way.
- Three more deposit doors were repaired in the same pass. Paystack could credit one payment twice — the status check and the webhook each recorded the credit under a different marker, so a customer whose browser polled and whose webhook arrived was credited for both. Adyen credited every deposit as USD whatever the customer paid, on any installation running MariaDB. PayPal captured the money and then failed to credit it, because the credit was filed under a reference the pending record already held.
- Reconcile Paystack deposits for duplicates, and Adyen deposits for currency. A PayPal deposit that failed this way took the money and left the record pending; those need crediting by hand.
Deleting an investment plan or duration destroyed the investments under it
The investment table cascades to both its plan and its duration at the database level, and Core's four plan and duration delete doors — single and bulk of each — had no precondition of any kind. Deleting one plan or one duration therefore physically removed every investment referencing it — including active ones holding customer principal. No refund, no transaction row, nothing left to find. The model marks investments as recoverable on delete, which made this look safe; the cascade is executed by the database, which does not know about that.
A second route to the same loss went through the guard rather than around it.
Deleting with ?force=1 — or force=no, or force=false — was graded by the
guard as the reversible kind of delete and then executed as the permanent kind.
A settled investment is not active, so it passed the check, and the database
then removed it: the platform's only record that it had paid that customer,
gone, by one character in a URL.
- All four now refuse: a permanent delete while any investment depends on the row, a recoverable one while any active investment does.
- If plans or durations have been deleted on your installation, check your investment history for gaps. Compare the number of investment records against the payouts in your transaction ledger. There is no way to recover rows a cascade removed except from a backup.
- The admin table itself only ever sent the well-formed spelling, so this needed either a hand-made request or the delete of a plan that still had customers on it.
- The same cascade sits under the add-ons that keep their own plans and durations. Each carries the repair for its own tables, and each names the records you have to go and check: AI Investment v6.1.6 and Forex Investment v6.1.8. Neither is covered by the check above, because their investments live in their own tables.
Approving and rejecting a withdrawal were both broken, in different directions
Approving a spot withdrawal failed on every attempt. That route read the withdrawal's stored details in a way that silently produced nothing, then looked for a wallet it had no currency for and answered "Wallet not found". It is the only door that pays a spot withdrawal out, so those requests sat PENDING with the customer's money already debited and no way to release it.
Rejecting from the wallet screen failed on every attempt too, with an internal error, because it wrote the withdrawal's note in a form the column will not take. The customer's withdrawal stayed pending with the money already debited and nothing was returned. Behind that failure sat a second one, repaired in the same pass: the refund was filed under a marker naming the wallet rather than the withdrawal, so the second rejection on any one wallet would have been skipped as a duplicate — the record reading rejected, the balance never moving, and the operator shown a message about a duplicate operation.
Rejecting from the withdrawal log returned less than was taken. That screen did work, and refunded the amount without the fee, while the wallet had been debited both. A 50.00 withdrawal on a 2% + 1.50 method returned 47.50. The platform did not keep the 2.50 either — fees are only booked on a completed withdrawal — so it left the ledger entirely.
Rejecting could refund something already paid out. Withdrawals in flight were rejectable, and rejecting one refunded a payout that had already left. The same shape existed in the spot route's own error handling, which could not tell "the exchange refused" from "the exchange paid and a follow-up call failed", and in the TransFi payout path, where a failed payout left the money debited, no refund written, and the record stamped as though nothing needed doing.
- Re-check every withdrawal you rejected from the withdrawal log against the customer's balance. Each was short by exactly its fee.
- Check for withdrawals sitting PENDING with the balance already taken, whether the operator was trying to approve or to reject. Both doors work now.
- A rejection now refuses rather than refunding whenever there is any signal that the payout may have been dispatched. Refusing something that never left is a minute of an operator's time; refunding something that did is not recoverable.
An API key could move money without holding the permission to
A gateway key can be minted through the product's own scope selector, and the check that enforces those scopes sat behind an earlier condition that no finance, exchange, futures or ecosystem-order route satisfies. On every route that moves money the check was unreachable. A key created with nothing ticked could therefore post a withdrawal, and because bot credentials are rows in the same table, an Algo Trading Bots key was a withdrawal key too.
Two more doors made it worse: a leaked read-only key could create a second key asking for any scopes it liked, or grant those scopes to itself.
- Money routes are now refused by family unless the key holds the matching scope, and a money route nobody remembered to map is refused rather than allowed. Read access is unchanged, so integrations that only read balances keep working.
- Creating or widening an API key now requires a signed-in session and refuses an API-key caller outright.
- Review the API keys on your installation and the scopes they hold, under the customer's own API keys screen. A key that was created before this release holds whatever it was ticked with, and until now that meant nothing about what it could reach.
- A password reset now disables that account's API keys as well as its sessions. A key used to survive the reset, so an attacker who had minted one kept access at the exact moment the customer believed they had taken the account back. A customer who resets a forgotten password must recreate any keys their bots use.
Affiliate commissions were computed on a number with no unit
Every referral engine on the platform multiplied the reward percentage by the activity's amount and paid the result in the condition's reward currency, with nothing in between. A 10% condition paying USDT therefore paid 100 USDT on a 1,000 JPY deposit worth about six dollars, and the same 100 USDT on a 1,000 USDT deposit. The activity amount is now expressed in the reward currency before the percentage is applied. Core carries the rate source and the reward calculation both engines use; the periodic evaluator that reads them ships in Multi Level Marketing v6.1.8, so update both.
- This changes nothing where a condition pays in the same currency the activity is denominated in — that path short-circuits before any conversion. Where they differ, your commissions have been wrong by whatever the exchange rate is.
- Where the conversion cannot be established the reward is now skipped and logged rather than paid on the raw figure. Paying it unconverted is how a six-dollar deposit earned a hundred-dollar commission.
- If you run conditions whose reward currency differs from the activity they pay on, review the commissions already in your affiliate ledger. The seeded e-commerce conditions ship paying USD while trading activity is denominated in USDT, so this is the default arrangement rather than an exotic one.
- Rewards were also being paid to the wrong party on one setting: on a DIRECT structure, a customer who sat at the top of the tree — or who had simply opened the affiliate network screen once — earned the full condition rate on their own activity. That row exists to give the tree a root; nobody referred anybody. Core's engine has always refused those rows; the evaluator that did not is repaired in Multi Level Marketing v6.1.8, and the commissions to review are the ones it wrote.
The order ticket quoted a rate that was not the market's
The Est. Fee line on the Pro terminal's order ticket read a rate table written into the page rather than the market's own settings: a fixed 0.100% on every spot and ecosystem market, regardless of what that market charges. A market left on the platform's default charges 1% — ten times the figure on the ticket. The standard spot ticket did read the market's published rates, but quoted the taker rate on every limit order and fell back to that same 0.100% constant on a market carrying no rates of its own. The matching engine always charged the market's own configured rate, so no customer was over- or under-charged; the ticket named a different number than the one being applied. Both tickets now read the market's own maker and taker rates.
- Review the maker and taker rates on your ecosystem and spot markets. If you ever set them, or left them, believing the ticket's 0.100%, that is not what your customers have been paying.
Wallet history counted a filled sell order as money arriving
A sale removes coins from the seller. Transaction History recorded that removal with a plus sign, and paired with the reservation taken when the order was placed, a completed sale added up to zero — as though the seller still held everything they had sold. The repair is under Fixed.
- Rows written before this release carry no record of which operation produced them, so they are shown without a sign rather than guessed at. Only trades from this release forward are signed.
- Re-check any figure you calculated by adding up Transaction History for a trading account. Sales were counted with the wrong sign.
An administrator who could create users could promote themselves
Changing an existing customer's role has always been Super-Admin-only. Creating a new account was not held to the same rule, and the refusal it did carry never fired: the check compared a number from the database against text from the browser, which can never match. Any administrator holding nothing but create user could therefore make themselves a Super Admin — the one role that bypasses every permission check on the platform — read its temporary password out of the success message, which the screen prints so you can pass it on, and sign in. The CSV importer was the same door with the password chosen in the file rather than generated.
Both now apply one rule: you may create an account in any role whose permissions are a subset of your own. A Super Admin is exempt; the Super Admin role is still refused to everybody. Creating ordinary customers, which is what the permission is for, is unaffected.
- Review the accounts your delegated administrators have created, and check whether any sits in a role above theirs. The role picker offered every role, so this needed no special knowledge to do by accident.
- Check who holds
create.userandimport.userunder Users → Roles & Permissions.
Apache and nginx no longer need TRUST_PROXY — and Apache needs one new line
Behind a reverse proxy the address the platform sees on the socket is the proxy's, so every visitor shared one rate-limit allowance and geo rules evaluated everyone as the server itself. The documented fix was TRUST_PROXY="true", and on Apache that made things worse rather than better: Apache appends the connecting address to whatever X-Forwarded-For the caller sent, the platform read the first entry, and so a caller could name their own address — and with it their own rate-limit allowance and their own country.
The address is now derived from the connection. A request that arrives from this machine is one your proxy made, and its forwarding header is honoured with nothing configured. The header is read from the last entry rather than the first, so a caller who prepends their own value is ignored even on a proxy that appends.
-
Remove
TRUST_PROXYfrom.envif your proxy runs on the same machine as the platform. It is no longer needed, and left at"true"it is actively worse: it tells the platform to believe a forwarding header from any caller, including one who reaches the API port directly. The API port binds every interface and nothing in the installer firewalls it. -
Apache: enable one module and add one line to every
<VirtualHost>, above theProxyPassdirectives. Without it the header still works, but your proxy passes the caller's copy through:RequestHeader unset X-Forwarded-Fora2enmod headers && systemctl restart apache2 -
nginx: the directive is
proxy_set_header X-Forwarded-For $remote_addr;— not$proxy_add_x_forwarded_for, which appends. Both install guides have been corrected. -
If your proxy or load balancer is on a different host, name its network in the new
TRUST_PROXY_CIDRSinstead of enabling blanket trust:TRUST_PROXY_CIDRS="10.0.0.0/8". This is also what unlocks the CDN headers (CF-Connecting-IPand friends), which are ignored by default because Apache and nginx forward them straight through from the caller. -
If the platform has been metering everyone as one address, it now says so in the log rather than waiting to be asked — it watches the addresses it actually resolves and warns when nearly all of them are the same one.
Demo installations could write to the NFT marketplace
Only affects installations running with NEXT_PUBLIC_DEMO_STATUS="true", where every visitor who signs up is given the Admin role so they can browse the admin panel before buying. The block that keeps them read-only matched on the address of the page rather than on the permission it required, and six marketplace routes carry an administrator permission while living outside the admin section — deploy, pause, unpause, whitelist, configuration and withdraw.
- A demo visitor could pause your marketplace or move funds out of it. The block now matches the permission, so those routes are refused like every other administrator write.
- If you run a public demo, check your marketplace balance and its paused state.
The first start after updating takes longer
Six database indexes ship with this release, covering the reads that had none: the row count behind admin lists, a customer's own transaction history, the sweep that reconciles open spot orders, and the notification feed. They are created automatically the first time the backend starts after the update.
- On a large installation — millions of transactions — that first start takes noticeably longer than usual. It is one-off, and there is nothing to run by hand.
- Let it finish rather than restarting into it.
A fresh install also now imports the futures insurance ledger table. It existed only in the models, so on any deployment created from the shipped schema the futures insurance fund would have had nowhere to write and the first seeder to touch it would have stopped the run.
Lists now return at most 1,000 rows per page
The admin tables ask for at most 96 rows a page, and the largest thing the platform itself asks for is 500. The page-size parameter behind them was never bounded, so a request naming a million was honoured: one worker read an entire table into memory to answer it, on a connection pool shared with every other request.
- The largest page any paged endpoint will return is now 1,000 rows. A request for more is served 1,000, and a page number below one is treated as the first page rather than passed through to the database as a negative offset.
- If you have a script or integration that pulls a whole table in one request, page through it instead. Nothing in the product's own screens is affected — none of them asks for more than 500.
Run the 6.6.3 data migration once, at your convenience
New two-factor enrollments store their recovery codes hashed the moment you update. The codes already in your database do not change by themselves.
pnpm db:migrate:6.6.3 # report — writes nothing
pnpm db:migrate:6.6.3:apply # convert- This changes no schema and needs no downtime. Run it with the platform up, before or after the first start, in whatever order suits you.
- Skipping it locks nobody out. The sign-in path reads both the old and the new form, and re-hashes whatever is left the first time a customer redeems a code. But a recovery code is redeemed roughly once in an account's lifetime, so the plain-text codes stay in your database until you run this.
- It is safe to run twice — the second run reports nothing to do.
- If you keep database backups taken before this release, they still contain the plain-text codes. Treat them accordingly.
The same command repairs three more things, and one of them is customer data your screens cannot currently show.
Customer profile data the application could not see. The profile record — biography, location, address, the note of a verified phone number — was being saved wrapped in an extra layer of encoding, and the reader unwraps exactly one layer, so everything asking for it got nothing back. The data sat intact in the database and invisible on every screen. Measured on the installation it was found on: fifteen of seventy-nine profiles affected, eleven of them carrying real content, including customer accounts and the operator's own. This is the repair to run promptly; the others can wait for a quiet hour.
The fee and limit settings on your ecosystem tokens, saved the same way. Nothing has been charged incorrectly, because the reader for those two unwraps up to two layers. A third layer would make the withdrawal fee read as not configured and charge zero, and re-importing a token could add one.
Wallet deposit-address maps and fifteen other structured columns — payment-method custom fields, support ticket bodies, NFT metadata. Nothing charged wrongly and no balance moved: these readers also unwrap the extra layer, which is exactly why it went unnoticed for so long. What sees the difference is anything reading those rows without going through the platform — a report, an export, a direct query — which gets a block of text where it expects a value. The code that writes them is fixed here, so new rows are stored correctly from now on; this repairs what is already there.
- Run the repairs after the update, not before. The code change and the repair belong together, and the repair assumes the fixed writers are already in place.
- Every one of them reports before it writes, refuses any value it does not recognise, leaves a row that is already correct untouched, and is safe to run twice.
An icon name you typed yourself may now draw a stand-in
Icons used to be fetched from a third-party service, which meant any name from any of its collections drew something. They are now part of the installation, so the product knows roughly 1,300 names — every one in use anywhere in Core and the add-ons, plus the plain and PascalCase spellings of each.
A name outside that catalogue can no longer be fetched into existence. It draws the stand-in for its position instead. This only affects a name an operator typed in by hand, and the one place the product invites that is a P2P payment-method rail, where the icon field is free text.
- If you set custom icon names on P2P payment methods, look at that list once after updating. A rail whose name is not recognised now shows a generic wallet glyph rather than nothing, which is an improvement on the previous failure, but it is not the icon you chose.
- Rails where you uploaded a logo image are unaffected — those were never icon names.
- Everything shipped with the product was checked and converted, including the icons stored against menu entries and admin metric tiles, so nothing that came from us falls back.
Added
The shared cleanup step add-ons build on
- Added a single cleanup helper that rolls a transaction back only when one is still open. The old pattern — cleaning up unconditionally in an error handler — fails whenever the transaction has already been committed or already been cleaned up, and that failure replaces the real error before anyone sees it. Core's own error handlers are on the helper now, the shared save path behind the admin screens among them; the ones that still roll back directly already swallow their own failure, so they cannot mask the error that reached them. It is exported, and most of the sweep is on the add-on side of the install — roughly ten times as many places as Core has — so each add-on's release names the screens and jobs where an operator will actually see the difference.
Indexes for the reads that had none
- Added six database indexes: two on transactions, and one each on users, wallets, spot orders and notifications. They cover the columns those tables were being filtered and sorted on with nothing to seek against — a customer's transactions newest-first, the reconciler's sweep for open orders, one customer's notification feed. They are created automatically on the first start after updating; see Upgrade Notes.
TRUST_PROXY_CIDRS, for a proxy that is not on this machine
- Added
TRUST_PROXY_CIDRS— a comma-separated list of addresses or networks whose requests may carry a forwarding header, for a load balancer or CDN on a different host. It grants that trust to those networks and to nothing else, whichTRUST_PROXY="true"does not: that setting believes any caller, including one who reaches the API port directly. Setting it is also what enablesCF-Connecting-IP,True-Client-IPandX-Real-IP, which are otherwise ignored — Apache and nginx forward them from the caller untouched, so on an installation with no CDN in front they are just something anyone can type.
A way back in when a role holds no permissions
- Added a
--grantmode to the permissions command, for the case where the only administrator sits on a role that authorises nothing and therefore cannot reach the screen that would fix it.pnpm sync:permissions -- --grant "Admin"reports what the role is missing and--applyhands it over. Naming a role that does not exist prints the ones that do, with their ids. It is deliberately not a button anywhere in the product: something that gives a role every permission on the platform should require access to the server, not a session.
A detector for the whole site sharing one rate-limit allowance
- Added a check that watches the addresses actually being resolved and warns once when nearly all of them are the same one — naming that address, and saying whether the cause is a proxy sending no forwarding header or one the platform does not recognise. The previous warning tested a setting rather than the traffic, so it stayed silent in the one case where the visitor's address is genuinely unrecoverable.
A page for one wallet, with its balance ledger on it
Wallet Management could adjust a balance but not audit the wallet it had just adjusted. The balance ledger — the append-only record of every credit, debit, hold and release, carrying the balance either side of it — was reachable only from a customer's record and from the deposit, withdrawal and transfer detail pages. So the operator answering "my balance is wrong" corrected the wallet on one screen and then went looking for the owning customer on another to read back what their own click had done.
- Added a wallet record page, opened with View on any row of Wallet Management. It carries the wallet's available, held and total balances; an Overview tab with the owner and a link through to their customer record, the wallet and user identifiers, and the per-network deposit addresses for ecosystem wallets; a Transactions tab; and an Audit Trail tab holding the staff actions taken against that wallet above the balance ledger for it. Adjust balance and Enable/Disable wallet sit in its header and behave exactly as they do from the table, so a correction and the ledger row it writes are now on one screen. The page opens on the same permission as the wallet list; both controls still require edit wallet, and an operator without it sees no buttons rather than disabled ones.
Closing an account now says what will happen to the money in it
Account deletion had no precondition of any kind. The account was deleted the moment the password verified — and because customer records are kept rather than erased, what that produced was a stamped-out account whose wallets, transactions and KYC documents were all still present and referenced by nobody. The money did not go anywhere. It stopped being reachable by the person who owned it.
- Added a preflight the deletion screen reads before anything destructive happens, listing what blocks the closure, what will be kept, and what is held. Open orders block it outright, because the customer can always close those themselves. A held balance does not block it — dust below the withdrawal minimum cannot be moved and a payout in flight is your desk's to finish, so blocking on either would make closure permanently impossible for some accounts — but the customer must acknowledge the figure before the request is accepted. The emailed confirmation link enforces the same refusal, since a guard on one of two doors is a detour rather than a guard.
- Added a public account-deletion page for somebody who has already removed the app and cannot reach the in-app screen. It states plainly which records are retained after closure, because they are.
The module catalogue an app or integration can ask about
- Added an endpoint that answers which parts of the platform are actually usable for the signed-in customer, graded by licence, by role, by KYC level and by the operational switch that turns the feature on. Before this, a client had to guess: it would draw a full order ticket for a module the server refuses, and the customer found out at the button that moves money. Where a module needs another extension to work — futures needs Ecosystem for its wallet ledger, and every futures route refuses without it — the catalogue now says so instead of reporting the module as available.
Explicit token refresh, and a device registry for push
- Added a token-refresh endpoint. The platform used to refresh tokens only as a side effect of a request, and only wrote the new token back on the sign-in routes — so a client that refreshed while reading anything else never learned its token had changed and re-presented the dead one for ever. A client that correctly discarded an expired token was refused outright before the refresh logic could run, which meant the only working strategy was to keep sending the expired token.
- Added a device registry, so a handset that reopens the app updates one record instead of appending another. Registering a device now switches push on for that customer when nobody has expressed a preference — before, a device could register perfectly and receive nothing, reported as blocked by the customer's own settings.
- Added server-resolved destinations to notifications, on the push payload and on the notification list alike, so a tap lands in the same place either way.
A rate-limit bypass for a test run, with four locks on it
- Added an opt-in way for an automated test run to say it is a test run, because the honest alternative — waiting out the login limiter — turns an hour of testing into an hour of sleeping. It is refused outright when
NODE_ENVisproduction, with no setting that overrides that; it does not exist unlessE2E_RATE_LIMIT_BYPASS_TOKENis configured; the token must be at least 32 characters and is refused loudly otherwise; and it applies per request, so a bypass left switched on does not open the door to anybody who has not got the token. A bypassed request does not spend the window, so a test run cannot exhaust the allowance for real visitors sharing an address, and its use is logged on first use and every hundredth.
Changed
Which of the two rates the ticket names
- Changed the ticket to say whether an order will pay maker or taker: one that would rest on the book quotes maker, one that would cross the spread quotes taker. The limit ticket had been quoting the taker rate for every order, including the resting ones that are the whole reason two rates exist.
The 100% button on a buy
- Changed it to reserve the market's real fee. A buy reserves cost plus fee, so on any market charging more than the constant the button had assumed, a full-balance buy asked for more than it reserved and was rejected as insufficient balance.
How an admin list counts its rows
Every paged list asks two questions: which rows to show, and how many there are altogether. The counting question was being handed the whole display join tree — every table joined in purely to render a row was joined again to count it, and the count was wrapped in a de-duplication pass whether or not any join could produce a duplicate.
- Changed the count to join only what can change the answer: the joins that filter, and any the search or filter panel actually names. De-duplication now runs only when a surviving join can multiply rows.
- Changed the two questions to be asked at the same time rather than one after the other.
- Changed page size and page number to be bounded — see Upgrade Notes.
The KYC dashboards
- Changed the level and application analytics to count in the database. Both read every application row into memory to produce a handful of totals, and each of those rows carries the applicant's full submitted form data — so the heaviest read on the admin was a dashboard nobody scrolls past. Every figure is unchanged, including the quirk where a customer approved at three levels counts three times among verified users, and a level with no applications at all still reports a rate of zero rather than dropping off the chart.
- Changed average processing time to be averaged by the database rather than accumulated row by row. Same value, same unit.
The blog dashboard
- Changed its eleven counts and lists to run together rather than in a queue. None of them depends on another's result; they were simply waiting their turn.
The exchange fee report
- Changed it to look each order's market up directly instead of scanning the exchange's entire market list once per order in the report. Where a venue lists the same symbol twice the first entry still wins, as before — the alternative would flip which of the maker and taker rates is billed.
Getting a deposit address
- Changed the deposit-address screen to download the exchange's currency catalogue once per request. It was downloaded three times, two of them discarded. A failed download is not remembered, so the next attempt still tries.
Choosing a currency to withdraw
- Changed the withdraw picker to check every one of a customer's wallets against the enabled-currency list in one query rather than one per wallet — a customer with thirty wallets issued thirty queries to draw one dropdown. It now also reads only the columns the list needs, instead of pulling each wallet's stored deposit addresses to display a balance.
Watching for a spot deposit
- Changed the deposit watcher to remember for five minutes that the exchange accepted its credentials, rather than re-testing them on every tick of the poll. A failed test is never remembered, and any authentication failure while fetching deposits clears the memory immediately, so a key that dies mid-window is caught on the next tick.
Wallet lookups during money operations
- Changed the ecosystem wallet check to re-read the row only when it has just written an address to it. Every money operation touching an ecosystem wallet re-read the row it had already been handed, unchanged.
- Changed a wallet that already holds every active chain's address to stop opening a database transaction to prove it. That transaction held a pooled connection for the length of a call that provably writes nothing.
The spot order reconciler
- Changed the 60-second sweep for unsettled spot orders to select only the columns it reads.
- Changed it to normalise the order's stored details as it reads them. The sweep reads rows in a raw form that skips the model, and that column arrives as a value on MySQL and as text on MariaDB — so the sweep saw one shape on one operator's install and another shape on the next. It chooses between two settlement models from that column, so reading it as absent settles a held order the other way.
Binary option settlement
- Changed the 15-second settlement backstop to ask the database for orders that have actually expired, instead of loading every pending order and discarding the unexpired ones. Orders settle exactly as before and on the same schedule; the safety net just stops carrying the whole open book each time it runs.
The live ticker feed
- Changed the opening price snapshot to go to the customer who just subscribed rather than to everyone already connected. On a busy markets page every new visitor pushed a full snapshot at every other visitor.
- Changed the check that a ticker still belongs to a live market from a scan of the market list per ticker to a direct lookup. The list is re-read at most every five seconds, and immediately whenever a market is switched off, so a disabled market disappears from the feed as promptly as before.
The notifications channel
- Changed the user notifications socket to load its history on the opening subscription only. Every inbound frame on that channel re-read the customer's entire notification list and every active announcement; nothing in the browser ever asked for a resend.
The currency price refresh
- Changed the scheduled price update to write changed prices in batches of 500 rather than one save per currency, inside the same single transaction as before — so the whole refresh still lands or does not, exactly as it did. Each price is handed to the database as an exact value rather than through a floating-point round trip.
The daily profit-and-loss job
- Changed it to read a page of customers' wallets and today's records in two queries per page rather than two per customer. If that prefetch fails for any reason the job falls back to reading per customer, so no customer's figures can be skipped by it.
Serving uploaded images and files
- Changed the file server to stop compressing formats that are already compressed. Every JPEG, PNG, WebP, GIF, MP4, PDF and Office document served from your uploads folder was being gzipped on the way out — which saves close to nothing on a file that is already entropy-coded, and blocks the server from answering anything else for the duration: roughly 14ms for a 1MB file and 54ms for a 4MB one. Plain text, CSV and SVG are still compressed, because those genuinely shrink.
Work that every single request was repeating
- Changed the request validator to compile each route's rules once per process instead of once per request. Every API call was rebuilding the same validator from the same unchanging definition.
- Changed route matching to test a request only against routes registered under its own method. Each request was pattern-matched against every one of the platform's 1,800-plus routes; a GET no longer walks past the POSTs. Registration order still decides which route wins, so nothing that resolved before resolves differently.
- Changed authentication to reuse what it has already resolved inside one request — the session it just read, and the API key it just verified — instead of fetching each a second time later in the same request. Only successful resolutions are reused, and a mismatch falls back to a fresh read.
- Changed the settings and extension caches so a cold cache is filled once rather than once per waiting request. On a restart under load every request in flight found the cache empty and started its own load, against a connection pool of twenty-five. A load that fails is retried by the next caller rather than remembered.
Diagnostics on the live-data broadcast path
- Changed the broadcast layer to build its debug diagnostics only when debug logging is switched on, and to serialise a message only once a subscriber has actually matched it. It was assembling a per-client dump of every subscription — and serialising the payload — on routes with nobody listening.
The fiat deposit routes carried the same exposure
The PayU and PayFast deposit routes — both the browser return and the provider's own webhook — cleaned up unconditionally in their error handlers, the same shape as the shared save path described under Fixed. No case of it firing has been observed, and they are listed here rather than claimed as a repaired defect.
- Changed all four to the shared cleanup step, so a stumble after a deposit is recorded can no longer be reported as the deposit failing. A webhook that answers with an error is retried by the provider, so this is the path where a misreported success is most expensive.
Location lookups
- Changed the record the geographic lookup keeps of addresses it could not resolve to be bounded at 50,000 entries, with expired entries reclaimed as they are read. A scanner hits thousands of addresses that never resolve, and that record previously grew for as long as the process ran.
- Changed the Solana deposit scanner to remember which signatures it has already credited, so a background scan stops re-downloading the same two dozen transactions from the public network every couple of minutes. The scan a customer's own deposit screen triggers deliberately still replays them — that replay is what produces the deposit confirmed message when the credit landed while their page was closed.
What the signup allowance counts
- Changed the three-registrations-per-hour allowance to count accounts created rather than attempts made. Solve the bot challenge and fail for any other reason — an address already in use, a password that does not meet the policy — and the attempt no longer costs anything. Failing the challenge itself still does, because that is the outcome the allowance exists to price.
- Changed Google sign-in so it stops spending that allowance. Registration and sign-in share one route there, so a returning customer signing in for the fourth time in an hour was refused with a message about creating too many accounts.
- Changed the mobile sign-in route to carry the same login limiter the web one always had. It had none: its only ceiling was the platform's generic per-minute cap, roughly three times looser, with no lockout, against the same accounts — which made it the cheapest door on the platform to try stolen passwords against.
What the admin user form insists on
- Changed the fields required when creating or editing a customer to a name and an email address, and on creation a role. Phone, avatar, biography, address, city, country, postcode and the six social links are optional and can be left empty or cleared — a value you do enter is still checked, so "optional" means "may be blank" rather than "unchecked".
What the demo-mode write block matches on
- Changed the block that keeps demo visitors read-only to match the permission a route requires rather than the address of the page, so an administrator route is treated as one wherever it lives. Applies only to installations running with
NEXT_PUBLIC_DEMO_STATUS="true"; see Upgrade Notes.
One address resolver, exported for add-ons to call
- Changed the platform's address resolution into the single entry point every part of the install reads a caller's address from, and exported it. Add-ons had been keeping their own copies of that policy — each with its own environment variable, its own idea of which end of the forwarding chain to read, and no validation of what it found. Anything enforcing an IP allowlist or a per-address throttle now calls this one, so a policy set once in
TRUST_PROXY_CIDRSholds everywhere. The add-on-side repairs are in Payment Gateway v6.1.9 and Hummingbot Connector v6.0.7; existing add-on settings such asHB_TRUST_PROXYstill work and need no value on a new installation.
Every reward is now paid in the currency its condition names
- Changed Core's event-driven referral engine to convert the activity into the reward currency before applying the percentage, and to skip and log a reward it cannot price rather than paying the raw number. See Upgrade Notes — this changes what your commissions are worth wherever the two currencies differ.
- Changed the reward figure that engine writes to be rounded to what its currency can actually pay. A converted percentage produces a figure with more decimals than the currency has, and the claim credits the rounded amount — so the row promised 9.9904 while the wallet received 9.99, and the difference landed nowhere. A fixed reward is still stored exactly as you typed it.
- Changed the platform's conversion rates into the single source a referral engine may price against, and exported the reward calculation itself so there is one implementation of it. There had been a second, private set of rules that disagreed with this one in three ways — it ignored the live quote between the exchange's own unit and the dollar, it could not price the dollar itself, and it did not honour a currency being switched off — so the same referred purchase was worth a different commission depending on which engine paid it: 200 USDT valued at $200.00 by one and $199.81 by the other. Retiring that second set is a change inside the periodic evaluator, which ships in Multi Level Marketing v6.1.8.
Two forex jobs that reported a clean run without running
Two scheduled jobs — the one that reconciles broker fills and the one that watches hedge exposure — correctly decline to run on the scheduler process when a forex execution provider is switched on, because the forex desk already runs them every minute. They declined silently, and the admin cron table showed both as idle with no refusal recorded: indistinguishable from two jobs doing their work.
- Changed the refusal to be announced rather than logged at a level nothing prints, so the cron screen now names the job, says the work is being done elsewhere, and says what to check if you suspect otherwise. An operator who cannot tell that a reconciliation job is not running has no way to know their books are drifting.
"Run now" on a split deployment
Manual triggering was refused on any process that had handed cron off to another — which, on both the recommended production layout and the development one, is the process serving the admin panel. So there was no manual trigger at all, and the button answered that you should wait for the next scheduled run. On a daily job that is not an answer.
- Changed it to hand the request to the process that owns the scheduler. The run happens once, in one place, under the same single-flight guard it always had, and reports the same three outcomes. A run that takes longer than the reply window reports as started rather than as a failure.
- Changed four DEX scheduled jobs that were gated on the Forex Trading licence rather than their own. Switching Forex Trading off stopped the DEX swap-confirmation sweep and the fee settlement with it, silently, and owning the DEX add-on alone never started them.
Every icon is now served by your own installation
- Changed all 141 files that fetched their icons from a third-party CDN to draw them from the installation. Behind a firewall the product now has its icons; on an ordinary connection they are part of the page rather than something that arrives after it, so nothing pops in late or shifts the layout when it lands. No visitor's browser contacts anyone else for a glyph, which is a privacy question the operator never got to answer before.
- Changed the button spinner to a local glyph. It used to be fetched on demand, so the one control that exists to say a request is in flight waited on the network to appear — and on an installation that could not reach the service, a working button simply looked inert.
- Changed the brand marks — X, GitHub, LinkedIn, Facebook, Instagram, YouTube and Discord — to render from the image files already in the installation for the footer, so there is one copy of each rather than two sources that can disagree.
- Changed the three separate translation tables that had grown in different corners of the product into one, so the admin metric tiles, the P2P payment rails and the navigation now resolve a stored icon name the same way. Our release checks now fail on any icon name in the product that has nothing to draw, which is what stops the screens drifting apart again — an icon that goes missing is caught here rather than on your installation.
- Changed the dependency list: the third-party icon package is gone from the product entirely.
Fixed
A completed trade showed a fee that was too high, and changed on its own
The Trades panel is given one fee for the whole order and has to work out each fill's share of it. It divided by how much of the order had filled so far instead of by the order's size — and that figure grows with every fill. So each fill of a resting order was reported too high, and the rows already on screen shrank each time another fill arrived: the same completed trade, reporting a different fee minute to minute.
A resting sell of 0.001 BTC at 62,000 on a market charging 1% showed its first fill as 0.199736 USDT, then 0.151069, then 0.121471, against 0.100000 actually charged every time. Every wallet was debited correctly throughout, and no balance needs correcting; only the display was wrong.
- Fixed each fill being charged its share of the order rather than a share of what had filled so far. The figure shown is now the one taken from the wallet, and it stops moving once the trade is done.
- Fixed the same calculation in both the standard and Pro trade panels. It had been written twice and was wrong the same way in both; there is now one implementation, and it reproduces the figures above exactly.
A spot fill could be settled twice
Three separate pieces of the platform settle a spot order — the live poller watching the exchange, the reconciliation sweep, and the cancel handler — and each marks its work with a key so the others do not repeat it. The poller built its key from the exchange's identifier for the order while the other two built theirs from the platform's own. Different namespaces, so the keys never collided.
- Fixed all three settlement writers keying on the same identifier, so a fill the reconciler had already settled cannot be credited a second time by the poller. The platform fee on that fill was collected through the same mismatched key and could be taken twice with it.
A partly-filled order was settled for the whole of it
A limit order can be filled in part the instant it is placed. The platform settles that first slice immediately and holds only the remainder — but neither of the two writers that settle the rest read back what had already been paid for. When the remainder filled, both took the exchange's running total at face value.
A 1.0 BTC buy at 60,000 that filled 0.3 on placement therefore tried to take 60,000 USDT out of a hold worth 42,000 — consuming whatever other order's reservation happened to be in the same wallet, or failing and rolling the settlement back on every tick for ever — and credited the full 1.0 BTC on top of the 0.3 already delivered.
- Fixed both writers settling only the slice that has not been paid for, with the fee and cost prorated to that slice.
Open orders on more than one market could stop updating
The live order panel polls each market a customer has an open order on, then walks that customer's orders to see which have changed. It walked all of them against every market's result — so an order on one market was looked up at the exchange under a different market's symbol, which the exchange refuses.
That refusal was handled by dropping the market from the poll and every one of the customer's orders on it along with it. The order panel then simply stopped updating for that market until the customer reconnected. A customer trading a single market was never affected, which is why this could sit unreported.
- Fixed each market's poll being judged against that market's own orders, and an order being looked up under its own symbol.
- Changed the poll to ask once per market rather than once per open order. Ten open orders on one market issued ten identical requests to the exchange every five seconds.
The Open Orders and History tabs were empty on every ordinary spot market
The trade screen's orders panel asked the platform for a customer's orders without naming the market, and spelled the status filter with a word the endpoint does not accept. The endpoint requires both halves of the pair and refuses without them, so a trader holding resting orders read an error banner and no rows.
The ecosystem and futures versions of the same panel were both correct, and the Pro terminal — which most installations ship switched on — has its own panel that was correct too. That is how this survived: the tab worked for whoever looked at it.
- Fixed the request naming the market and spelling the filter the way the endpoint reads it. Open orders and order history both list again on ordinary spot markets.
An amount below what the currency can express was recorded as a completed movement
Every entry point on the wallet service checked the amount and then rounded it to the currency's precision. An amount smaller than the currency can express passed the check as positive and finite, and rounding then turned it into zero — so the balance was written back unchanged, a transaction row was created reading COMPLETED for an amount of zero, and the caller's operation marker was spent. The honest retry that followed was refused as a duplicate of something that never happened.
- Fixed all eleven entry points refusing an amount that cannot survive rounding, naming the smallest unit the currency can move. The check follows the currency's own precision, so one satoshi in BTC still passes and the same figure in dollars does not.
An admin record that saved could report that it had not
The shared code behind creating and updating records — used by admin screens across the platform, not by one section — commits the write and then reads the record back to return it. If that read failed for any reason, the error handler ran a cleanup step against a transaction that had already been committed. That step fails in its own right, and its failure is what reached the browser: an internal server error mentioning a transaction state.
The write had already happened. So the screen reported a failure on a record that was saved, and the obvious response — press Save again — created a second copy on a create, or re-applied a stale form over whatever had been written since on an update.
- Fixed the cleanup step doing nothing when there is nothing left to clean up, so an error after the save reports itself and a save that succeeded is reported as having succeeded.
Changing an exchange provider's status could report a failure it had already applied
- Fixed the exchange provider status screen answering with the same transaction message when the switch had already been recorded. Provider status decides which exchange the platform trades through, so an operator who believed the change had not applied had every reason to set it again.
Adjust Balance refused every request it received
The route declared its path identifier as a required field of the request body. Body validation never sees a path parameter, so the platform demanded a duplicate copy inside the request and refused anything without one — before the handler ran, with a bare rejection.
The shipped admin screen does not send that duplicate, so adjusting a wallet balance from Wallet Management did not work at all. The handler itself was correct throughout; only a caller ever found out, and nothing in the product had exercised this door.
- Fixed the route to require only what a caller can actually send, and swept every other route in Core for the same shape. There were no others here.
- The sweep did turn up two more, both in the Ecosystem add-on's custodial transfer screens; those are repaired in Ecosystem v6.3.9.
Subtracting more than a wallet held was refused with no figures on screen
The Adjust Balance dialog checks the amount against the wallet's available balance before sending, so an operator drawing on money that is held rather than free is told before anything happens. That check could never be true: balances arrive from the API as text, and the check only accepted a number. Every over-subtraction therefore went to the server, which refuses it correctly, and came back as a bare Insufficient funds in wallet with the form's own figures no longer on screen.
No adjustment was ever applied wrongly — the server's check is the one that decides, and it was right throughout. What was lost was the explanation. A subtraction draws only on the wallet's free balance; funds held against a resting order or a payout in flight are not available to it. Being told the figure you may draw on, rather than only that the one you typed was too large, is the difference between correcting the amount and going to look for missing money.
- Fixed the form's own check, which now converts the balance before comparing and names the available figure and its currency when it refuses.
The order ticket quoted a fee rate that was not the market's
The matching engine always applied the market's own configured rate, so the charge was right throughout and the quote was wrong. See Upgrade Notes — a rate you set by trusting that quote is not the rate your customers paid.
- Fixed the Pro terminal's Est. Fee line reading a rate table written into the page instead of the market. It named 0.100% on every spot and ecosystem market, a tenth of what a market left on the platform's default charges, and it now reads the market's own maker and taker rates.
- Fixed the standard spot and ecosystem ticket falling back to that same 0.100% constant on a market that publishes no rates. Where a market publishes none it now shows no percentage at all, rather than a plausible-looking default.
A filled sell order was recorded as money arriving
A sale removes coins from the seller. Transaction History recorded that removal with a plus sign, and paired with the reservation taken when the order was placed, a completed sale added up to zero — as though the seller still held everything they had sold. The cause is that the row written when an order fills and the row written when a cancelled order's funds are handed back carried the same label, and that label was read as money arriving.
- Fixed a fill being counted as an inflow. Direction is now recorded by the operation that performed it instead of being inferred from a label. Reserving funds, and cancelling to get them back, are shown as neither an inflow nor an outflow — neither changes what the customer holds. The money leaves at the fill, and that is now the row that carries the minus.
- Fixed the same one-label-two-meanings problem in NFT offers, P2P locks and binary orders.
Money figures could be laundered into ordinary-looking wrong numbers
Every decimal amount crosses from the platform to the browser as text, so adding two of them joins them end to end instead of summing them. Rendered raw that produces a figure so obviously wrong it gets reported within the hour. Rendered through a formatter it did not: all twenty-seven of the platform's money formatters began by parsing as far as they could and stopping, so a customer holding $35.00 was shown a perfectly ordinary $25.00.
The same shape one step over: a figure that made a second trip through a formatter kept only the digits before its thousands separator, so 1,234 rendered as 1.
- Fixed the shared money formatters to refuse a value that is not a single number — a second decimal point, thousands grouping, trailing text — rather than salvaging the first part of it. Two of them also had one guarded branch and one unguarded branch inside the same function, so whether a missing amount printed
0.00or the literalNaNdepended on which argument the caller had omitted. - Fixed the binary trading header, which added the wallet balance to the pending stakes this way and rendered the join.
A wallet page added two balances into one long number
- Fixed the wallet detail screen joining the available and held balances end to end instead of summing them, so a wallet holding 25 free and 10 on hold reported a total of 2510.
- Fixed the transfer screen reading the available balance out of the first page of a paginated wallet list, so a customer with more than ten currencies could not see — or spend — anything past the tenth.
The withdrawal summary did not match the debit
The withdrawal form served fiat, spot and ecosystem withdrawals from one fee formula, and those three routes charge in three different ways. Four consequences, each measured:
- Fixed the platform's own spot withdrawal percentage being invisible to the form. It is added on top of the currency's fee by the server, so a withdrawal of 1 ETH against a 0.005 chain fee displayed fee 0.005 · you'll receive 0.995 and debited 1.01.
- Fixed the ecosystem fee being overstated: the form added the minimum and the percentage together where the server charges whichever of the two is larger. 100 USDT against a 1 minimum and 2% quoted 103.00 against a real 102.00.
- Fixed the direction of the fee on ecosystem chains that take the fee alongside the amount rather than out of it, where the summary had it backwards.
- Fixed Use Max and the balance guard, which both assumed the fee comes out of the amount. On spot and ecosystem it is added on top, so the one control whose entire job is to name a valid amount produced a request the server was certain to refuse — and the customer was told after pressing Withdraw rather than by a disabled button with a reason. The advertised minimum was inflated the same way, so the form refused amounts the server accepts.
A transfer fee on a crypto asset was rounded to cents
- Fixed the transfer screen rounding the fee to two decimal places on assets the platform does not round. At the default 1%, moving 0.5 BTC quoted a fee of 0.01 against a real 0.005; moving 0.001 BTC quoted 0.00, which also suppressed the fee row entirely and printed Recipient receives 0.00 BTC where 0.00099 lands. The fee now follows the asset's own precision, rounded up in its last place, because a fee quoted below what will be charged reads as a better deal than the customer gets.
Sending money to yourself inflated the wallet's own address map
An ecosystem transfer to your own account resolved the same wallet twice, debited one copy, credited the other, and wrote both — so whichever write landed second is what the customer kept. Nothing anywhere refused it.
- Fixed a transfer refusing its own sender as the recipient. A self-transfer of 100 USDT previously added 100 to the wallet's per-chain balances with no debit behind it, so those balances no longer summed to the wallet's own.
The currency list did not carry the fee it was being asked for
The list of currencies a customer can move money in is Core's, and every screen that has to quote a transfer fee before the money moves looks the currency up in it. The rows carried only a value and a label. Anything reading a fee off them read nothing, and an absent fee renders as 0 rather than as an error — so a screen could promise Fee 0.00 while the backend charged the configured percentage, and no screen could tell that it had been handed nothing.
- Fixed the list to carry each spot currency's own
feeandprecision, read from the currency record where the charge is actually configured. Fiat is deliberately still zero, because no fee is configurable on it, and ecosystem and futures balances are left alone — those carry their fees elsewhere. - Screens that were quoting zero because of this live in the add-ons that own them, and are repaired alongside: see Forex Investment v6.1.8.
The 24-hour market change was the price move with a percent sign on it
The ecosystem and futures engines publish the absolute price change and the percentage change as two separate figures; only the exchange feed sends one under both names. Two market panels captured the percentage and then rendered the absolute figure, and both prefer the ecosystem feed.
- Fixed both panels reading the percentage. A market moving from 60,000 to 61,200 — a 2% rise — published "+1200.00%". On the standard panel this hit the selected market's row only, so the list and the header above it disagreed with each other on one screen.
Trading Pro's analytics were zeros and NaN for every trader
Every performance figure on that panel was derived from a profit-and-loss field that exists on no order anywhere in the platform. Reading a missing field and treating it as zero turned "there is no such number" into "the answer is zero", and the panel published it: total and today's profit and loss both +$0.00, win rate 0.0%, profit factor 0.00, and — because it then divided zero by zero — risk/reward 1:NaN, break-even win rate NaN% and edge NaN%, under a red verdict reading no edge — review strategy. Every account saw the same figures. Futures orders were skipped from the calculation entirely.
Computing a real spot profit and loss means matching buys against sells with a cost basis, which is a feature rather than a rounding fix.
- Fixed the endpoint reporting nothing rather than zero where nothing has been measured, and the panel drawing an em dash for it — untinted, because a colour is a verdict too. What survives is what the orders genuinely support: trade counts, volume, the per-symbol and per-market-type breakdowns and the hourly histogram.
- Fixed four figures on the same screen — Sharpe, Sortino, maximum drawdown and Calmar — which were fixed literals, colour-coded and rendered beside real numbers, identical for every customer on the platform. They needed a return series the component is not given, so they are gone rather than approximated.
- Fixed the trade distribution card stamping a dollar sign on a total that summed volumes denominated in different quote assets.
The public market page advertised a market capitalisation it had invented
- Fixed the market list publishing price × 1,000,000 as market capitalisation for every market, because no route computes one and the fallback was always taken. Bitcoin at 60,000 advertised "$60.00B". The screen already had a branch for "we do not know this", which had been unreachable.
Binary trading told the customer four things the contract does not do
- Fixed the No Touch button promising 43% more than the contract pays. Touch and No Touch price their two sides differently — that asymmetry is the product — and the settlement engine scales the payout accordingly. The panel printed one figure on both buttons: +95% / +950.00 on a 1,000 stake that credits 665.00. The two multipliers were arriving in the payload and were never read.
- Fixed an open position quoting a rate re-derived from its duration rather than the rate frozen when the order was accepted, which is the one settlement pays. The re-derivation routinely missed, and its fallback was a hardcoded 85% — above the platform's own default.
- Fixed the cancel dialog quoting the full stake as the refund, in bold green above the confirm button, while the server applies a graduated 5/10/20% penalty it deliberately refuses to let a client choose. The dialog no longer asserts a figure it cannot know.
- Fixed a losing trade rendering +0.00 in green beside a result column reading LOSS, and the same zero making the equity curve rise on a losing account: maximum drawdown was permanently 0.0% and the recovery factor divided by it and rendered infinity. A draw is a genuine zero — the stake comes back — so the result is now read rather than the number negated.
- Fixed an empty binary wallet rendering "Infinity% of balance" beside the stake field.
The binary safety net settled contracts it could not judge
The 15-second backstop that settles orders whose live monitor was lost — typically to a restart — called the settlement decision without the two facts that decide a path-dependent contract. Missing facts read as did not happen. That is not an occasional mis-settlement but a fixed one: every No Touch it settled won and every Touch lost, whatever the price had actually done, and every Turbo settled as though its barrier had never been breached.
It cannot reconstruct the answer either. That path fetches a couple of minutes of candles around expiry to find a closing price; it never sees the span the option lived through, which is the only thing that decides whether a barrier was touched.
- Fixed the backstop refusing rather than guessing. Touch/No Touch and Turbo orders it cannot judge are flagged for review, which is the state the platform already has for that. The monitored path settles them normally, as before.
- Fixed a losing stake being split in a way that could destroy part of it. What returns to the customer and what the platform takes must add up to the stake; a corrupted profit figure could produce a negative return, and the platform would then have consumed more than the order ever held.
- Fixed an order that was paid for from a real wallet being recorded as practice. The practice flag is supplied by the browser, and while the wallet decision correctly normalised it, the raw value went into the order record — so the text
"false"took the stake from a real wallet and stored the order as a demo. Every later check reads the wrong side of that: settlement, copy-trading replication, and the split between practice and live in the AI engine.
Cancelling an investment has never worked
A customer who opened an investment and then cancelled it was shown "Validation error", and their money stayed where it was. Not sometimes, and not on certain plans — every cancellation, on every installation, since the button existed. Both the general and the Forex investment families were affected.
Every transaction in your ledger carries a reference to the thing it was for, and no two transactions may carry the same one. Opening an investment writes a transaction referencing the investment. Cancelling it wrote the refund against the same reference, which the database refuses — so the refund was rejected, and because the refund and the closure were one operation, the whole cancellation was undone with it. The customer was told it had failed, which was true, and given a message that explained nothing.
- Fixed the refund carrying its own reference. Cancelling now returns the principal to the customer's wallet and closes the position, as the screen has always said it would.
- Nothing needs repairing in your data. Cancellations that failed changed nothing at all — no money moved, no position closed — so a customer who gave up and left the investment running can simply cancel it now.
- The audit trail is unchanged: the original funding transaction still carries the plain reference, and is still annotated with the cancellation and a link to the refund.
An investment could be paid out twice
Once cancelling worked, a second problem behind it became reachable. The settlement job selects every active investment in one pass and then works through the list, sending an email, a notification and a referral reward for each — so a position near the end of a long batch is settled minutes after its status was read. The Cancel button read the same status from outside its own transaction. Both doors could pass their check on the same investment: the job credited principal and return, the cancellation credited the principal back, and because each filed its work under a different marker nothing deduplicated them.
Cancelling also left the row reading active while marking it closed, so an administrator reviewing deleted records saw an entry labelled active and could restore it — producing a live investment whose maturity date has passed, which the next hourly tick settles on top of the refund the customer already had.
- Fixed both doors claiming the investment under a lock before deciding anything, so the second one to arrive is refused.
- Fixed a cancelled investment being stamped cancelled, so restoring one cannot produce a second payout.
An investment projected a maturity value the position will not receive
An investment's return is fixed at the moment it is bought and stored on the record; settlement pays that figure. Both the detail page and the portfolio projected from the plan's rate as it stands today.
- Fixed both screens reading the same figure settlement will pay. After any admin edit to a plan, every running position under it advertised a maturity value it would not receive — and because plans are edited elsewhere, the number simply changed under the holder with nothing on screen to explain it.
Two-factor recovery codes were stored exactly as the customer was shown them
When a customer turns on two-factor authentication they are given twelve recovery codes. Each one is a complete substitute for the second factor: the sign-in prompt takes one instead of the authenticator code, and so do the password-change dialog, the withdrawal confirmation and the P2P escrow release.
They were stored in the database as-is. One SELECT on that table therefore
yielded a working second factor for every two-factor account on the
installation — from a backup, a reporting replica, a support export, or anything
that could read one column. The customers it protects could not tell, and had
nothing to rotate.
The authenticator secret in that same row had always been encrypted. The recovery codes sat beside it in plain text, and they are the stronger credential of the two: the secret still needs the current time to produce a code, whereas a recovery code is the code.
- Changed recovery codes to be stored as a one-way hash, using the same primitive as account passwords. Codes your customers already wrote down keep working, and nothing about the sign-in flow changes. Nobody can read them back out of the database again — including you.
- Added
pnpm db:migrate:6.6.3, which converts the codes already in your database — and, in the same pass, the four other record repairs this release needs. It reports first and only writes with--apply, like every other migration script. See Upgrade Notes.
Turning on two-factor authentication did not prove you held the device
Two of the three doors that enable a second factor accepted the secret and switched two-factor on without ever asking for a code from it. One checked that the secret looked like a secret; the other checked only that it was there. On a stolen session that is an account takeover: the attacker submits a secret they generated, and the victim's next sign-in demands a code only the attacker can produce. One of those doors compounded it by minting twelve fresh recovery codes and returning them in the attacker's own response, replacing the ones that would have rescued the account. The second of the two has no caller anywhere in the product — an unused door that writes the second factor is exactly the kind nobody looks at.
- Fixed both doors verifying a live code before writing anything, as the third one always did. The legitimate flow has a valid code in hand at that point, so nothing changes for a customer enrolling normally.
- Fixed a recovery code being spendable more than once. The code list was read, edited in memory and written back with no lock, so simultaneous requests carrying the same code each found it, each removed it and each returned a session. Its callers are the whole sensitive surface — two-factor sign-in, password change, withdrawal confirmation and P2P escrow release — so the same reuse bought a bypass on each. One code is now consumed once.
An API key's scopes were never enforced on any route that moves money
The product's API-key screen lets a customer tick which things a key may do, and the check that enforces those ticks sat behind an earlier condition that finance, exchange, futures and ecosystem-order routes do not meet. On every route that moves money the check was unreachable code. A key minted through that screen with nothing ticked could post a withdrawal, and because Algo Trading Bots credentials are rows in the same table, a bot key was a withdrawal key too.
- Fixed the scope check running before the condition that was skipping it. Money routes are refused unless the key holds the matching scope, and — deliberately — a money route nobody remembered to map is refused rather than allowed, because being unmapped is how every gap here came to exist.
- Read access is unchanged and still open to any key. Closing it would refuse every shipped integration that reads a balance, which is a breaking change and a separate decision.
An API key could be used to mint a wider API key
An API key is accepted anywhere a session is, and resolves to the account that owns it. Creating a key and widening an existing one were both reachable that way, so a leaked read-only key could make itself a second key holding any scope it liked — or simply grant those scopes to itself, no second key needed.
That is worse than one more hole. It makes the money-route scope check described in Upgrade Notes bypassable by walking around it: the check refuses a key that lacks withdraw, and these doors handed it withdraw.
- Fixed both routes refusing an API-key caller outright and requiring a signed-in session, which is what the password routes already did for the same reason.
- Fixed a password reset disabling that account's API keys as well as its sessions. Keys are disabled rather than deleted, so the record survives for audit. The reset is the exact moment a customer believes they have taken their account back, and a key minted from a stolen session used to survive it untouched.
Every sign-in response carried its own credentials in the reply body
Access token, session identifier and CSRF token were all sent in the body of every sign-in, second-factor verification and token refresh, as well as in the cookies that were meant to be the only copy. The code that was supposed to strip them ran after the reply had already been assembled, so it removed nothing from what went out.
- Fixed the removal happening before the reply is built, so a browser receives its credentials as cookies and nothing else. Anything that records a response body — a request log, a proxy cache, a debugging session — was recording a working session for the account that had just signed in.
- Fixed signing out leaving the session identifier in the platform's own index of a customer's sessions. The session itself was gone, so the Active Sessions screen looked right; anything reading that index directly — a revoke-everything sweep, a session count — was reading identifiers for sessions that no longer existed.
A signed-out handset kept receiving the account's notifications
- Fixed sign-out revoking push for the device that signed out. Push registrations outlive a session entirely, so a phone that had been signed out went on receiving that account's order fills and withdrawal notices — on a shared or resold handset, to whoever holds it next. Only the device identifying itself is revoked, and only for the customer who was signed in.
- Fixed the sign-out route accepting a session presented as a header rather than a cookie. It was the one session-aware route without that fallback, so a client that does not keep cookies could not sign out at all and its session survived for the full fourteen days.
The Affiliate add-on's routes answered "not found" on every installation
The licence gate guarded the affiliate routes under a product name the extension seeder never creates, so the gate's check for that name was permanently false and it refused every request under it — all eleven customer routes and the entire administrator back office. A second entry guarded a route tree that does not exist, and therefore guarded nothing.
- Fixed one entry, under the name the product is actually sold and seeded as, over the routes that actually exist. This opens nothing: an operator who has not enabled Multi Level Marketing still gets a refusal — now for the real reason.
- One screen inside the add-on had also lost its gate entirely, which the licence registry cannot see and cannot repair. That repair is in Multi Level Marketing v6.1.8.
"Enable Spot Trading" did not enable spot trading
The order route checked a setting nothing in the product ever writes — not the seeders, not the settings registry, not the toggle an operator sees, and it is absent from the published settings so no screen could show it either. The row therefore never existed, and every spot order on a stock installation was refused with "Spot trading is currently disabled", with no setting anywhere an operator could find to change it.
- Fixed the route reading the switch the toggle actually writes, compared the same tolerant way its four sibling readers already compare it.
An email address could be tested for registration without signing in
The resend-verification endpoint answered three different bodies for three account states, to anyone: a neutral success for an address that is not registered, one refusal for an address that is registered and already verified, and another for one that is registered and not. The unknown-address branch was written to be neutral on purpose and carried a comment saying so; the two branches after it undid that.
- Fixed every outcome answering the same way. The real reason still reaches your own operator log. Nothing unusual had to be configured — the already verified refusal fires everywhere.
Four fiat deposit doors credited the wrong thing, or credited twice
See Upgrade Notes for what to reconcile. The repairs:
- Fixed PayFast crediting the amount supplied by the caller, and verifying the provider's signature only when a signature and a passphrase both happened to be present — so omitting the field skipped verification. The shipped deposit screen made that reachable without any attacker at all: it announced the payment complete when the PayFast window merely closed, so a customer who opened it and walked away was credited in full. The route now fails closed with no passphrase, demands a valid signature, asks PayFast itself whether the payment happened, and credits the figure the platform recorded. The screen asks the read-only status endpoint instead of asserting an outcome.
- Fixed Paystack crediting one payment twice. Its two webhook doors marked their work with one key and its status check used another, and only doors that agree on the marker can deduplicate — so a browser that polled and a webhook that arrived each credited.
- Fixed Adyen resolving the deposit's currency from a stored field that arrives as text on MariaDB, so on that engine the currency was never readable and every deposit was credited as USD whatever the customer paid.
- Fixed PayPal filing the credit under a reference the pending record already held, which the ledger refuses — so the money was captured and the credit then failed.
An admin delete could destroy customer investments
See Upgrade Notes for what to check. The investment table cascades to both its plan and its duration at the database level, and Core's four plan and duration delete doors had no precondition at all.
- Fixed all four refusing by the kind of delete that will actually happen rather than the kind the caller asked for. A permanent delete is refused while any investment depends on the row, because it destroys them; a recoverable one is refused while any active investment does, because a hidden parent means those never settle. Which kind a delete is is not the caller's choice — plans are recoverable and durations are not, whatever the request says.
- Added the dependency check itself as a shared guard, so an add-on carrying its own plans and durations can hold the same line without transcribing it. The add-ons decide their own refusals and describe them in their own releases: AI Investment v6.1.6 and Forex Investment v6.1.8.
- Fixed the two readers of the "permanent delete" flag disagreeing about what it says. The guard read it strictly and the door read it loosely, so
?force=1was graded as reversible and executed as permanent — and a settled investment is not active, so it passed the guard and the database removed it: the platform's only record that it had paid that customer, gone.
Neither approving nor rejecting a withdrawal did what the screen said
See Upgrade Notes for what to check. The repairs:
- Fixed rejecting from the wallet screen failing outright with an internal error, because it wrote the withdrawal's note in a form the column will not take. Every fiat rejection failed, so the withdrawal stayed pending with the money already debited and the operator had no way to return it. The approval route carried the same fault in its own error handling, where it is worse placed: the refund has already succeeded by that point, so a failure there left the customer refunded, the record still pending and nothing recording that it had been, which invites a second refund.
- Fixed every approval of a spot withdrawal failing outright, because it read the withdrawal's stored details in a way that produced nothing and then looked for a wallet with no currency. It is the only door that pays a spot withdrawal out.
- Fixed the refund on a rejection being filed against the wallet rather than the withdrawal, so a second rejection on any one wallet would have been skipped as a duplicate while the withdrawal was already marked rejected — the record reading refunded and the balance never moving. It sat behind the failure above and would have surfaced the moment that was repaired.
- Fixed rejecting from the withdrawal log returning the amount without the fee. A fiat withdrawal stores the net and the fee separately, and the wallet was debited both. The platform did not keep the difference either.
- Fixed a rejection refunding a payout that had already been dispatched. Withdrawals in flight were rejectable, and the spot route's own error handling could not tell a failed dispatch from a successful one followed by a failed read-back — so a rate limit on the second call refunded a completed withdrawal in full.
- Fixed the TransFi payout path, where a refund with no currency to credit threw, abandoned the function, and left the record stamped as though nothing needed doing: the payout had failed, the money stayed debited, and no refund was written.
- Fixed an amount arriving negative being turned positive before the check that refuses negative amounts, so the check could never fire and
-10became a live payout obligation of 10. - Fixed the fee column reading zero on every fiat withdrawal. The money was always right; the column is what the withdrawal list, the detail page and the fee analytics read, so all three reported the platform earning nothing on fiat.
The manual deposit and fiat withdrawal forms handled their own fields wrongly
The extra fields you define on a payment method reach the browser in two different shapes depending on which endpoint served them, and each screen had picked one shape and broken on the other.
- Fixed the fiat withdrawal form refusing outright with "Invalid method configuration. Please contact support." — the withdrawal could not be submitted at all.
- Fixed the manual deposit form silently skipping its own required-field check, so a manual deposit could be submitted with no payment reference on it and you had nothing to match the transfer against.
- Fixed the deposit form rendering "error loading form fields" in place of the fields.
Verification results made the KYC application screen fail
- Fixed the administrator KYC screen answering with an internal error for every application that has a verification result attached. The route parsed two fields the platform had already parsed for it, which throws, and the file had no error handling anywhere — so the screen simply failed, on both database engines.
Structured columns arrived as text on one installation and as values on another
Columns that hold structured data are handed back already parsed by MySQL and as raw text by MariaDB, so a column with no reader of its own serves a different shape per installation. The consumer either fails outright or — quieter and worse — quietly decides there is nothing there and renders nothing.
- Fixed it across three sweeps covering the records that had been missed — trading-bot, P2P, KYC, deposit gateway, wallet audit, NFT, ICO, gateway, binary-engine, API-key and activity-log among them — each with the guarded reader the platform already uses elsewhere. The two that had been reported: a P2P dispute's evidence gallery did not appear at all, which is the material an adjudication is made from; and a purchased trading-bot strategy deployed with no configuration, because the form spread a block of text where it expected settings — and the frozen copy of what the buyer paid for, which a dispute is settled against, had the same problem.
- Fixed a customer's profile being stored wrapped in an extra layer, so biography, location, address and the record of a verified phone number were invisible to every screen while sitting intact in the database. See Upgrade Notes for the repair.
- Fixed the wallet address map being stored the same way, and its reader failing outright on a value it cannot parse. That reader runs on ordinary reads, including inside authentication and every wallet listing, so one malformed row did not affect one wallet — it failed every request that touched it. It now yields no address, which is a state every caller already handles.
Every visitor shared one rate-limit allowance, or could choose their own
Behind a reverse proxy the address the platform sees on the socket belongs to the proxy, so all of a site's visitors were metered as one caller: three registrations per hour for the whole installation, thirty sign-ins per quarter-hour, five wallet sign-ins. Registration refuses rather than allows when it cannot check, so the failure presented as the platform telling honest visitors they had created too many accounts.
The documented remedy was TRUST_PROXY="true". On Apache — the deployment the
install guide calls the default — that made the address forgeable instead:
Apache appends the connecting address to whatever X-Forwarded-For the caller
sent, and the platform read the first entry.
- Fixed both directions at once: the header is honoured on the strength of where the connection came from rather than a setting, and it is read from the end a proxy writes rather than the end a caller writes. A proxy on the same server — which is every deployment both install guides describe — now needs nothing configured. See Upgrade Notes.
- Changed four other places that were each working the visitor's address out their own way — the geographic engine, session device records, the customer activity log and the payment gateway's API routes — to ask the same code as the rate limiter. Two of them had reached different conclusions about the same request.
- Fixed the Apache install guide, which had never carried a directive about
X-Forwarded-For, and the two pages that printed nginx's appending form as though it were the correct one.
A malformed address in a forwarding header became a valid one
The check applied to a forwarded address accepted values that are not addresses,
and the conversion that followed silently repaired them rather than refusing:
999.999.999.999 became 231.231.231.231, and 1.2.3.4:5678 — an address with
a port — became an entirely unrelated one. Each distinct piece of nonsense
produced a distinct valid-looking result.
- Fixed the validation, which now uses the strict check from the language's
own standard library. On an installation with
TRUST_PROXYenabled this was an unlimited supply of fresh rate-limit allowances from a single header, needing no misconfigured proxy and leaving nothing in the log.
The address in a customer's activity log was whatever the caller typed
Activity history records the address behind each sign-in, second-factor change and API-key action — it is the column you read when deciding whether a login came from somewhere unusual. It, and the session device record beside it, both took the first entry of the forwarding header with no check, and fell back to a single-value header that any caller can send.
- Fixed both to use the same resolution as everything else, so one request produces one answer. An attacker could previously write an address of their choosing into their victim's own history — the record your desk reads back when the account is disputed.
Geographic rules evaluated page requests as the server, in production only
Pages are served by the web front end rather than the API, so it relays the visitor's address for the page-level geographic gate to mean anything. That relay was accepted only from a connection recognised as local, and the comparison was made against a list of three literal spellings — after a conversion step that produced a fourth. In production the platform binds in a mode where a local connection arrives in that fourth spelling; in development it does not.
- Fixed the address conversion to reduce an IPv4 address wearing an IPv6 spelling to its plain form before anything compares or classifies it. The relay had therefore been working in development and silently doing nothing in production, so page-level geographic rules were being evaluated against the server's own address on every live installation.
- Fixed the geographic preflight, which refused to let you switch
restrictions on while
TRUST_PROXYwas unset — now the recommended state. It checks whether the address it ends up with is still a private one, which is the thing that actually matters, and its advice no longer points at a setting that would make the installation less safe.
Two copies of one header disagreed with each other
A caller may send the same header twice. The platform kept the last copy in one place and the first in another, so two parts of the same request could read two different forwarding headers — and a caller could split a list across two lines to hide entries from whichever reader mattered.
- Fixed repeated forwarding headers being joined in the order they arrived, as the HTTP specification allows, so every reader sees the same list.
An administrator with only "create user" could create an administrator
Changing an existing customer's role has always been restricted to a Super Admin. Creating a new account was not, and its one refusal never fired: the guard compared a number held by the database against text arriving from the browser, which can never be equal. Every role was assignable, the Super Admin role included — the one role that bypasses every permission check on the platform — while the route printed the new account's temporary password in its own success message.
- Fixed the comparison, so the Super Admin role is refused as it was always meant to be.
- Fixed both the create form and the CSV importer to refuse any role holding permissions the caller does not hold themselves. A Super Admin is exempt. See Upgrade Notes — this one is worth reviewing your existing accounts for.
Re-using a deleted customer's email address returned a server error
Deleting a customer keeps their record, and with it their claim on the email address, which is unique. Every check for whether an address was free was made against the records that had not been deleted — so it could not see the one record that was about to reject the new account. The check passed, the insert failed on the database constraint, and the operator got a server error naming a SQL index.
- Fixed all four ways an account can be created — public registration, Google registration, the admin form and the CSV importer — to look at deleted records too, and to say what is actually wrong: that a deleted account still holds the address, and that restoring it is probably what you want.
The admin user form demanded fields it described as optional
Saving a customer who had no phone number and no social links came back refused, listing every one of them: Phone must be at least 8 characters long. Zip must be at least 5 characters long. Facebook is incorrectly formatted. The form itself marks all of them optional. The only way to save was to invent a phone number and a postcode for the customer.
- Fixed the empty value being checked against the rules meant for a filled-in one. A blank optional field now clears it, and stores nothing rather than an empty string.
The CSV importer accepted a column it then discarded
The import template offers a twoFactor column and the documentation described
what it does. Two-factor authentication is not stored on the customer record and
needs a secret an import cannot produce, so the value was dropped and the row
reported as fully imported.
- Fixed the row saying so — it still imports, with a note that the customer must enrol two-factor themselves.
- Fixed an unrecognised
statusvalue falling back toACTIVEinstead of failing the row with a raw database error.
Registration consumed a role identifier on every signup
Each registration wrote to the roles table to make sure the default role existed, using a form that reserves an identifier before discovering the row is already there. Nothing broke, but the identifiers climb by one per signup, so the next role you create is numbered for how many customers you have rather than how many roles — which reads as a corrupt table and sends anyone diagnosing a permissions problem in the wrong direction.
- Fixed the lookup reading first and only creating when the role is genuinely absent, so an ordinary registration no longer writes to that table at all.
"You need to be authenticated" was shown to people who were
An administrator whose role holds no permissions was sent to a page reading Access Restricted — You need to be authenticated to access this area, whose only action was Sign in. Every part of that is wrong for them: they are signed in, and signing in again cannot change the outcome. A role created by hand, or on a demo installation, holds no permissions until somebody grants them — nothing in the product seeds any — so this is the ordinary way to arrive there.
- Fixed the page distinguishing "not signed in" from "signed in without permission". The second names the role, says a new role holds no permissions until they are granted, points at Users → Roles & Permissions, and does not offer a Sign in button that cannot help.
A settled investment could be paid out and then say nothing about it
Deleting a plan is an ordinary administrator action, and the platform deliberately settles the investments it orphans by doing so. The settlement email read the plan's name straight off a record that is no longer there, which failed — and because the email and the in-app notification sat in one block with the email first, the notification was never reached either.
- Fixed both notices tolerating a deleted plan or duration, and each having its own error handling. The money moved and the platform said nothing at all, on both the general and the Forex investment families.
A wallet page could stay blank for ever after a failed load
- Fixed the wallet connection provider having no error state. It renders nothing while its code downloads and it rendered nothing on failure too — so a blocked script, a content-delivery hiccup or an offline moment left the whole section blank permanently, with nothing logged and no way for a customer to tell a broken page from a slow one. It now explains what happened and offers a reload.
Wallet and transfer notifications could never reach a phone
- Fixed the shared notification funnel hard-coding the in-app channel. Every wallet, transfer and referral notice went through it, so no deposit confirmation, withdrawal failure, transfer or referral reward could ever be delivered to a handset — while the push layer dutifully worked out where each one should open. Push still requires the customer's own consent, which is off until they give it.
- Fixed the destinations for staking, investment and P2P chat notifications, which had no entry in the routing table or an entry keyed on a title nothing sends — so tapping one of those notices did nothing at all, with no error anywhere.
Three live-data streams could never reach a client
All three had been silent for a long time and none had been reported, because in every case the browser polls the same data over ordinary requests and nobody noticed the stream was not arriving.
- Fixed futures position updates being published to an address that is not a live-data route at all, and can never be. They now go to the route their sibling order updates already use.
- Fixed the binary settlement's balance update being published to a route that does not exist anywhere in the platform.
- Fixed subscriptions to several markets at once overwriting each other. The buffer that batches outgoing frames was keyed by stream type alone, so every market subscribed to the same stream wrote into one slot and all but the last were discarded on each flush. The symptom read backwards, which is why it survived: subscribing to more markets delivered fewer updates. The trade screen watches one market at a time, which is the one case the fault does not touch.
An ecosystem wallet could be handed an address that was never saved
- Fixed the wallet completion check editing the record's own address map in place while deciding whether it needed to write one. When nothing needed writing, the caller was handed back a wallet carrying an address that had never been stored.
- Fixed a Solana deposit being skipped for ever when the customer had previously withdrawn to their own deposit address. The withdrawal writes a record carrying the same on-chain reference, and the deposit scanner's "have I already handled this?" check did not distinguish the two — so it found the withdrawal and never credited the incoming payment.
- Fixed two TRON recovery paths failing to record an off-chain adjustment when the wallet involved carries no derivation index, which is exactly the case for wallets credited through P2P or by an older path. The record was never written, so the same wallet stayed selectable and drainable — the failure the equivalent token path had already been repaired for.
A wallet disconnect worked on an installation where connecting did not
- Fixed the route that removes a linked wallet carrying no licence check, alone among the four. On an installation where wallet linking is switched off or unlicensed, every route that could create a link refused while the route that removes one carried on working — so a disabled add-on could still drop a customer's only means of signing in.
An admin list's total counted rows that had been deleted
- Fixed the soft-delete condition reaching the row query but not the count beside it, so a page showing ten live rows sat under a total that included deleted ones.