# @masterpeach/launchpad-sdk

面向 ARC Launchpad **Production V2** 合约的 TypeScript SDK，仅依赖 Viem。一个 Launch 先在 Pons 式
bonding curve 上出售代币，卖到固定的 Token 侧边界后，通过两个可独立重试的无许可步骤毕业到 Uniswap V4 池。

本包只支持 Production V2。它**不是**上一版 V3 SDK 的升级：合约体系整个重写了，V3 的地址、ABI、类型
没有任何一个能沿用。Referrer 系统、独立的 creator denylist 注册表、暂停开关、一步式迁移、WAD 定点曲线
全部不复存在。

## 安装

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

## 最容易踩的四个坑

写任何对接代码之前先看这四条，每一条都会让调用**看起来对但实际失败**。

**1. 每个 Launch 有自己的 `BondingCurve`，交易发给 curve，不是 factory。** Factory 只负责创建和查询。
用 `getCurveAddress`（或从 `TokenCreated` / `getLaunchRecord` 里取）解析出 curve 并按 token 缓存。

**2. 授权的 spender 因操作而异，而且 forwarder 地址换过。** 交易资金由 **curve** 拉取；直接
`createToken` 由 **factory** 拉取；走 forwarder 的两个入口都由 **forwarder** 自己拉取。forwarder
地址请从 deployment 读，不要写死——2026-09-07 已轮换到 Holder-aware forwarder，授权给旧地址的额度
不会自动转移。`prepareApprove` 要求显式传 `spender`；用
`launchpadCreationSpender(deployment, entryPoint)` 拿到正确的那个。

**3. 开盘狙击税按交易的 token recipient 判定，不是付款人。** Router 代付时既不会借出也不会继承自己的
豁免资格。调 `prepareBuy` 时要传真实的 recipient，否则会按错误的费率报价、并设出一个永远达不到的
`minTokenOut`。

**4. 一个 Launch 可能在别人的交易里毕业。** RC2 会在某笔买入（包括创建时的首购）吃掉最后一份可售
Token 时自动毕业。不要假设刚创建的 Launch 一定是 `notGraduated`，也不要不重新读状态就调 `graduate`。
SDK 会给这类交易显式设置 gas limit；如果你从 `request` 手工重建交易，请保留 `gas` 字段——只用估算值
会让交易内的自动毕业 gas 不足、必然 revert。

## 创建客户端

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

// 另一个是 `arcMainnetLaunchpadDeployment`（chain 5042）。两者没有任何地址相同，contractVersion 也
// 不同；都不是注册的默认值，按名字选。
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,
  // 一个 factory 支持多个 QuoteToken，把你要提供的币种都列出来。
  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` 分三类校验，因为它们失败的原因完全不同：factory 的 runtime code hash
（身份）、factory 报告的每个模块地址加上 hook 自己看到的 V4 PoolManager（接线）、以及 fee policy、
partner policy、狙击税、launch fee、creator 上限和每个 pinned 的 QuoteToken 与 LaunchConfig（经济参数）。
接入新部署前一定要跑：V2 的依赖是构造后用一组独立 setter 逐个接上的，接错一半的部署在调用真正打到零地址
之前看起来一切正常。

## 读取 Launch 状态

```ts
// 先 pin 住区块。curve 快照是由很多个 getter 拼出来的，不 pin 会混用不同高度，
// 拼出一个从未同时存在过的储备状态。
const blockNumber = await publicClient.getBlockNumber();

const state = await client.getTokenState(token, { blockNumber });
state.record;      // 创建时冻结的经济参数（curve 地址、quote token、creator tax、各项分成）
state.curveState;  // 实时储备、可售量、是否已到毕业边界
state.graduation;  // "notGraduated" | "swept" | "poolCreated" | "emergencyExited"

