# @masterpeach/launchpad-sdk

Typed, Viem-only SDK for the ARC Launchpad **Production V2** contracts. A launch sells its token on
a Pons-style bonding curve until the curve reaches a fixed token-side boundary, then graduates into
a Uniswap V4 pool in two permissionless, separately retryable steps.

This package targets Production V2 only. It is **not** an upgrade of the previous V3 SDK: the
contract family was rewritten, so no address, ABI, or type from V3 carries over. The referrer
system, the standalone creator-denylist registry, the pause switch, one-step migration, and the
WAD-scaled curve are all gone.

## Install

```bash
pnpm add @masterpeach/launchpad-sdk viem
```

## The four things most likely to trip you up

Read these before writing any integration; each one produces a call that looks right and fails.

**1. Every launch has its own `BondingCurve`. Trades go to the curve, not the factory.** The factory
only creates launches and answers questions about them. Resolve the curve with `getCurveAddress`
(or read it from `TokenCreated` / `getLaunchRecord`) and cache it per token.

**2. Approval spenders differ by operation, and the forwarder address moved.** A trade's funds are
pulled by the **curve**. A direct `createToken` is pulled by the **factory**. Either forwarder entry
point is pulled by the **forwarder** itself. Read the forwarder from the deployment record rather
than pinning a literal — it was rotated on 2026-09-07 to the Holder-aware forwarder, and allowances
granted to the old one do not carry over. `prepareApprove` requires an explicit `spender`;
`launchpadCreationSpender(deployment, entryPoint)` returns the right one.

**3. The opening snipe tax is resolved against the trade's token recipient, not its payer.** A
router buying on someone's behalf neither lends nor borrows its own exemption. Pass the real
recipient to `prepareBuy`, or you will quote the wrong rate and set an unreachable `minTokenOut`.

**4. A launch can graduate inside someone else's transaction.** RC2 graduates automatically when a
buy — including a creation's initial buy — takes the last sellable token. Never assume a freshly
created launch is `notGraduated`, and never call `graduate` without re-reading the status first.
The SDK sets an explicit gas limit on those transactions; if you rebuild one from `request` by hand,
keep the `gas` field, because a bare estimate leaves the in-transaction graduation short and it
always reverts out-of-gas.

## Create a client

```ts
import {
  arcTestnetLaunchpadDeployment,
  createLaunchpadFactoryClient,
  defineLaunchpadChain,
  verifyLaunchpadDeployment,
} from "@masterpeach/launchpad-sdk";

// `arcMainnetLaunchpadDeployment` (chain 5042) is the other one. They share no address and no
// contractVersion; neither is a registered default, so pick one by name.
const deployment = arcTestnetLaunchpadDeployment;

const chain = defineLaunchpadChain({
  slug: "arc-testnet",
  chainId: deployment.chainId,
  family: "evm",
  nativeCurrency: { symbol: "ARC", decimals: 18 },
  rpcUrls: ["https://rpc.testnet.arc.io"],
  explorerUrl: "https://testnet.arcscan.app",
  apiBaseUrl: "https://api.testnet.arc.example",
  realtimeUrl: "wss://rpc.testnet.arc.io",
  deployment,
  // One factory serves several quote tokens, so list every currency you offer.
  quoteTokens: deployment.economics.quoteTokens.map((quoteToken) => ({
    address: quoteToken.address,
    decimals: quoteToken.decimals,
    symbol: quoteToken.symbol,
  })),
  capabilities: { create: true, bondingTrade: true, partnerAttribution: true },
  confirmations: 2,
});

await verifyLaunchpadDeployment(publicClient, chain.deployment);
const client = createLaunchpadFactoryClient({ publicClient, walletClient, chain });
```

`verifyLaunchpadDeployment` checks three separable things, because they fail for different reasons:
the factory's runtime code hash (identity), every module address the factory reports plus the hook's
own view of the V4 PoolManager (wiring), and the fee policy, partner policy, snipe tax, launch fee,
creator ceilings and every pinned quote token and launch config (economics). Run it before trusting
a newly configured deployment — V2 wires its dependencies with individual post-construction setters,
so a half-configured deployment looks fine until a call reaches address zero.

