Refunds on Campaign Failure
When an LGE does not reach its target before the deadline, the campaign flips to a Failed state and no liquidity pool is created. Instead of stranding contributor ICP, the platform enables a claimable refund: contributors press the “GET REFUND” button on the LGE page and are repaid on-chain, atomically, from funds held in custody for that campaign. The refund window is one week (7 days) from the campaign’s failure — its expiry for the normal paths (campaigns fail at expiry); when a campaign reaches Failed later (a resolved halt re-classified afterwards, the automatic failsafe below, or a governance decision), the window runs from that recorded transition instead.
When a campaign becomes Failed
Section titled “When a campaign becomes Failed”A campaign is marked Failed in one of four ways:
| Trigger | Mechanism |
|---|---|
| The campaign expires without selling out | Housekeeping (check_expired_icos / check_and_update_campaign_status) sets the state to Failed once expiry_timestamp has passed |
| Finalization runs on a campaign that did not reach the token target | The backend marks it Failed and does not create a pool (e.g. finalize_ico is invoked by the authorized caller — the backend’s own finalize flow or an authorized canister — on a non–sold-out campaign) |
| A campaign halted in the Frozen state passes its scheduled end date by more than a fixed 30-day grace without having reached its target | The housekeeping failsafe resolves it to Failed automatically — refunds open without requiring any manual or governance action |
| A sold-out campaign whose finalization is wedged (a recorded finalize-failure freeze) stays wedged for 14 days after the first failure, with at least 24 failed automatic hourly retries recorded | The wedge failsafe resolves it to Failed automatically — after first returning any already-moved funds to the campaign’s custody subaccount (see the sold-out section below) |
The graces leave room to resolve a halt (resume, recover, or finalize through
the normal flows) before refunds are irreversibly triggered; past them, the
transition is automatic — contributor refunds never depend on anyone’s
availability. Every transition into Failed is recorded on-chain, and the
7-day claim window and the residual sweep both anchor at max(recorded failure, expiry) — so whether a campaign fails at expiry, is re-classified
after a halt is resolved (say, ten days past its end date), or is failed by a
failsafe, contributors always get the same full week to claim, and residuals
are never swept before that week closes. A campaign that was deliberately
frozen by a guardian or governance decision is exempt from the sold-out
failsafe: a deliberate stop always resolves through an explicit decision, not
the automatic machinery. The frozen-campaign status box on the LGE page shows
the exact date on which refunds unlock automatically when a failsafe applies.
Every Failed transition goes through a single chokepoint that first proves
custody (“Failed implies refundable”): before the status is written, the
backend asks pool_manager to confirm that the campaign’s raise is not parked
in its pool’s ICP deposit account (the account a finalize ICP leg lands in
before the pool credits it); while it is, the flip is refused by name
(FAILED_FLIP_RAISE_IN_POOL_DEPOSIT_ACCOUNT), nothing changes, and each refused
attempt is written to the campaign’s own log (get_campaign_logs) so the
repeated automatic retries stay visible from the campaign’s register. A campaign
can therefore only flip to Failed with the refundable funds actually sitting in
its custody subaccounts, so a refund can never dead-end on missing funds. The
flip itself is status-only; money moves only when a contributor (or the
authorized-caller bulk path) initiates a refund.
When a sold-out campaign cannot finalize (Frozen after reaching target)
Section titled “When a sold-out campaign cannot finalize (Frozen after reaching target)”The table above covers campaigns that did not reach their target. The opposite case — the curve sells out but finalization fails (most commonly the ICPSwap pool or the liquidity position cannot be created) — follows a deliberately different path:
- Failure freezes, it never fakes success. When the target is reached the campaign
enters the provisional
Finalizingstate and the backend runs the finalization steps (escrow reconcile → pool / liquidity-position creation on ICPSwap, with the ICP deposited from the campaign subaccount → vesting → governance autonomy → terminalCompleted). Any pool/liquidity error rolls the campaign back toFrozen(status-only); a few fail-closed pre-checks leave it inFinalizinginstead. Either way no terminal state is written and no contributor funds leave custody: claims requireCompletedand refunds requireFailed, so both stay blocked while the campaign is wedged. The raise sits in the campaign’s own subaccounts — or, if a finalize ICP leg was already transferred, in the pool’s ICP deposit account (see step 3) — and every step is recorded in the per-campaign[finalize_step]log (get_campaign_finalize_logs), which the LGE page’s finalization status box surfaces. - Retry is automatic, idempotent and convergent. Every finalize-failure freeze
records an on-chain wedge marker (public query
get_wedge_record: first-wedge time, retry counter, last failure reason). Housekeeping then re-drives the wedged finalization once per hour, automatically — most transient third-party failures converge toCompletedunattended within hours. The same re-drive covers a campaign stranded inFinalizing. Explicit levers remain for faster recovery: the ONS guardian (guardian_retry_finalization, Frozen and Finalizing campaigns) and an ONS governance proposal (proposal_retry_add_liquidity). Every step recognizes its own already-done end state (position pre-gate, skip-if-exists mint), so a retry can be repeated safely and never double-moves funds. A deliberate freeze (guardian/governance moderation) clears the wedge marker and stands the automatic machinery down. - A persistently wedged sold-out campaign fails automatically — with custody verified
first (“Failed implies refundable”). Once a wedge has persisted for 14 days from
the first failure AND at least 24 failed automatic retries have been recorded
(proof the failure is persistent and the retry machinery was running), the failsafe
resolves the campaign to
Failedthrough a single verify-custody-then-fail chokepoint. The raise is always in one of three places, andpool_managerreports which:- A campaign whose liquidity position actually exists is never failed: it can only
converge to
Completedthrough the retry machinery. - A campaign whose raise is parked in the pool’s ICP deposit account (a finalize ICP
leg that was transferred to the pool but never credited by the pool’s
deposit) is refused by name (FAILED_FLIP_RAISE_IN_POOL_DEPOSIT_ACCOUNT): refunds draw only from the campaign subaccount and would under-pay with the money one hop away. The failsafe drives the finalize retry instead, whose balance-driven ICP leg completes the deposit from where it stopped; if the pool itself is gone, the parked ICP is recovered through the pool-sweep + ONS withdrawal path (sweep_pool_unused_balance, thenproposal_pool_manager_withdrawback to the campaign subaccount) and resolved again. - Otherwise the raise is entirely in the campaign subaccount and the flip is direct
(identical to the unsold failsafe).
The transition records the failure moment on-chain, so contributors get the standard
full 7-day claim window counted from it, followed by the normal residual sweep.
The DAO can also resolve a wedge before the deadline:
guardian_resolve_wedged_lge(ONS guardian or an approved ONS proposal, available from 48 hours after the first wedge) runs the same chokepoint — it replaces any raw status write, and the raw setter (admin_update_campaign_status, executed byohshii_governance) asks the samepool_managerpre-flight and refuses a directFailedwrite while the pool’s deposit account holds the campaign’s ICP, so refunds can never be opened against an under-funded subaccount.
- A campaign whose liquidity position actually exists is never failed: it can only
converge to
Third-party dependency disclaimer
Section titled “Third-party dependency disclaimer”Pool and liquidity-position creation depend on ICPSwap, a third-party protocol with its own canisters and interfaces. ICPSwap may change those interfaces at any time, and neither OhShii nor the DAO guarantees that the platform is always immediately compatible with such changes. What the architecture does guarantee is custody, not liveness: a finalization blocked by a third-party incompatibility leaves contributor funds in the campaign’s on-chain custody, in a retryable state, until the launch completes — automatically or via a governance retry — or the wedge failsafe (or an early governance resolution) fails it and opens refunds. Contributors’ exit never depends on anyone’s availability.
The claimable “GET REFUND” flow
Section titled “The claimable “GET REFUND” flow”Refunds are user-initiated: there is no automatic payout. A contributor on the LGE page presses “GET REFUND”, which calls the backend method request_refund(campaign_id). The backend validates that the campaign is Failed, that the deadline has not passed, and that the caller has contributions, then marks those contributions refunded in dao_storage atomically before transferring the ICP from custody. Before any ICP leaves the campaign subaccount, pool_manager runs one pre-flight on the campaign’s ICPSwap pool: if the pool’s ICP deposit account (owned by the pool, keyed by pool_manager’s principal) holds more than dust, a finalization leg was transferred there but never credited, and the refund is refused by name (REFUND_RAISE_IN_POOL_DEPOSIT_ACCOUNT) with nothing paid — never paid at a reduced amount from a partly emptied subaccount. The backend rolls the refund mark back and the request can be retried once the parked leg is resolved; a pre-flight that cannot read the balance also refuses (fail closed, retriable).
When the pot is short by ledger fees. Each refund leg is already paid net of one ledger fee —
the participant bears it — so the campaign account is sufficient for every contributor by
construction, no matter how many there are. What can still leave a gap is a recovery of a
raise parked mid-way: that round trip charges the campaign account about five ledger fees
(≈0.0005 ICP), and because every refund pays the exact amount computed for it, the gap would
otherwise land whole on the last person to claim. So when the account is short by at most ten
ledger fees, the refund is still paid, for what the account can afford, and the difference is
recorded by name in the canister log (REFUND_PAID_SHORT_BY_FEES) rather than the contributor being
turned away. A larger shortfall is refused with nothing sent — a gap that size is not fee residue,
and paying a materially reduced amount silently would be worse than refusing. The amount actually
sent is pinned, so retrying re-sends the identical transfer and the ledger discards the duplicate: a
contributor can never be paid twice, and never paid the difference later by accident. Only an
account that cannot cover even one ledger fee is refused outright.
- Deadline. The refund must be claimed within 7 days of the campaign’s failure: its
expiry_timestampfor an ordinary failure, or the recorded failure time when the campaign failed later. After that window,request_refundreturns an error. - Contributors only. A caller with no contribution to the campaign is refused early (
User has no contribution to this campaign): after the Shield check, before the campaign is read and before any public log row. - Concurrency. The backend uses per-campaign and per-user locks, so only one refund is in progress per user per campaign at a time.
- Bulk refund path.
refund_campaign(campaign_id), invoked by the authorized caller, processes all pending refunds for a Failed campaign in one go. It routes each user through the same atomic-mark + subaccount-transfer path, so it is idempotent and a laterrequest_refundfor an already-refunded user cannot double-pay.
See the full flow in Core Workflows.
Per-party refund percentages
Section titled “Per-party refund percentages”| Party | Refund | Notes |
|---|---|---|
| Contributors | 90% of contributed ICP (legacy) / 95% for deferred-referral campaigns | The 95% is 90% net + the held 5% referral band, returned tier-independently |
| Creator | ~70% of ICP spent on their own token contributions | Same contributor↔creator ratio across campaign types |
| OHSHII | Non-refundable | The OHSHII platform share is not returned |
The user-facing summary is ~95% contributors / ~70% creator / OHSHII platform fee non-refundable; only the oldest non-migrated campaigns refund ~90%. The 95% figure applies to deferred-referral campaigns (every current campaign), where the full 5% referral band was held in custody rather than paid out at purchase time and is therefore available to return on failure. For pre-escrow / legacy campaigns the referral subaccount is empty and the entire amount is drawn from the campaign subaccount.
How the percentage is computed (gross vs net)
Section titled “How the percentage is computed (gross vs net)”The backend computes the exact refund and the pool_manager transfers it exactly — it does not re-apply a 90% factor (that double-discount would over-draw a net-funded subaccount). The campaign subaccount holds the net that was forwarded into it:
| Campaign type | Contributor refund basis | Creator |
|---|---|---|
Pre-escrow / legacy (e.g. dao-0000000011) | 90% of contributed ICP | 70% |
| New (escrow) | 90% of contributed (gross) ICP from the campaign subaccount — the same basis as legacy — plus the held 5% referral band from the referral subaccount (~95% total). The escrow changed where the money sits, not what the refund is computed from | Same contributor↔creator ratio (70/90) |
Either way the refund draws only from the net actually held in the subaccount, so it can never over-draw.
Migrated legacy campaigns: no practical difference
Section titled “Migrated legacy campaigns: no practical difference”A migrated legacy campaign — a pre-escrow campaign that now carries the current settings (referral_deferred = Some(true), curve_input_net = Some(false)), such as the long-running showcase LGE dao-0000000011 — behaves exactly like a freshly created escrow campaign in every user-visible respect. There is no migration step a participant or integrator needs to be aware of, and no fund relocation is required:
- Same fund location. Its contributed ICP sits in the standard per-campaign subaccount on
pool_manager(derived from the campaign id) — the identical account a brand-new campaign uses. Nothing is commingled with the treasury. - Same model for new contributions. Any contribution made after migration flows through the per-contribution escrow and the deferred-referral hold, exactly like any other current campaign.
- Same ~95% refund — not 90%. Because the campaign carries
referral_deferred = Some(true), contributors recover 90% net + the held 5% referral band = ~95%, tier-independent — the same figure a new campaign returns, not the 90% that non-migrated pre-escrow campaigns give. The only internal difference is the source of that 5%: for the campaign’s original pre-escrow contributions the per-campaign referral subaccount is empty, sosplit_refund_legs_e8sfolds the 5% into the campaign-subaccount leg — fully covered, because those contributions over-funded the campaign subaccount to ≈ the gross (not just the net). For contributions made after migration the 5% is held in, and returned from, the referral subaccount. Either way the contributor receives ~95%, drawn only from the campaign’s own held subaccounts — never the treasury — and the held funds are sufficient to cover every contributor’s full refund plus the creator’s ~70%.
The pre-escrow vs escrow distinction is therefore an internal accounting detail, not a behavioural one: a migrated legacy campaign and a freshly created one are indistinguishable to participants.
Per-contribution escrow custody
Section titled “Per-contribution escrow custody”Refunds work because contributor ICP is held in custody subaccounts tied to the campaign, never commingled with the platform treasury. Each purchase moves ICP through a per-contribution escrow before it reaches the campaign pool; on a Failed campaign the funds sit in custody and the refund draws them back out.
- Campaign subaccount. Holds the net ICP that funds the LGE.
process_single_refundtransfers the contributor’s campaign leg from here, and the amount is bounded by what the subaccount actually holds, so a refund can never over-draw it. If, after a recovery of a raise parked in the pool’s deposit account, this subaccount is short of a leg by at most ten ledger fees, the leg is paid for what the subaccount can afford, the amount is pinned per contributor so a retry never pays twice, and the shortfall is logged by name (REFUND_PAID_SHORT_BY_FEES); a larger shortfall is refused by name and nothing is paid (see When the pot is short by ledger fees above). - Per-campaign referral subaccount (deferred-referral campaigns). Holds the full 5% referral band. On failure the held band is returned to the contributor as a separate
refund_referral_legfrom this subaccount — this is the difference between the 90% and 95% figures. On legacy campaigns this subaccount is empty and the full amount comes from the campaign subaccount. - Held funds only. The backend’s
split_refund_legs_e8ssplits the refund across the held subaccounts only — never the treasury. Each leg is net of its own ~0.0001 ICP ledger fee, which the participant bears.
Guest fee refund (OHSHII)
Section titled “Guest fee refund (OHSHII)”If a contributor paid the one-time guest fee (charged only to Guest-tier participants on their first contribution), both request_refund and the bulk refund_campaign also return that OHSHII to them on a Failed campaign, alongside the ICP. The exact amount is snapshotted at contribution time, so the refund is independent of any later fee-config change. It is idempotent (recorded per campaign + user, mark-before-pay), and eligibility is read from the recorded payment, not the user’s current tier (tiers can change over time). The refund returns NotOwed if the user is still a participant.
On-chain, atomic, and self-healing
Section titled “On-chain, atomic, and self-healing”- On-chain and atomic. The backend marks contributions refunded in
dao_storageatomically before thepool_managertransfers ICP from custody. There is no off-chain reconciler — on-chain custody state is the source of truth. - No double-pay. Both the user path and the authorized-caller bulk path go through the same atomic mark + subaccount transfer, so a contribution can be refunded at most once.
- 7-day residual sweep. Once a campaign’s 7-day refund window has fully closed, a periodic housekeeping task sweeps any un-refunded residual ICP to the OhShii ecosystem treasury — NOT to the failed campaign’s own SONS DAO (the campaign failed, so it does not retain these funds). Both legs land in the same single account: the OhShii platform treasury, which is the launcher backend canister’s main account. The campaign subaccount residual is transferred there directly. The per-campaign referral subaccount is drained by
drain_campaign_referralwith a zero SONS amount — and that leg only fires when its amount is at least the ICP ledger fee, so a zero amount suppresses it entirely and the whole referral residual flows to the same backend treasury. The sweep is fully on-chain, balance-driven, and at-most-once per campaign. Un-refunded OHSHII guest fees in the per-campaign guest-fee subaccount are forwarded to the treasury by the OHSHII leg of the same sweep. That treasury is ONS-controlled, so recovering any swept residual requires an ONS governance withdraw proposal — it is not lost, but it can only be released by a community vote.
For the end-to-end sequence — including the finalization success path that this failure path mirrors — see the full flow in Core Workflows.