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 automatic Failed transition goes through a single chokepoint that first proves — or restores — custody (“Failed implies refundable”): a campaign can only flip to Failed with the refundable funds actually sitting in its custody subaccounts, so a refund can never dead-end on missing funds. For campaigns whose funds never moved, the flip 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 → LP drain → pool / liquidity-position creation on ICPSwap → 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 the single LP transfer already landed, on the pool_manager main account, tracked by the public finalize drain pin (get_finalize_drain_pin) and 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 (drain pin, 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 restored 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 repatriate-then-fail chokepoint:
    • If the finalize LP drain already landed on pool_manager (pin drained=true), the exact pinned amount (plus the drain overhead) is first repatriated on-chain from the pool_manager main account back into the campaign’s custody subaccount — an at-most-once, idempotent transfer (public record: get_repatriation_record). Only after the subaccount balance is verified restored does the campaign flip to Failed.
    • If nothing ever moved toward the pool, the flip is direct (identical to the unsold failsafe).
    • A campaign whose liquidity position actually exists is never failed: it can only converge to Completed through the retry machinery. 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 repatriate-then-fail chokepoint — it replaces any raw status write, and the raw setter (admin_update_campaign_status, executed by ohshii_governance) now refuses a direct Failed write while a drained, non-repatriated pin exists, so refunds can never be opened against an empty 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.

sequenceDiagram
participant User
participant Backend
participant Storage as dao_storage
participant PoolMgr as pool_manager
participant ICPLedger
participant OHSHIILedger as "OHSHII ledger"
Note over Backend,Storage: Campaign is Failed (expiry or finalize on non-sold-out)
User->>Backend: "request_refund(campaign_id)"
Backend->>Storage: get_campaign
Backend->>Backend: validate state Failed, deadline, has contributions
Backend->>Storage: mark_contributions_refunded_atomic(user, campaign_id)
Storage-->>Backend: Ok
Backend->>Backend: compute refund + split into (campaign leg, referral leg)
Backend->>PoolMgr: process_single_refund(campaign_id, user, campaign_leg)
PoolMgr->>ICPLedger: transfer ICP from campaign subaccount to user
alt referral leg > 0 (deferred-referral campaign)
Backend->>PoolMgr: refund_referral_leg(campaign_id, user, referral_leg)
PoolMgr->>ICPLedger: transfer held referral ICP from referral subaccount to user
end
opt Guest paid the one-time OHSHII guest fee
Backend->>OHSHIILedger: refund OHSHII guest fee to user
end
Backend-->>User: "RefundResult::Ok"
  • Deadline. The refund must be claimed within 7 days of campaign.expiry_timestamp. After that window, request_refund returns an error.
  • 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 the simple 90% contributors / 70% creator / OHSHII non-refundable model. The 95% figure applies specifically to deferred-referral campaigns, 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 — the same basis as legacy. 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.
  • 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.