const progress = await client.getCurveProgress(token, { blockNumber });
progress.progressBps; // 按 Token 侧衡量，所以恰好封顶在 100%
```

用户粘贴进来的地址必须先过 `isLaunchToken`。和 V3 不同，V2 的 record getter 对陌生地址**不 revert**，
而是返回一个全零结构体——跳过这个检查会让后续每一次调用都打到零地址。

## 交易

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

// curve 拉取 quote，所以 spender 是 curve。
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,          // 决定狙击税；不传则用当前连接账户
  partnerId,               // 可选归因；bytes32(0) 或不传表示无 Partner
});
const result = await client.wait(await client.send(buy));
```

`buy.preview` 会给出 `tokenOut`、`quoteSpent`、`refund`、三项费用明细，以及实际适用的 `snipeTaxBps`。
在一条 curve 的收尾买入里，`quoteSpent` 会小于你报的金额，差额在同一笔交易里原子退回——授权和展示都要用
`quoteSpent`，不要用用户输入的数字。

卖出动的是 launch token，需要它自己的授权，spender 同样是 curve。卖出永远不收狙击税。

### 收尾买入的滑点陷阱

合约按实际花费等比缩放最低成交量：`mulDiv(minTokenOut, quoteSpent, quoteInRaw)`，RC2 起向上取整
（RC1 是向下取整，所以现在的舍入对用户略有利）。但这仍然会让保护**按退款比例等比减弱**。报价约为实际所需两倍时，有效最低量直接砍半；再往上，任何用户物理上能填的 `minTokenOut`
（哪怕填满整个 supply）都无法生效。

想在收尾那笔上要一个真实的下限，就先报价、再按实际需要的金额下单；**不要**报一个大整数指望
`minTokenOut` 兜底。

## 创建 Token

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

const gate = await client.getLaunchGate(creator);
// gate.canLaunch，以及背后的三个输入：launchEnabled / whitelisted / denied。
// Denylist 优先级最高：被拉黑的 creator 即使同时在白名单里也不能创建。

const params = {
  name: "Peach Demo",
  symbol: "PEACH",
  logo: "ipfs://...",
  description: "...",
  socials: { twitter: "", telegram: "", discord: "", website: "", farcaster: "" },
  quoteToken,
  launchConfigId: 0n,
  // 把这次创建锁定到一份确切的经济参数模板。可选：传 bytes32(0) 就跳过校验。
  expectedEconomics: launchpadEconomicsHashFromDeployment(client, { quoteToken, launchConfigId: 0n }),
  launchTaxExemptions: [],
  creatorFeeRecipient: zeroAddress, // 链上会解析成 creator 本人；这里原样保留不代填
  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]; // 唯一能证明创建成功的依据