## Read a launch

```ts
// Pin a block. A curve snapshot is assembled from many getters, so an unpinned read can mix
// heights and produce reserves that never simultaneously existed.
const blockNumber = await publicClient.getBlockNumber();

const state = await client.getTokenState(token, { blockNumber });
state.record;      // frozen per-launch economics (curve address, quote token, tax, fee shares)
state.curveState;  // live reserves, sellable supply, graduation readiness
state.graduation;  // "notGraduated" | "swept" | "poolCreated" | "emergencyExited"

const progress = await client.getCurveProgress(token, { blockNumber });
progress.progressBps; // measured on the token side, so it saturates at exactly 100%
```

An address a user pasted must go through `isLaunchToken` first. Unlike V3, the record getter does
not revert for a stranger address — it returns a zero-filled struct — so skipping the check leaves
every later call pointed at address zero.

## Trade

```ts
const curve = await client.getCurveAddress(token);

// The curve pulls the quote, so the curve is the spender.
await client.send(
  await client.prepareApprove({ asset: quoteToken, spender: curve, amount: quoteIn }),
);

const buy = await client.prepareBuy({
  token,
  quoteInRaw: quoteIn,
  slippage: { bps: 100 },
  deadline,
  tokenRecipient,          // determines the snipe tax; defaults to the connected account
  partnerId,               // optional attribution; bytes32(0) or omitted means none
});
const result = await client.wait(await client.send(buy));
```

`buy.preview` reports `tokenOut`, `quoteSpent`, `refund`, the three fee components and the
`snipeTaxBps` actually applied. On the final buy of a curve, `quoteSpent` is below the amount you
offered and the difference is refunded atomically — approve and display `quoteSpent`, never the
user's input.

Selling moves the launch token, so it needs its own approval, also to the curve. A sell never pays
snipe tax.

### The slippage caveat on the last buy

The contract scales the minimum output by how much was actually spent:
`mulDiv(minTokenOut, quoteSpent, quoteInRaw)`, rounded up since RC2 (RC1 rounded down, so the
rounding now favours the trader). It still weakens the guard in proportion to the refund. Offering roughly twice what the final fill needs halves the effective minimum, and past
about that point no `minTokenOut` a trader could physically supply — not even the entire token
supply — can bind at all.

If you want a real floor on the closing buy, quote the fill first and offer close to it. Do not
offer a large round number and rely on `minTokenOut`.

## Create a token

```ts
import { UNCHECKED_ECONOMICS, launchpadEconomicsHashFromDeployment } from "@masterpeach/launchpad-sdk";

const gate = await client.getLaunchGate(creator);
// gate.canLaunch, and the three inputs behind it: launchEnabled / whitelisted / denied.
// The denylist outranks everything: a denied creator cannot launch even if also whitelisted.

const params = {
  name: "Peach Demo",
  symbol: "PEACH",
  logo: "ipfs://...",
  description: "...",
  socials: { twitter: "", telegram: "", discord: "", website: "", farcaster: "" },
  quoteToken,
  launchConfigId: 0n,
  // Commits the launch to one exact economics template. Optional: bytes32(0) skips the check.
  expectedEconomics: launchpadEconomicsHashFromDeployment(client, { quoteToken, launchConfigId: 0n }),
  launchTaxExemptions: [],
  creatorFeeRecipient: zeroAddress, // resolved to the creator on chain; preserved as given
  buybackEnabled: true,
  creatorTaxBps: 100,
  creatorInitialBuyQuoteRaw: 0n,
  creatorInitialBuyRecipient: creator,
  minInitialTokenOut: 0n,
  deadline,
  salt,
};

const predicted = await client.predictAddresses(params, creator); // { token, curve }
const created = await client.wait(await client.send(await client.prepareCreateToken(params)));
const launch = created.events.tokenCreated[0]; // the only authority that a launch happened
```

`prepareCreateToken` validates every rule that is a pure function of the parameters and reports
**all** violations at once, so a form can mark every bad field in one pass. Byte lengths are UTF-8,
matching the contract: a name of emoji or CJK text can pass a naive `.length <= 64` check and still
revert.

