Skip to content

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.

A campaign is marked Failed in one of four ways:

TriggerMechanism
The campaign expires without selling outHousekeeping (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 targetThe 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 targetThe 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 recordedThe 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:

  1. Failure freezes, it never fakes success. When the target is reached the campaign enters the provisional Finalizing state 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 → terminal Completed). Any pool/liquidity error rolls the campaign back to Frozen (status-only); a few fail-closed pre-checks leave it in Finalizing instead. Either way no terminal state is written and no contributor funds leave custody: claims require Completed and refunds require Failed, 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.
  2. 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 to Completed unattended within hours. The same re-drive covers a campaign stranded in Finalizing. 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.
  3. 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 Failed through a single verify-custody-then-fail chokepoint. The raise is always in one of three places, and pool_manager reports which:
    • A campaign whose liquidity position actually exists is never failed: it can only converge to Completed through 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, then proposal_pool_manager_withdraw back 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 by ohshii_governance) asks the same pool_manager pre-flight and refuses a direct Failed write while the pool’s deposit account holds the campaign’s ICP, so refunds can never be opened against an under-funded subaccount.

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.

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.

"OHSHII ledger"ICPLedgerpool_managerdao_storageBackendUser"OHSHII ledger"ICPLedgerpool_managerdao_storageBackendUserCampaign is Failed (expiry or finalize on non-sold-out)alt[referral leg > 0 (deferred-referral campaign)]opt[Guest paid the one-time OHSHII guest fee]"request_refund(campaign_id)"get_campaignvalidate state Failed, deadline, has contributionsmark_contributions_refunded_atomic(user, campaign_id)Okcompute refund + split into (campaign leg, referral leg)process_single_refund(campaign_id, user, campaign_leg)transfer ICP from campaign subaccount to userrefund_referral_leg(campaign_id, user, referral_leg)transfer held referral ICP from referral subaccount to userrefund OHSHII guest fee to user"RefundResult::Ok"
  • Deadline. The refund must be claimed within 7 days of the campaign’s failure: its expiry_timestamp for an ordinary failure, or the recorded failure time when the campaign failed later. After that window, request_refund returns 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 later request_refund for an already-refunded user cannot double-pay.

See the full flow in Core Workflows.

PartyRefundNotes
Contributors90% of contributed ICP (legacy) / 95% for deferred-referral campaignsThe 95% is 90% net + the held 5% referral band, returned tier-independently
Creator~70% of ICP spent on their own token contributionsSame contributor↔creator ratio across campaign types
OHSHIINon-refundableThe 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 typeContributor refund basisCreator
Pre-escrow / legacy (e.g. dao-0000000011)90% of contributed ICP70%
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 fromSame 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, so split_refund_legs_e8s folds 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.

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_refund transfers 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_leg from 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_e8s splits 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.

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 and atomic. The backend marks contributions refunded in dao_storage atomically before the pool_manager transfers 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_referral with 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.