```

`prepareCreateToken` 会校验所有「纯粹由参数决定」的规则，并**一次性返回全部违规项**，这样表单可以一遍
标出所有错误字段。长度按 UTF-8 字节算，和合约一致：emoji 或中文的名字能通过朴素的 `.length <= 64`
检查，但链上照样 revert。

有两条规则容易漏。salt 每个 creator 只能用一次（用 `isLaunchpadSaltUsed` 查）。以及豁免地址会和
**自动豁免**的那几个一起去重——creator、fee recipient、首购 recipient 本来就已豁免，所以把 creator
自己填进 `launchTaxExemptions` 会直接 revert，不是无害的冗余项。

`prepareLaunchAndBuy` 走 forwarder，用于在由另一个合约发起创建时保留 creator 身份。
**它的授权给 forwarder，不是 factory**——RC2 改成由 forwarder 精确收款、在调用期间临时授权 factory、
调用结束后撤销授权并退回余额。这与 RC1 正好相反，从 RC1 迁过来的代码会授权错对象。

创建也不保证 Launch 还留在内盘：如果首购吃掉了最后一份可售 Token，factory 会在同一笔交易里完成毕业。
收到 `TokenCreated` 后请读 `getGraduation(token)`，不要默认它是 `notGraduated`。

### Holder Fee Sharing（按持有人分成）

可选模式：Launch 的 creator 费用不再进单一收款地址，而是进一个「每个 Launch 一份」的 distributor，
由它与持有人分成。

```ts
const prepared = await client.prepareLaunchWithHolderFeeSharing({
  ...params,
  creatorFeeRecipient: zeroAddress, // 必须为零地址，见下
});
prepared.preview.distributor; // 预测出来的地址，也是这笔交易将要安装的那个
```

一笔交易内完成：创建 distributor → 装成 `creatorFeeRecipient` → 创建 token 与 curve → 校验所有绑定。
任何一步失败整笔回滚，不会留下半成品。

**`creatorFeeRecipient` 必须是零地址。** recipient 由 forwarder 自己推导。知道有 distributor 之后
自己预测一个填进去——这个最自然的动作，恰恰是合约明确拒绝的。

distributor 由 `(creator, salt)` 决定，**不**由 token 决定：token 自己的地址要经 `creatorFeeRecipient`
依赖 distributor，反过来预测会循环。签名前可以用 `predictLaunchpadHolderDistributor` 拿到地址。

**它不是不可逆的。** 链上没有任何东西把 distributor 钉死：当前 recipient 可以用
`transferCreatorFeeRecipient` 把后续收益转走，治理也能按常规延迟覆盖 recipient。留下来的只有注册表
记录和 distributor 已经收到的资产。不要向用户承诺"开启 Holder Sharing 后就锁死了"。

**Buyback 最终也会分给持有人，但晚、而且是 token 形式。** 开了 buyback 之后，creator gross 的一部分
会被用来买入 LaunchToken 存进 vault、按五年释放；`harvest` 会把已归属的那部分拉进 distributor，所以它
确实会被分掉——只是作为后续某一期的 coin 侧，而不是交易当时的 quote。"全部 creator fee 立即分给持有人"
和"开启 buyback"仍然是两个不同的承诺。

领取也覆盖了。收益按 Epoch 发放：distributor 累积 creator 费用，publisher 用一个 Merkle Root 开一期，
持有人在 90 天窗口内凭 proof 领取，没领完的滚回池子留给下一期。

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

// allocation 来自 distributor 项目的 Proof 服务
if (!launchpadHolderAllocationMatchesRoot(allocation, epoch.root)) throw new Error("stale proof");
await client.send(await prepareLaunchpadHolderClaim(ctx, { distributor, allocation }));
```

每份 allocation **同时**发两边——quote 收益和 launch token，一次领完；而且任何人都能提交，因为付款按
`allocation.account` 走而不是按发起人。领之前先对着 epoch root 本地校验：合约的 `InvalidMerkleProof`
说不出到底是金额、账户还是 epoch 错了，而最常见的原因是把 Proof 服务自己的数据库 epoch id 当成了链上
id——链上要的是 `onchain_epoch_id`。