Two rules are easy to miss. A salt is single-use per creator (`isLaunchpadSaltUsed` to check). And
launch tax exemptions are de-duplicated against the **automatic** ones — the creator, the fee
recipient, and the initial buy recipient are already exempt, so listing the creator is a revert,
not a redundant entry.

`prepareLaunchAndBuy` routes through the forwarder, which preserves creator identity when another
contract initiates the launch. **Its allowance goes to the forwarder, not the factory** —
RC2 made the forwarder pull the exact amounts, hold a temporary factory approval for the duration of
the call, then revoke it and refund the residue. This is the reverse of RC1, so an integration
carried over from it approves the wrong contract.

A creation is also not guaranteed to leave the launch on the curve. If the initial buy takes the
last sellable token, the factory graduates it in the same transaction — read
`getGraduation(token)` after `TokenCreated` rather than assuming `notGraduated`.

### Holder Fee Sharing

An optional mode in which the launch's creator fees go to a per-launch distributor that shares them
with holders, instead of to a single recipient.

```ts
const prepared = await client.prepareLaunchWithHolderFeeSharing({
  ...params,
  creatorFeeRecipient: zeroAddress, // required — see below
});
prepared.preview.distributor; // predicted, and installed by the transaction itself
```

One transaction creates the distributor, installs it as `creatorFeeRecipient`, creates the token and
curve, and verifies every binding; any failure reverts the lot.

**`creatorFeeRecipient` must be the zero address.** The forwarder derives the recipient itself.
Predicting the distributor and passing it in — the natural move once you know one is involved — is
exactly what the contract rejects.

The distributor is keyed by `(creator, salt)`, never by the token: the token's own address depends
on the distributor through `creatorFeeRecipient`, so predicting it the other way round would be
circular. `predictLaunchpadHolderDistributor` gives you the address before signing.

**It is not irreversible.** Nothing on chain pins the distributor in place: the current recipient
can `transferCreatorFeeRecipient` fees away from it, and governance can override the recipient on
the usual delay. What survives is the registry entry and whatever the distributor already holds. Do
not tell users that enabling holder sharing locks it in.

**Buyback reaches holders too, but late and as tokens.** With buyback enabled, part of the creator's
gross buys the launch token into the vault and vests over five years. `harvest` pulls whatever has
vested into the distributor, so it does end up shared — as the coin side of a later epoch, not as
quote at trade time. "All creator fees shared immediately" and "buyback enabled" are still different
promises.

Claiming is covered too. Revenue reaches holders in epochs: the distributor accumulates creator
fees, its publisher opens an epoch with a merkle root, holders claim against it for 90 days, and the
remainder rolls over.

```ts
const { distributor } = await getLaunchpadHolderBinding(ctx, token);
const epoch = await getLaunchpadHolderEpoch(ctx, { distributor, epochId });

// `allocation` comes from the distributor project's proof service.
if (!launchpadHolderAllocationMatchesRoot(allocation, epoch.root)) throw new Error("stale proof");
await client.send(await prepareLaunchpadHolderClaim(ctx, { distributor, allocation }));
```

Each allocation pays **both** sides — quote revenue and launch tokens — in one claim, and anyone may
submit it, since the payout follows `allocation.account` rather than the sender. Validate against
the epoch root first: the contract's `InvalidMerkleProof` cannot say whether the amount, the account
or the epoch was wrong, and the commonest cause is using the proof service's own database epoch id
where the contract wants `onchain_epoch_id`.

