ICO troubleshooting
Offerings that never settle, contributions stuck in escrow, every refund door and what it refuses, phase supply that will not reconcile, and the settings key that does nothing.
Every ICO fault is a money fault. The platform holds the payment currency of every live contribution on one wallet, and nothing releases it without either a cron run or an administrator clicking something. An offering that does not settle is not a cosmetic bug — it is contributors' money held indefinitely.
Backend log modules to filter on:
| Module | Written by |
|---|---|
ICO_OFFERING_PROCESS |
The hourly settlement cron's own wrapper |
ICO |
Settlement itself, including per-offering failures and refund totals |
ICO_VESTING |
The vesting cron |
ICO_INVESTMENT |
The purchase route |
ICO_REFUND |
The shared refund authority and /api/ico/refund/process |
ICO_RELEASE |
A creator submitting a delivery hash |
ADMIN_ICO |
The admin offer, transaction and settings routes |
ADMIN_ICO_CANCEL |
The Super Admin emergency cancel |
First checks
Before anything specific:
- The ICO extension row is active at
/admin/system/extension - Process ICO Phases and Process ICO Vesting both appear at
/admin/system/cronwith a recent, green run - One account holds the Super Admin role — it is the escrow
- The offering's launch plan currency and wallet type still exist as a real wallet type on your install
The first two cover most of this page. The third causes purchases to fail with what reads like a server fault; see Install.
Nothing settles without the cron
Two jobs, both hourly, both in the cron process — the PM2 app started with
CRON_MODE=only on port 4001.
| Job on screen | Function | What it decides |
|---|---|---|
| Process ICO Phases | processIcoOfferings |
Promotes UPCOMING → ACTIVE, then closes ended offerings as SUCCESS or FAILED and refunds every contribution on a failure |
| Process ICO Vesting | processIcoVestingReleases |
Announces tranches that have come due, closes finished schedules, cancels schedules behind refunded contributions |
processIcoOfferings is the only automatic path that ends a sale and the only
automatic path that pays contributors back when it fails. With the cron process
down, an ended offering sits at ACTIVE forever: it refuses new contributions
because its end date has passed, it never becomes FAILED, and because the
refund doors accept only FAILED and CANCELLED offerings, there is no way to
hand the money back short of cancelling the sale.
Neither job has a manual equivalent inside the ICO screens. If you need one to run now, use Run now on its row at Admin → System → Cron.
An offering is stuck UPCOMING past its start date
UPCOMING is promoted to ACTIVE by the cron, in two places within the same
job: an early pass in processIcoOfferings, and again inside the settlement
stage it delegates to. Either one moves it. If neither has, the job is not
running — check the run state on the cron screen before looking anywhere else.
Approving an offering sets it to ACTIVE immediately, whatever its start date;
the purchase route is what refuses early contributions, with "Offering has not
started yet". So an UPCOMING row on a current install came from a direct
database edit or from a much older build. It is still promoted correctly — but
do not wait for an "Upcoming" queue to fill itself.
An offering is stuck ACTIVE past its end date
This is the one that matters. The early pass in processIcoOfferings
deliberately does not decide the outcome — it logs "Offering X has ended -
status will be determined by checkAndUpdateOfferingStatus" and moves on. The
whole end-of-sale decision, including SUCCESS versus FAILED and every
refund, happens in the settlement stage that runs after that loop.
So an offering frozen at ACTIVE past its end date means one of exactly two
things:
- The job is not running at all.
- The job runs, but the settlement stage is failing — for this offering or for the whole stage.
Tell them apart on the cron screen. A job that is not running has a stale Last run. A job that is running and failing shows red with a Last error.
A red run on Admin → System → Cron
Settlement runs one database transaction per offering, on purpose: refunding a large failed sale takes row locks on every contribution and on the escrow wallet, which is the hottest row on the platform. One sale's failure therefore does not stop the sweep — the offering is rolled back, the error is collected, and the loop continues to the next one. At the end, if anything was collected, the whole run throws and is reported failed.
That means a red run does not mean nothing settled. Open the job on Admin → System → Cron and read Last error; it names the offerings:
ICO offering status update: 2 offering(s) failed: offering <uuid>: <message>; offering <uuid>: <message>Only the first ten are listed. The full set is in the log under ICO, one
Error settling offering <id> line each.
Two nuances worth knowing:
- Errors raised while promoting
UPCOMINGofferings in the early pass are logged underICO_OFFERING_PROCESSand broadcast to the run log, but they are not collected — they do not turn the run red on their own. - If the settlement stage fails as a whole rather than per offering, the run is
reported failed with
Phase manager update failed: …. It is rethrown deliberately: swallowing it once let the panel report "completed, 100% success" every hour while no ended offering settled and no contributor was refunded.
Rerun with Run now once you have fixed the cause. Settlement is safe to
repeat — an offering that already moved off ACTIVE is no longer selected, and
every refund is idempotent against the ledger.
A contribution is stuck
Read the status first. Two of the five look like problems and only one of them is yours.
| Status | What it means | Who owes the next move | Where the money is |
|---|---|---|---|
PENDING |
Paid. Creator has not delivered tokens | The creator | Escrow |
VERIFICATION |
Creator submitted a delivery hash | You | Escrow |
RELEASED |
You verified it | Nobody | Paid to the creator |
REJECTED |
You refused the delivery | Nobody | Refunded to the buyer |
REFUNDED |
Swept back by a failure, a cancel or a refund run | Nobody | Refunded to the buyer |
Both sit on the Super Admin escrow wallet. The buyer has paid in both cases and the creator has been paid in neither. The only difference is who is late.
A PENDING contribution is a creator problem — chase the project, or refuse
to release escrow. A VERIFICATION contribution is an operator problem: it
is on your desk at Admin → ICO → Transactions, and while it waits the
creator is not being paid for tokens they have already sent.
A vesting contribution stays PENDING longer than operators expect. Its
whole-amount release door is closed — a creator who tries it is refused with
"This contribution vests. Record each tranche on the Vesting tab as you send
it…" — and the contribution moves to VERIFICATION only when the first
tranche is recorded. Verifying that first tranche releases the entire
purchase amount; there is no partial escrow release. See
Vesting.
If the status is right but the ledger looks wrong, work through Reconciling a stuck contribution.
Refund paths, and what each one refuses
There are four doors into one shared refund implementation. They differ only in who may open them and which offerings they accept.
| Door | Who | Accepts | UI |
|---|---|---|---|
| Reject a delivery | edit.ico.transaction |
One contribution, VERIFICATION only |
Transaction desk |
| Soft-cap failure | The cron | Every outstanding contribution on an offering it just marked FAILED |
None — automatic |
POST /api/ico/refund/process |
The offering's own creator, or any Admin / Super Admin |
Offerings in FAILED or CANCELLED only |
None |
POST /api/admin/ico/offer/[id]/cancel-refund |
Super Admin role only | Any offering; sets it CANCELLED first |
Offering detail page |
Three things trip operators up here.
The refund run has no screen. POST /api/ico/refund/process is API-only —
nothing in the admin panel calls it. It takes offeringId and a reason, both
required:
curl -X POST "https://your-host/api/ico/refund/process" \
-H "Content-Type: application/json" \
-H "Cookie: accessToken=..." \
-d '{"offeringId":"OFFERING_ID","reason":"Soft cap not reached"}'It refuses anything that is not FAILED or CANCELLED with
Cannot process refunds for offering with status: ACTIVE. You cannot hand money
back on a running sale — pause it, let it end, or cancel it. It also refuses
with Every contribution on this offering has already been refunded when there
was nothing left to pay, which is a success, not a fault.
The emergency cancel is gated on the role name, not on a permission. The
route declares manage.system, and then additionally requires the caller's role
to be literally Super Admin. Granting manage.system to a custom role does
not open it. It needs a reason of at least ten characters, which goes into the
audit trail.
GET /api/ico/refund/eligibility/[offeringId] computes its soft cap as
targetAmount × 0.3 regardless of what icoSoftCapPercentage is set to, and it
counts REFUNDED contributions into the totalRaised it reports. Settlement
uses neither of those rules.
If you have moved the soft cap off 30, treat this endpoint's eligible and
softCap as advisory. The number that decides SUCCESS versus FAILED is the
one on Admin → ICO → Settings → Investment.
Nobody is refunded twice, whichever door is used. All four share one idempotency
rule and the ledger is the authority: one completed REFUND against a
contribution id means that buyer has their money, and every later sweep skips
the row rather than paying again.
Contributions left REJECTED by the old lifecycle
An older build's failure sweep flipped every live contribution on a failed sale
to REJECTED and stopped there. Nothing scheduled ever refunded those rows, and
all three manual doors accepted only PENDING and VERIFICATION — so the
status change itself put the money out of reach of every remaining path.
Investors were left holding a rejected transaction and no way to get their
contribution back.
REJECTED is now a refundable status. But nothing sweeps it on a timer: it is
paid out only when a refund is explicitly triggered against the offering.
The transaction desk's status filter offers PENDING, VERIFICATION,
RELEASED and REJECTED — and REJECTED there mixes legitimate admin
rejections, which were already refunded, with the stranded ones, which were not.
REFUNDED is not a filter option at all. You have to go to the database.
List them first. This finds REJECTED contributions with no completed refund
leg on the ledger, using the same test the refund authority itself applies:
SELECT o.id AS offeringId,
o.name,
o.status AS offeringStatus,
t.id AS contributionId,
t.userId,
t.amount * t.price AS value,
o.purchaseWalletCurrency AS currency
FROM ico_transaction t
JOIN ico_token_offering o ON o.id = t.offeringId
LEFT JOIN `transaction` r
ON r.referenceId IN (CONCAT(t.id, '_refund'), t.id)
AND r.type = 'REFUND'
AND r.status <> 'FAILED'
AND (r.description NOT LIKE '%escrow%' OR r.description IS NULL)
WHERE t.status = 'REJECTED'
AND t.deletedAt IS NULL
AND r.id IS NULL
ORDER BY o.name;Rows that come back have been paid for and never paid back. Then work per offering:
-
Check the offering's status. The refund run accepts only
FAILEDorCANCELLED. A stranded row on an offering still sitting atACTIVEpast its end date is a settlement problem first — fix the cron and let it settle. -
Run the refund with
POST /api/ico/refund/processfor that offering, as above. Contributions already refunded through the rejection door are skipped by the idempotency check, so the run pays only the stranded ones. -
Read the response.
refundedCountandtotalRefundedare what moved;failedRefundsis an array naming each contribution that could not be paid and why. The commonest reason is a wallet that could not be resolved. -
Re-run the query. Anything still listed failed, and the reason is in the response and in the log under
ICO_REFUND.
Refunds are sourced from the escrow wallet before the buyer is credited. If no Super Admin exists, the credit still happens and is logged as unbalanced — the buyer is made whole out of the platform's own funds. Fix the Super Admin; do not rely on that.
icoRefundGracePeriod does nothing
PUT /api/admin/ico/settings/limits accepts refundGracePeriod and writes
icoRefundGracePeriod; the matching GET reads it back with a default of 7.
Nothing in the refund logic consults it anywhere.
There is no grace period, no window after which a refund is refused, and no automatic refund triggered by its expiry. Refund eligibility is decided entirely by the offering's status. The key is not on the ICO settings screen either — the only way to set it is that endpoint.
Both limits endpoints additionally gate on the caller's role name and accept
only Admin or Super Admin, so a custom role holding view.ico.settings is
refused there even though the settings page itself opens for them.
Phase remaining does not match what was sold
Every purchase decrements remaining on the phase that priced it, and every
refund, rejection and failure sweep puts the tokens back into that same
phase — the contribution records its phaseId. Contributions written before
that column was populated fall back to the offering's first phase on refund,
which is the one way the two figures can legitimately drift apart.
Reconcile per offering:
SELECT p.name,
p.allocation,
p.remaining,
p.allocation - p.remaining AS soldByPhase,
COALESCE(SUM(t.amount), 0) AS soldByContributions
FROM ico_token_offering_phase p
LEFT JOIN ico_transaction t
ON t.phaseId = p.id
AND t.deletedAt IS NULL
AND t.status IN ('PENDING', 'VERIFICATION', 'RELEASED')
WHERE p.offeringId = 'OFFERING_ID'
GROUP BY p.id, p.name, p.allocation, p.remaining, p.sequence
ORDER BY p.sequence;soldByPhase above soldByContributions means allocation is burnt — tokens
that can never be sold to anybody. Below it means supply was credited back twice.
Either way, correct remaining directly; there is no admin screen that sets it.
Remember which phase is selling: the lowest sequence with remaining > 0, not
the phase whose date window contains today. Durations are display only. That is
why a phase can sell out on day three and hand over immediately, and why a phase
whose window closed keeps selling.
A sale stopped taking money and you cannot see why
Two caps end a sale and they are measured in different units, so either can bite while the other looks healthy.
| Refusal message | Cap that fired |
|---|---|
No tokens available for sale |
Every phase has remaining = 0 |
Only N tokens remaining in current phase |
The current phase's remaining, for this one contribution |
Investment exceeds target amount. Only N ... remaining. |
targetAmount, against the sum of every PENDING/VERIFICATION/RELEASED contribution |
Nothing checks the two against each other at creation time. Multiply out
allocation × tokenPrice for every phase and compare the total with
targetAmount — see
Phases and pricing.
A contribution never spills over from one phase into the next at the next price;
the buyer is refused and has to buy twice.
An offering with no phase left stays ACTIVE until its end date and then settles
on whatever it raised. That is not a fault, but it means the progress bar can sit
at 30% with the sale genuinely over.
Soft-cap failure refunded a sale you thought succeeded
At settlement, total raised is compared with targetAmount × icoSoftCapPercentage.
At or above it the offering becomes SUCCESS; below it, FAILED — and a
FAILED offering refunds every outstanding contribution in the same pass.
Things to check when the outcome surprises you:
- The setting is read at settlement time, so a change applies to offerings already running. Raising it can turn a sale that would have succeeded into one that fails.
- A value outside 0–100, or an unparseable one, silently falls back to 30%.
If someone set it to
0.3meaning "30 percent", settlement used 0.3% — every sale succeeded. targetAmountis editable on a live offering. The soft cap is a percentage of whatever the target is at settlement, not at launch.- The raised figure counts
PENDINGtoo. A contribution is paid the moment it is made. Rejected and refunded rows drop out.
The sweep returns every PENDING, VERIFICATION and REJECTED contribution. A
creator who already sent tokens on-chain to a VERIFICATION buyer has sent the
tokens and had the money handed back. The platform has no custody of the
token and cannot reverse the transfer.
That is correct for a failed raise, but it is worth saying to a creator when you approve a sale whose target looks optimistic.
Evidence was destroyed by an edit to notes
ico_transaction.notes is a TEXT column holding JSON, not free text. It
carries the purchase metadata every later screen and every refund path reads:
the phase name, the token decimals, the raw token amount, the investment amount,
the fee percentage and fee, the currency, the wallet transaction id, and later
the delivery hash and the refund record.
Replacing it destroys the purchase record. The creator's release door threw on parsing it and every refund path silently skipped the contribution.
The admin actions are safe — save-note, remove-note, verify and reject all
merge into the object rather than assigning over it, and an older plain-text
value is preserved as a note key rather than dropped. Hand edits in the
database are the exposure. If you are correcting a row, edit the JSON.
Only save-note is refused once a contribution is RELEASED — "Cannot add
note: Transaction already verified and released." remove-note carries no
status check at all: it requires only that a note exists, so an administrative
note can still be stripped off a RELEASED contribution, and the buyer and the
creator are both emailed and notified when it is.
A buyer's address or a creator's hash is rejected
Both checks are driven by one string: the value column of the row in
ico_blockchain, compared lowercase. The creator picks a blockchain by that
value at launch, and it is stored on the offering's token detail.
value |
Address format enforced | Hash format enforced |
|---|---|---|
ethereum, bsc, polygon |
0x + 40 hex |
0x + 64 hex |
bitcoin |
bc1… or legacy 1/3 |
64 hex |
solana |
32–44 base58 | 87–88 base58 |
| anything else | 20–128 characters, no whitespace | none — any string is accepted |
The messages are Invalid <value> wallet address format on a contribution and
Invalid <value> transaction hash format on a delivery submission, so the
message itself tells you which value the offering is carrying.
Two failure shapes:
Everything is rejected. The row's value is one of the five and the address
genuinely does not match — usually the buyer pasted an address for a different
chain, or a creator pasted an explorer URL for a chain the extractor does not
know. Explorer URLs are unwrapped automatically only for etherscan.io,
bscscan.com and polygonscan.com; anywhere else, paste the bare hash.
Nothing is rejected. The row's value is not one of the five — eth,
Ethereum Mainnet, ETH with trailing whitespace — so addresses fall back to a
length check and hashes are not checked at all. A buyer can typo their receiving
address into an unrecoverable transfer.
The chosen value is copied onto the offering's token detail at launch.
Correcting ico_blockchain changes what future creators pick; it does not touch
a single existing offering.
To fix one, edit the offering itself — PUT /api/admin/ico/offer/[id] with a
blockchain field, which is the Edit dialog on the offering detail page. Do it
before the first contribution, not after: contributions already taken carry the
address they were validated under.
Nothing on this platform ever checks a hash against a chain. Only its format is validated, and only on those five values. Verification is a human decision — open the hash on a block explorer and check the amount and the destination against the contribution before you click. See Transactions and payouts.
Escalation
Collect before you ask for help:
- The offering id and, if it is one contribution, the contribution id.
- The offering's
status,startDate,endDateandtargetAmount. - The contribution's
statusand itsnotesJSON, verbatim. - The Last error text from Process ICO Phases on Admin → System → Cron, and whether the job has run since the problem started.
- The
ICOandICO_REFUNDlog lines around the timestamp. - The ledger legs present for the contribution — search the platform's
transactiontable for the contribution id as a reference prefix, not an exact match, because every leg carries its own scoped reference.
That last one settles most escrow arguments on its own: the legs present tell you exactly how far the money got.