发布 Epoch 和提供 Proof 仍属于 distributor 项目，见[对接指南](docs/integration-guide.md#holder-claims)。

交易回执里会**同时**出现两半：forwarder 的 `HolderSharingLaunchCreated` 与 distributor factory 的
`DistributorCreated`，都在 `events.holderSharing` 里。它们**故意不合并**——只带其中一个的 Launch
不是官方的 Holder Sharing Launch。权威读法是 `getHolderDistributor(token)` 与
`isHolderDistributor(address)`。任何 Launch 都可以把 `creatorFeeRecipient` 填成任意地址，所以不查注册表
就把它标成 holder distributor 是不对的。

## 毕业

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

// 第一步：把 curve 的资产 sweep 给 executor。curve 关闭后任何人都能调——而且常常已经被别人做掉了，
// 因为 RC2 会在清空 curve 的那笔买入里自动毕业。prepareGraduate 会直接拒绝，而不是构造一笔必然失败的交易。
if (graduation.status === "notGraduated" && state.curveState.graduationReady) {
  await client.send(await client.prepareGraduate(token));
}

// 第二步：建 V4 池并铸出 position。同样无许可，且可重试。
if (graduation.status === "swept") {
  await client.send(await client.prepareCreateGraduatedPool(token));
}

const poolId = await client.getPoolId(token); // 池子存在之前是 bytes32(0)
```

`swept` 是一个可以停留的状态，不是过渡态。第二步失败时资产安全地留在 executor，直接重试即可——这是相对
V3 那个全有或全无的 `migrate` 的实质改进。

自动毕业尝试失败时 RC2 会发 `AutoGraduationFailed`，**但对应的买入或创建仍然成功**。
`parseLaunchpadReceiptEvents` 特意把它放进独立的 `autoGraduationFailures` 分组，就是为了避免前端把它
显示成购买失败；Launch 只是停在 `notGraduated`，由常规的无许可路径接手。

## 费用

费用先在 curve 上累计，再经由单独的 sweep 步骤转出，最后落到 `FeeEscrow` 里由受益人自己领取。

```ts
const pending = await client.getPendingFees(curve);   // 尚未 sweep
const claimable = await client.getClaimableBalance({ recipient, asset }); // 已在 escrow 里
await client.send(await client.prepareClaim({ asset }));  // 领取自己的余额
```

**Escrow 只按 `(recipient, asset)` 两维记账。** 它不记录某笔 credit 来自哪个 Launch，所以同一个
recipient 被多个 Launch 复用时，每种资产只会汇成一个无法拆分的池子，链上没有办法还原归属。需要按
Launch 归因的话，要么每个 Launch 用不同的 recipient 地址，要么从 `CurveFeesSwept` / `FeeCredited`
事件在链下自行重建。

## Partner

Partner 是**按笔交易声明**的，不在创建时绑定到 token，而且比例是实时从注册表读的——所以调整比例从下一笔
交易开始生效，绝不追溯。

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

三种结果，前端不能把它们混为一谈：

| | `usable` | `earning` | 对交易的影响 |
|---|---|---|---|
| `bytes32(0)` | 是 | 否 | 正常成交，无归因 |
| 已注册、已禁用 | 是 | 否 | 正常成交，事件里仍记录，但拿不到分成 |
| 未注册的非零 ID | **否** | 否 | **整笔交易 revert** |

在已毕业的 V4 池上做 swap 时，用 `encodeLaunchpadPartnerHookData` 编码。冻结的 ABI 就是
`abi.encode(bytes32)`，无 Partner 时是空 bytes；hook 会严格校验长度，其他任何形式都会 revert
而不是被忽略。

## Buyback 与紧急退出

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

Vesting 时钟是对「历史全部锁仓」的单一五年加权平均，不是按笔分 tranche：每次新存入都会把整个余额的
`vestingEnd` 往后推，所以只要 buyback 持续有流入，这个 Launch 就永远不会完全归属完。

`prepareSetBuybackEnabled` 的权限在链上是不对称的：当前 creator fee recipient 可以**开也可以关**，
而 protocol owner **只能关**。

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

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

触发退出是 **owner-only** 的，与两步毕业不同——被套住的持有人只能在治理触发之后赎回，自己拉不动这个闸。

紧急退出的适用面比「卡住的 Launch」窄得多：它要求 Launch **已经处于 `swept`**，并从 `sweptAt` 起算
七天，因为它填的正是两步毕业之间的那个缺口。还在 curve 上交易的 Launch 根本无从退出，而且没有任何快速通道。

赎回的分母是代币的**实时** `totalSupply()`，每次重新读取而非快照。因此有人直接 burn 而不赎回时，会抬高
其余所有人的单位赎回额，最后一个赎回的人拿走全部剩余。报价会随别人的行为漂移，提交前请重新读取。

## Keeper 操作

Curve sweep 和 V4 池的 Epoch 分页 Partner 结算在 keeper 接口里。注意一个很容易搞错的参数不对称：
`beginPoolFeeSweep` 收的是 **token**，而 `settlePoolFeeSweepPartners` 和 `finalizePoolFeeSweep`
收的是 **poolId**（用 `getLaunchpadPoolId` 解析）。协议对 Partner 数量和分页大小都**没有上限**，
每批多少完全取决于你自己的 gas 预算。

RC2 改变了 sweep 的失败方式：碰到内部价格影响上限不再整笔回滚，而是能换多少换多少，剩下的留在
Pending，Partner 未结算的部分滚入下一个 Epoch。`InternalPriceImpactExceeded` 已经不存在，所以没有
异常可捕获重试——改为读 `getLaunchpadPartnerEpochAccounting({ poolId, epochId })`，在
`allPartnersSettled` 为 false 时继续分页，只在 `finalizeReady` 为 true 时才 finalize（链上校验的是
五个条件，不只是 Partner 计数），并把 `partnerLaunchInputConsumed < partnerLaunchInputTotal`
当作"还有活没干完"而不是错误。

还有一个 RC2 之前就存在的坑：`MemeHook.settlingEpoch` 是**一基**存储的，0 表示没有正在结算的 Epoch。
Epoch id 请取 `getLaunchpadPoolSweepState(...).settlingEpochId`（已解码），直接用原始值会大 1，
导致 settle 和 finalize 全部 revert。

另外 curve 的 `sweepFees` 可能顺带触发毕业：它内部的 buyback 有可能买走最后的可售 Token。每次 sweep
成功后都要重新读一次毕业状态。

## 错误处理

这套部署能产生的每一个合约 revert 都映射到稳定、可捕获的类型，且映射表对生成的 ABI 做了穷尽性断言。
值得单独分支处理的几个：

- `LaunchpadTradingClosedError` —— curve 已到边界。**永久性**关闭，之后去 V4 池交易。
- `LaunchpadTradeFeeConsumesInputError` —— 费用吃光了输入。加大金额，或等狙击税衰减。
- `LaunchpadInsufficientQuoteReserveError` —— 卖出超过了 curve 真实（非 phantom）的储备。
- `LaunchpadSlippageError`、`LaunchpadDeadlineExpiredError` —— 重新报价并重建交易。
- `LaunchpadCreatorDeniedError` 与 `LaunchpadLaunchNotPermittedError` —— 前者是针对单个账户的封禁，
  后者是对所有非白名单账户关闭的闸门，处理方式完全不同。
- `LaunchpadEconomicsHashMismatchError` —— 治理改了模板；重新读取并重建哈希。
- `LaunchpadApprovalRequiredError` —— 带着确切的 `spender`，而它因操作而异。
- `prepareGraduate` 抛出的 `LaunchpadPhaseUnavailableError` —— 在 RC2 下这通常意味着别人的交易已经把
  它毕业了。看 `error.status`：`launchpadGraduateIsIdempotentSuccess(status)` 对 `swept` 和
  `poolCreated` 返回 true，keeper 应当按"已完成"处理而不是告警。

## 主网

合约与经济参数完全相同，只剩一处不同。主网已于 2026-09-08 轮换到 Holder-aware forwarder 并绑定了
distributor factory，两条创建路径和 Holder Fee Sharing 的行为与测试网完全一致——只有地址不同。仍然不
同的是 Quote Token：只有 USDC，tUSDG 是测试网水龙头代币，照搬过去等于没有可交易资产。

Public Launch 已开启，创建、买入、卖出都已在链上跑通；毕业路径尚未验证。细节见
[对接指南](docs/integration-guide.md#mainnet-what-differs)。

## 包入口

- `@masterpeach/launchpad-sdk` —— 客户端、actions、math、类型与错误
- `@masterpeach/launchpad-sdk/abi` —— 生成的 Production V2 ABI
- `@masterpeach/launchpad-sdk/core` —— 跨 SDK 共享的链无关原语

完整生命周期、索引注意事项、keeper 运维手册和上线检查清单见
[对接指南](docs/integration-guide.md)。