Publishing epochs and serving proofs stay with the distributor project — see the
[integration guide](docs/integration-guide.md#holder-claims).

Afterwards, the receipt carries **both** halves of the creation — `HolderSharingLaunchCreated` from
the forwarder and `DistributorCreated` from the distributor factory — in `events.holderSharing`.
They are deliberately not merged: a launch carrying only one of the two is not an official
holder-sharing launch. `getHolderDistributor(token)` and `isHolderDistributor(address)` are the
authoritative reads. Any launch may nominate any `creatorFeeRecipient` it likes, so do not present
an arbitrary one as a holder distributor without checking the registry.

## Graduation

```ts
const graduation = await client.getGraduation(token);

// Step 1: sweep the curve's assets to the executor. Permissionless once the curve closes — and
// frequently already done for you, since RC2 graduates automatically at the end of the buy that
// empties the curve. prepareGraduate refuses rather than building a doomed transaction.
if (graduation.status === "notGraduated" && state.curveState.graduationReady) {
  await client.send(await client.prepareGraduate(token));
}

// Step 2: create the V4 pool and mint the position. Permissionless and retryable.
if (graduation.status === "swept") {
  await client.send(await client.prepareCreateGraduatedPool(token));
}

const poolId = await client.getPoolId(token); // bytes32(0) until the pool exists
```

`swept` is a resting state, not a transient one. If step two fails, the assets stay safely with the
executor and the call can simply be retried — this is the substantive improvement over V3's
all-or-nothing `migrate`.

When an automatic graduation attempt reverts, RC2 emits `AutoGraduationFailed` and the **trade or
creation still succeeds**. `parseLaunchpadReceiptEvents` puts these in their own
`autoGraduationFailures` bucket precisely so a UI cannot mistake one for a failed purchase; the
launch simply stays `notGraduated` and the ordinary permissionless path picks it up.

## Fees

Fees accrue on the curve, are swept out in a separate step, and land in `FeeEscrow` where the
beneficiary claims them.

```ts
const pending = await client.getPendingFees(curve);   // not yet swept
const claimable = await client.getClaimableBalance({ recipient, asset }); // in the escrow
await client.send(await client.prepareClaim({ asset }));  // claims your own balance
```

**The escrow keys balances by `(recipient, asset)` and nothing else.** It does not record which
launch a credit came from, so a recipient reused across several launches accumulates one
indistinguishable pool per asset that the chain cannot attribute back. If you need per-launch
attribution, use a distinct recipient address per launch, or reconstruct it off-chain from
`CurveFeesSwept` / `FeeCredited` events.

## Partners

A partner is declared per trade rather than bound to a token at creation, and the registry is read
live, so a share change applies from the next trade onward and never retroactively.

```ts
const usability = await client.getPartnerUsability(partnerId);
```

Three outcomes that a UI must not collapse:

| | `usable` | `earning` | effect on the trade |
|---|---|---|---|
| `bytes32(0)` | yes | no | trades normally, no attribution |
| registered, disabled | yes | no | trades normally, recorded in the event, earns nothing |
| unregistered, non-zero | **no** | no | **reverts the whole trade** |

For a swap on a graduated V4 pool, encode the id with `encodeLaunchpadPartnerHookData`. The frozen
ABI is exactly `abi.encode(bytes32)`, and empty bytes for no partner; the hook validates the length
strictly, so anything else reverts rather than being ignored.

## Buyback and emergency exit

```ts
const vest = await client.getBuybackVest(token);
await client.send(await client.prepareBuybackRelease(token));
```

The vesting clock is a single five-year weighted average over everything ever locked, not per-deposit
tranches: each new deposit pushes `vestingEnd` further out for the whole balance, so a launch with
steady buyback flow never fully vests while the flow continues.

`prepareSetBuybackEnabled` has an asymmetric permission enforced on chain: the current creator fee
recipient may turn buyback on **or** off, while the protocol owner may only turn it **off**.

```ts
import { launchpadEmergencyExitAvailableAt } from "@masterpeach/launchpad-sdk";

const availableAt = launchpadEmergencyExitAvailableAt(graduation); // undefined unless swept
const { distributor } = await client.getEmergencyDistributor({ launchToken: token, quoteToken });
const quote = await client.quoteEmergencyRedeem({ distributor, tokenIn });
```

Triggering the exit is **owner-only**, unlike both graduation steps — a stranded holder can redeem
once governance has acted, but cannot pull the lever themselves.

The emergency exit is narrower than "a stuck launch": it requires the launch to already be `swept`
and measures seven days from `sweptAt`, because it exists for the one gap the two-step graduation
opens. A launch still trading on its curve has nothing to exit from, and there is no expedited path.

Redemption divides by the token's **live** `totalSupply()`, re-read every time rather than
snapshotted. A holder who burns without redeeming therefore raises everyone else's payout per token,
and the last redeemer takes the entire remainder. Quotes drift with other people's behaviour, so
re-read immediately before submitting.

## Keeper operations

Curve sweeps and the V4 pool's epoch-paged partner settlement live in the keeper surface. Note the
argument asymmetry that is easy to get wrong: `beginPoolFeeSweep` takes the **token**, while
`settlePoolFeeSweepPartners` and `finalizePoolFeeSweep` take the **poolId** (resolve it with
`getLaunchpadPoolId`). There is no protocol limit on partner count or page size — any batch ceiling
is your own gas budget.

RC2 changed how a sweep fails. Hitting the internal price-impact ceiling no longer reverts the
batch: the hook converts what it can, leaves the rest pending, and carries an unsettled partner
remainder into the next epoch. `InternalPriceImpactExceeded` is gone, so there is nothing to catch
and retry — read `getLaunchpadPartnerEpochAccounting({ poolId, epochId })` instead, keep paging
while `allPartnersSettled` is false, finalize only once `finalizeReady` is true (the hook checks
five conditions, not just the partner count), and treat
`partnerLaunchInputConsumed < partnerLaunchInputTotal` as work still outstanding rather than as an
error.

One trap that predates RC2: `MemeHook.settlingEpoch` is stored **one-based**, zero meaning "no
sweep open". Take the epoch id from `getLaunchpadPoolSweepState(...).settlingEpochId`, which decodes
it; the raw slot is one too high and makes every settle and finalize revert.

A curve `sweepFees` can also graduate a launch as a side effect: its internal buyback may take the
last sellable tokens. Re-read the graduation status after every successful sweep.

## Errors

Every contract revert this deployment can produce maps to a stable, catchable type; the mapping is
asserted exhaustive against the generated ABIs. The ones worth branching on:

- `LaunchpadTradingClosedError` — the curve reached its boundary. Permanent; trade the V4 pool.
- `LaunchpadTradeFeeConsumesInputError` — fees exceed the input. Increase size or wait for decay.
- `LaunchpadInsufficientQuoteReserveError` — the sell exceeds the curve's real, non-phantom reserve.
- `LaunchpadSlippageError`, `LaunchpadDeadlineExpiredError` — re-quote and rebuild.
- `LaunchpadCreatorDeniedError` vs `LaunchpadLaunchNotPermittedError` — a sanction on one account
  versus a gate closed for everyone not allowlisted. Different remedies.
- `LaunchpadEconomicsHashMismatchError` — governance moved the template; re-read and rebuild.
- `LaunchpadApprovalRequiredError` — carries the exact `spender`, which differs by operation.
- `LaunchpadPhaseUnavailableError` from `prepareGraduate` — under RC2 this usually means someone
  else's trade already graduated the launch. Check `error.status`:
  `launchpadGraduateIsIdempotentSuccess(status)` is true for `swept` and `poolCreated`, which a
  keeper should treat as done rather than alert on.

## Mainnet

Same contracts, same economics, one difference left. Mainnet rotated onto the Holder-aware forwarder
on 2026-09-08 and bound a distributor factory to it, so both creation paths and Holder Fee Sharing
work exactly as they do on the testnet — the addresses differ, nothing else. What still differs is
the quote token: USDC is the only one, and tUSDG is a testnet faucet token that leaves nothing
tradable if carried over.

Public launch is open and create, buy and sell have been exercised on-chain; graduation has not.
Details in the [integration guide](docs/integration-guide.md#mainnet-what-differs).

## Package entry points

- `@masterpeach/launchpad-sdk` — clients, actions, math, types and errors
- `@masterpeach/launchpad-sdk/abi` — generated Production V2 ABIs
- `@masterpeach/launchpad-sdk/core` — shared chain-agnostic primitives

See the [integration guide](docs/integration-guide.md) for the full lifecycle, indexing notes,
keeper runbooks and a go-live checklist.
