npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@whetstone-research/doppler-sdk

v1.0.23

Published

A unified TypeScript SDK for interacting with the Doppler Protocol across EVM and Solana/SVM deployments.

Readme

Doppler SDK

A unified TypeScript SDK for interacting with the Doppler Protocol across EVM and Solana/SVM deployments.

Overview

The Doppler SDK exposes network-specific entrypoints for creating, managing, and interacting with Doppler launches. The EVM entrypoint consolidates functionality from the previous doppler-v3-sdk and doppler-v4-sdk packages into one interface for Ethereum and EVM-compatible chains. The Solana entrypoint provides generated instruction builders, PDA helpers, clients, React bindings, and examples for Doppler's SVM programs.

Key Features

  • EVM Auctions: Static auctions, dynamic auctions, and multicurve launches across Uniswap V3/V4 paths
  • EVM Migration Paths: Support for V2, V2 split, V4, V4 split, DopplerHook, and no-op migration
  • EVM Multicurve Fees: Single-token and batched pending-fee previews plus beneficiary fee claiming for locked multicurve pools
  • Solana Launches: Initializer, CPMM, migrator, hook, oracle, and Token-2022-compatible instruction helpers
  • Solana Clients and React: Read clients, PDA helpers, generated codecs, and optional React bindings
  • Token Management: Built-in EVM support for DERC20 tokens with vesting
  • Type Safety: Full TypeScript support across EVM and Solana entrypoints
  • Network Support: EVM deployments on Base, Unichain, Ink, and other supported chains; Solana/SVM support via explicit Solana program deployments

Installation

npm install @whetstone-research/doppler-sdk viem
# or
yarn add @whetstone-research/doppler-sdk viem
# or
pnpm add @whetstone-research/doppler-sdk viem

Use network-specific entrypoints:

import { DopplerSDK } from '@whetstone-research/doppler-sdk/evm';
import { initializer, cpmm, cpmmMigrator } from '@whetstone-research/doppler-sdk/solana';
import { DopplerSolanaProvider } from '@whetstone-research/doppler-sdk/solana/react';

Quick Start

EVM

import { DopplerSDK } from '@whetstone-research/doppler-sdk/evm';
import { createPublicClient, createWalletClient, http } from 'viem';
import { base } from 'viem/chains';

// Set up viem clients
const publicClient = createPublicClient({
  chain: base,
  transport: http(),
});

const walletClient = createWalletClient({
  chain: base,
  transport: http(),
  account: '0x...', // Your wallet address
});

// Initialize the SDK
const sdk = new DopplerSDK({
  publicClient,
  walletClient,
  chainId: base.id,
});

Solana

import { address, createSolanaRpc, type Address } from '@solana/kit';
import { cpmm, initializer } from '@whetstone-research/doppler-sdk/solana';

const rpc = createSolanaRpc('https://api.devnet.solana.com');
const WSOL_MINT: Address =
  'So11111111111111111111111111111111111111112' as Address;

if (!process.env.BASE_MINT) {
  throw new Error('BASE_MINT must be set');
}

const baseMint = address(process.env.BASE_MINT);
const [initializerConfig] = await initializer.getConfigAddress();
const [cpmmConfig] = await cpmm.getConfigAddress();
const pool = await cpmm.getPoolByMints(rpc, baseMint, WSOL_MINT);

console.log('Initializer config:', initializerConfig);
console.log('CPMM config:', cpmmConfig);
console.log('Pool:', pool?.address ?? 'not found');

For runnable Solana flows, see:

Creating Auctions

Static Auction (Fixed Price Range)

Static auctions use Uniswap V3 pools with concentrated liquidity in a fixed price range. They're ideal for simple, predictable price discovery.

import { StaticAuctionBuilder } from '@whetstone-research/doppler-sdk/evm';
import { base } from 'viem/chains';

const params = new StaticAuctionBuilder(base.id)
  .tokenConfig({
    name: 'My Token',
    symbol: 'MTK',
    tokenURI: 'https://example.com/metadata.json',
  })
  .saleConfig({
    initialSupply: parseEther('1000000000'),
    numTokensToSell: parseEther('900000000'),
    numeraire: '0x...',
  })
  .poolByTicks({
    startTick: -92200,
    endTick: -69000,
    fee: 10000,
    numPositions: 15,
  })
  .withVesting({
    duration: BigInt(365 * 24 * 60 * 60),
    // Optional: specify multiple recipients and amounts
    // recipients: ['0xTeam...', '0xAdvisor...'],
    // amounts: [parseEther('50000000'), parseEther('50000000')]
    // Optional: define per-beneficiary vesting allocations on the DERC20 V2 path
    // allocations: [
    //   {
    //     recipient: '0xTeam...',
    //     amount: parseEther('50000000'),
    //     schedule: { duration: BigInt(180 * 24 * 60 * 60), cliffDuration: 30 * 24 * 60 * 60 },
    //   },
    //   {
    //     recipient: '0xAdvisor...',
    //     amount: parseEther('50000000'),
    //     schedule: { duration: BigInt(365 * 24 * 60 * 60), cliffDuration: 90 * 24 * 60 * 60 },
    //   },
    // ]
  })
  .withMigration({ type: 'uniswapV2' })
  .withUserAddress('0x...')
  .build();

const result = await sdk.factory.createStaticAuction(params);
console.log('Pool address:', result.poolAddress);
console.log('Token address:', result.tokenAddress);

If you set cliffDuration > 0 or provide allocations, the SDK automatically uses the DERC20 V2 factory and exposes schedule-aware token reads via sdk.getDerc20V2(tokenAddress). When allocations is provided, the SDK dedupes identical schedules internally and maps each recipient to the correct on-chain schedule.

For a runnable example, see examples/multicurve-per-beneficiary-vesting.ts.

Tick spacing reminder: When you provide ticks manually via poolByTicks, make sure both startTick and endTick are exact multiples of the fee tier's tick spacing (100→1, 500→10, 3000→60, 10000→200). The SDK now validates this locally and will fail fast if the ticks are misaligned.

Static Auction with Lockable Beneficiaries (V3)

When you want fee revenue to flow to specific addresses without migrating liquidity, use lockable beneficiaries. The pool enters a "Locked" state where trading fees are collected and distributed to beneficiaries:

import {
  StaticAuctionBuilder,
  WAD,
  getAirlockOwner,
} from '@whetstone-research/doppler-sdk/evm';
import { parseEther } from 'viem';

// Get the protocol owner (required beneficiary with min 5%)
const protocolOwner = await getAirlockOwner(publicClient);

// Define beneficiaries - shares must sum to WAD (1e18 = 100%)
const beneficiaries = [
  { beneficiary: protocolOwner, shares: parseEther('0.05') }, // 5% (minimum required)
  { beneficiary: '0xTeamWallet...', shares: parseEther('0.45') }, // 45%
  { beneficiary: '0xDAOTreasury...', shares: parseEther('0.50') }, // 50%
];

const params = new StaticAuctionBuilder(chainId)
  .tokenConfig({
    name: 'My Token',
    symbol: 'MTK',
    tokenURI: 'https://example.com/metadata.json',
  })
  .saleConfig({
    initialSupply: parseEther('1000000000'),
    numTokensToSell: parseEther('900000000'),
    numeraire: wethAddress,
  })
  .poolByTicks({
    startTick: 174960, // Must be multiple of 60 for fee 3000
    endTick: 225000,
    fee: 3000, // Set > 0 to accumulate fees for beneficiaries
  })
  .withBeneficiaries(beneficiaries) // Lock pool and enable fee streaming
  .withMigration({ type: 'noOp' }) // Use NoOp since pool is locked
  .withGovernance({ type: 'default' })
  .withUserAddress('0x...')
  .build();

const result = await sdk.factory.createStaticAuction(params);
console.log('Pool address:', result.poolAddress); // SAVE THIS - needed to collect fees!

Important Notes:

  • Shares must sum to exactly WAD (1e18 = 100%)
  • Protocol owner must receive at least 5% of fees
  • SDK automatically sorts beneficiaries by address (ascending)
  • Use withMigration({ type: 'noOp' }) - locked pools cannot migrate
  • Set fee > 0 (e.g., 3000 for 0.3%) to accumulate trading fees
  • Pool status = "Locked" - liquidity stays permanently in the V3 pool
  • Anyone can call collectFees() to trigger distribution to beneficiaries

See examples/static-auction-lockable-beneficiaries.ts for a complete example.

Dynamic Auction (Dutch Auction)

Dynamic auctions use Uniswap V4 hooks to implement gradual Dutch auctions where the price moves over time.

import {
  DynamicAuctionBuilder,
  DAY_SECONDS,
} from '@whetstone-research/doppler-sdk/evm';
import { base } from 'viem/chains';

const params = new DynamicAuctionBuilder(base.id)
  .tokenConfig({
    name: 'My Token',
    symbol: 'MTK',
    tokenURI: 'https://example.com/metadata.json',
  })
  .saleConfig({
    initialSupply: parseEther('1000000'),
    numTokensToSell: parseEther('900000'),
    numeraire: '0x...',
  })
  .poolConfig({ fee: 3000, tickSpacing: 60 })
  .auctionByTicks({
    duration: 7 * DAY_SECONDS,
    epochLength: 3600,
    startTick: -92103,
    endTick: -69080,
    minProceeds: parseEther('100'),
    maxProceeds: parseEther('1000'),
    numPdSlugs: 5,
  })
  .withVesting({
    duration: BigInt(365 * 24 * 60 * 60),
    // Optional: specify multiple recipients and amounts
    // recipients: ['0xTeam...', '0xAdvisor...'],
    // amounts: [parseEther('50000'), parseEther('50000')]
  })
  .withMigration({
    type: 'uniswapV4',
    fee: 3000,
    tickSpacing: 60,
    streamableFees: {
      lockDuration: 365 * 24 * 60 * 60,
      beneficiaries: [
        { beneficiary: '0x...', shares: parseEther('0.5') }, // 50%
        { beneficiary: '0x...', shares: parseEther('0.5') }, // 50%
      ],
    },
  })
  // Optional: override module addresses instead of chain defaults
  .withAirlock('0xAirlock...')
  .withPoolManager('0xPoolMgr...')
  .withDopplerDeployer('0xDeployer...')
  .withTokenFactory('0xFactory...')
  .withV4Initializer('0xInitializer...')
  .withGovernanceFactory('0xGovFactory...') // used for standard, no-op, or launchpad governance overrides
  // .withV2Migrator('0xV2Migrator...')
  // .withV3Migrator('0xV3Migrator...')
  // .withV4Migrator('0xV4Migrator...')
  .withUserAddress('0x...')
  .build();

const result = await sdk.factory.createDynamicAuction(params);
console.log('Hook address:', result.hookAddress);
console.log('Token address:', result.tokenAddress);

Opening Auction (Lifecycle + Bid Management)

Support includes:

  • sdk.buildOpeningAuction() for CreateOpeningAuctionParams
  • sdk.factory.simulateCreateOpeningAuction(params) and sdk.factory.createOpeningAuction(params)
  • sdk.getOpeningAuction(hookAddress) for hook reads + settleAuction() / claimIncentives()
  • sdk.factory.simulateCompleteOpeningAuction(...) and sdk.factory.completeOpeningAuction(...) for handoff to Doppler
  • sdk.getOpeningAuctionLifecycle(initializerAddress?) for initializer-level state + complete/recover/sweep helpers
  • sdk.getOpeningAuctionPositionManager(positionManagerAddress?) for placing/withdrawing opening-auction bids
    • Resolve the address via await (await sdk.getOpeningAuctionLifecycle(initializerAddress)).getPositionManager() when chain defaults are not configured
    • Resolve positionId for incentives via opening.getPositionId(...) or opening.claimIncentivesByPositionKey(...) (no log parsing required)

Base caveat: on Base mainnet (chainId = 8453), openingAuctionInitializer and openingAuctionPositionManager default to 0x0000000000000000000000000000000000000000 until deployment. Override with .withOpeningAuctionInitializer('0x...') / .withOpeningAuctionPositionManager('0x...') (or pass explicit addresses) before using opening-auction create/completion/bid flows there.

const params = sdk
  .buildOpeningAuction()
  .tokenConfig({
    name: 'My Token',
    symbol: 'MTK',
    tokenURI: 'https://example.com/metadata.json',
  })
  .saleConfig({
    initialSupply: parseEther('1000000'),
    numTokensToSell: parseEther('900000'),
    numeraire: '0x...',
  })
  .openingAuctionConfig({
    auctionDuration: 3600,
    minAcceptableTickToken0: -887220,
    minAcceptableTickToken1: -887220,
    incentiveShareBps: 500,
    tickSpacing: 60,
    fee: 3000,
    minLiquidity: 1n,
    shareToAuctionBps: 8000,
  })
  .dopplerConfig({
    minProceeds: parseEther('10'),
    maxProceeds: parseEther('100'),
    startTick: -69080,
    endTick: -92103,
  })
  .withMigration({ type: 'uniswapV4', fee: 3000, tickSpacing: 60 })
  .withUserAddress('0x...')
  .withOpeningAuctionInitializer('0x...') // required on Base until deployed
  .build()

const sim = await sdk.factory.simulateCreateOpeningAuction(params)
const created = await sim.execute()

const opening = await sdk.getOpeningAuction(created.openingAuctionHookAddress)
await opening.getPhase()

const lifecycle = await sdk.getOpeningAuctionLifecycle('0x...')
await lifecycle.getState(created.tokenAddress)

await sdk.factory.completeOpeningAuction({
  asset: created.tokenAddress,
  initializerAddress: '0x...',
})

completeOpeningAuction auto-settles and auto-mines dopplerSalt when omitted; because completion mining can race with block timestamps/state changes, the SDK may re-mine and retry a few times if needed. simulateCompleteOpeningAuction requires the opening auction to already be settled.

Position-manager bid wrappers are available, but bid sizing is still “advanced user”: liquidity is Uniswap V4 liquidity units. Use simulatePlaceBid(...) / simulateWithdrawBid(...) to inspect the BalanceDelta (token amounts in/out) and iterate. During the active auction, liquidity withdrawals must be full (no partial removals); use withdrawFullBid(...) to read the onchain liquidity and withdraw safely.

See examples/opening-auction-lifecycle.ts for the full builder/factory/lifecycle flow, and examples/opening-auction-bidding.ts for the bid-management pattern + positionId resolution.

Multicurve Auction (V4 Multicurve Initializer)

Multicurve auctions use a Uniswap V4-style initializer that seeds liquidity across multiple curves in a single pool. This enables richer distributions and can be combined with any supported migration path (V2, V3, V4, or NoOp). Multicurve initializer modes are modeled as a typed variant (standard, scheduled, decay, rehype) so new hook/initializer variations can be added without breaking existing integrations.

Standard Multicurve with Migration:

import { MulticurveBuilder } from '@whetstone-research/doppler-sdk/evm';
import { parseEther } from 'viem';
import { base } from 'viem/chains';

const params = new MulticurveBuilder(base.id)
  .tokenConfig({
    name: 'My Token',
    symbol: 'MTK',
    tokenURI: 'https://example.com/metadata.json',
  })
  .saleConfig({
    initialSupply: parseEther('1000000'),
    numTokensToSell: parseEther('900000'),
    numeraire: '0x...',
  })
  .poolConfig({
    fee: 0,
    tickSpacing: 8,
    curves: [
      {
        tickLower: 0,
        tickUpper: 240000,
        numPositions: 10,
        shares: parseEther('0.5'),
      },
      {
        tickLower: 16000,
        tickUpper: 240000,
        numPositions: 10,
        shares: parseEther('0.5'),
      },
    ],
  })
  .withGovernance({ type: 'default' })
  // Choose a migration path (V2, V2 split, V4, V4 split, DopplerHook, or noOp)
  .withMigration({ type: 'uniswapV2' })
  .withUserAddress('0x...')
  .build();

const result = await sdk.factory.createMulticurve(params);
console.log('Pool address:', result.poolAddress);
console.log('Token address:', result.tokenAddress);

Market Cap Presets (Low / Medium / High):

import { MulticurveBuilder, FEE_TIERS } from '@whetstone-research/doppler-sdk/evm';
import { parseEther } from 'viem';
import { base } from 'viem/chains';

const presetParams = new MulticurveBuilder(base.id)
  .tokenConfig({
    name: 'Preset Launch',
    symbol: 'PRST',
    tokenURI: 'ipfs://preset.json',
  })
  .saleConfig({
    initialSupply: parseEther('1000000'),
    numTokensToSell: parseEther('900000'),
    numeraire: '0x...',
  })
  .withMarketCapPresets({
    fee: FEE_TIERS.LOW, // defaults to 0.05% fee tier (tick spacing 10)
    presets: ['low', 'medium', 'high'], // defaults to all tiers
    // overrides: { high: { shares: parseEther('0.25') } }, // optional per-tier tweaks
  })
  .withGovernance({ type: 'default' })
  .withMigration({ type: 'uniswapV2' })
  .withUserAddress('0x...')
  .build();

const presetResult = await sdk.factory.createMulticurve(presetParams);
console.log('Pool address:', presetResult.poolAddress);
console.log('Token address:', presetResult.tokenAddress);

The preset helper seeds three curated curve buckets sized for ~1B token supply targets:

  • low: ~5% of the sale allocated to a $7.5k-$30k market cap window.
  • medium: ~12.5% targeting roughly $50k-$150k market caps.
  • high: ~20% aimed at $250k-$750k market caps.

Pass presets to pick a subset (e.g. ['medium', 'high']) or provide overrides to adjust ticks, positions, or shares for a specific tier. When the selected presets sum to less than 100%, the builder automatically appends a filler curve (using the highest selected tier's shape) so liquidity always covers the full sale. Shares must stay within 0-1e18 and the helper will throw if the total ever exceeds 100%.

Scheduled Multicurve Launch:

import { MulticurveBuilder } from '@whetstone-research/doppler-sdk/evm';
import { parseEther } from 'viem';
import { base } from 'viem/chains';

const startTime = Math.floor(Date.now() / 1000) + 3600; // one hour from now

const scheduled = new MulticurveBuilder(base.id)
  .tokenConfig({
    name: 'My Token',
    symbol: 'MTK',
    tokenURI: 'ipfs://scheduled.json',
  })
  .saleConfig({
    initialSupply: parseEther('1000000'),
    numTokensToSell: parseEther('900000'),
    numeraire: '0x4200000000000000000000000000000000000006',
  })
  .poolConfig({
    fee: 0,
    tickSpacing: 8,
    curves: [
      {
        tickLower: 0,
        tickUpper: 240000,
        numPositions: 12,
        shares: parseEther('0.5'),
      },
      {
        tickLower: 16000,
        tickUpper: 240000,
        numPositions: 12,
        shares: parseEther('0.5'),
      },
    ],
  })
  .withSchedule({ startTime })
  .withGovernance({ type: 'default' })
  .withMigration({ type: 'uniswapV2' })
  .withUserAddress('0x...')
  .build();

const scheduledResult = await sdk.factory.createMulticurve(scheduled);
console.log('Pool address:', scheduledResult.poolAddress);
console.log('Token address:', scheduledResult.tokenAddress);

Ensure the target chain has the scheduled multicurve initializer whitelisted. If you are targeting a custom deployment, override it via .withV4ScheduledMulticurveInitializer('0x...').

Decay Multicurve Launch (Dynamic Fee):

import { MulticurveBuilder } from '@whetstone-research/doppler-sdk/evm';
import { parseEther } from 'viem';
import { baseSepolia } from 'viem/chains';

const startTime = Math.floor(Date.now() / 1000) + 300;

const decay = new MulticurveBuilder(baseSepolia.id)
  .tokenConfig({
    name: 'Decay Token',
    symbol: 'DMC',
    tokenURI: 'ipfs://decay.json',
  })
  .saleConfig({
    initialSupply: parseEther('1000000'),
    numTokensToSell: parseEther('900000'),
    numeraire: '0x4200000000000000000000000000000000000006',
  })
  .poolConfig({
    fee: 500, // terminal fee (0.05%)
    tickSpacing: 10,
    curves: [
      {
        tickLower: 0,
        tickUpper: 220000,
        numPositions: 12,
        shares: parseEther('0.5'),
      },
      {
        tickLower: 20000,
        tickUpper: 220000,
        numPositions: 12,
        shares: parseEther('0.5'),
      },
    ],
  })
  .withDecay({
    startTime,
    startFee: 3000, // starts at 0.3%
    durationSeconds: 3600, // decays to pool.fee over 1 hour
  })
  .withGovernance({ type: 'default' })
  .withMigration({ type: 'uniswapV2' })
  .withUserAddress('0x...')
  .build();

const decayResult = await sdk.factory.createMulticurve(decay);
console.log('Pool address:', decayResult.poolAddress);
console.log('Token address:', decayResult.tokenAddress);

For decay pools, pool.fee is always the terminal fee (endFee) of the schedule. withDecay({ startTime }) is optional; if omitted, startTime defaults to 0. The SDK supports startFee values up to 800_000 (80%) for anti-sniping configurations. Ensure your deployed decay initializer/hook also supports the same max start fee. Override the decay initializer module with .withV4DecayMulticurveInitializer('0x...') when targeting custom deployments.

Multicurve with Lockable Beneficiaries (NoOp Migration):

When you want fee revenue to flow to specific addresses without migrating liquidity after the auction, use lockable beneficiaries with NoOp migration:

import { MulticurveFees, WAD } from '@whetstone-research/doppler-sdk/evm';

// Define beneficiaries with shares that sum to WAD (1e18 = 100%)
// IMPORTANT: Protocol owner must be included with at least 5% shares
const lockableBeneficiaries = [
  { beneficiary: '0xProtocolOwner...', shares: WAD / 10n }, // 10% to protocol (>= 5% required)
  { beneficiary: '0xYourAddress...', shares: (WAD * 4n) / 10n }, // 40%
  { beneficiary: '0xOtherAddress...', shares: WAD / 2n }, // 50%
];

const params = new MulticurveBuilder(base.id)
  .tokenConfig({
    name: 'My Token',
    symbol: 'MTK',
    tokenURI: 'https://example.com/metadata.json',
  })
  .saleConfig({
    initialSupply: parseEther('1000000'),
    numTokensToSell: parseEther('900000'),
    numeraire: '0x...',
  })
  .poolConfig({
    fee: 3000, // 0.3% fee tier - set > 0 to accumulate fees for beneficiaries
    tickSpacing: 8,
    curves: [
      {
        tickLower: 0,
        tickUpper: 240000,
        numPositions: 10,
        shares: parseEther('0.5'),
      },
      {
        tickLower: 16000,
        tickUpper: 240000,
        numPositions: 10,
        shares: parseEther('0.5'),
      },
    ],
    beneficiaries: lockableBeneficiaries, // Add beneficiaries for fee streaming
  })
  .withGovernance({ type: 'default' })
  .withMigration({ type: 'noOp' }) // Use NoOp migration with lockable beneficiaries
  .withUserAddress('0x...')
  .build();

const result = await sdk.factory.createMulticurve(params);
const assetAddress = result.tokenAddress; // SAVE THIS - you'll need it to collect fees!
console.log('Asset address:', assetAddress);

// Later, to preview and claim fees while the pool is locked:
// const pool = await sdk.getMulticurvePool(assetAddress)
// const pending = await pool.getPendingFees('0xBeneficiary...')
// await pool.collectFees()
//
// To preview many locked multicurve tokens at once:
// const fees = new MulticurveFees(publicClient, walletClient, tokenAddresses)
// const pendingByToken = await fees.getPendingFees('0xBeneficiary...')

Important Notes:

  • Set fee > 0 (e.g., 3000 for 0.3%) to accumulate trading fees for beneficiaries
  • Save the asset address (token address) returned from creation - you need it to collect fees later
  • Use MulticurvePool.getPendingFees(beneficiary) to preview a beneficiary's claimable token0/token1 fees for one pool
  • Use MulticurveFees.getPendingFees(beneficiary) to preview pending fees for multiple tokens with one multicall by default
  • Pass tokenBatchSize to MulticurveFees when an RPC provider needs large pending-fee previews split into smaller token batches
  • collectFees() claims a payout for the calling account only when the caller is a configured beneficiary
  • Pool enters "Locked" status (status = 2) and liquidity cannot be migrated
  • Beneficiaries are immutable and set at pool creation time
  • The SDK automatically handles PoolKey construction and PoolId computation for you

See examples/multicurve-lockable-beneficiaries.ts for a complete example. See docs/multicurve-fees.md for single-token and multi-token pending-fee previews, claiming, batching, and current migrated-launch limitations.

Transaction gas override

  • You can pass a gas limit to factory create calls via the gas field on CreateStaticAuctionParams / CreateDynamicAuctionParams / CreateMulticurveParams.
  • If omitted, the SDK uses the simulation's gas estimate when available, falling back to 13,500,000 gas for the create() transaction.
  • simulateCreate* helpers now return gasEstimate so you can tune overrides before sending.
  • Builders expose .withGasLimit(gas: bigint) so you can set overrides fluently.

Builder Pattern (Recommended)

Prefer using the builders to construct CreateStaticAuctionParams and CreateDynamicAuctionParams fluently and safely. Builders apply sensible defaults and can compute ticks and gamma for you.

import {
  StaticAuctionBuilder,
  DynamicAuctionBuilder,
} from '@whetstone-research/doppler-sdk/evm';
import { parseEther } from 'viem';
import { base } from 'viem/chains';

// Dynamic auction via builder
const dynamicParams = new DynamicAuctionBuilder(base.id)
  .tokenConfig({
    name: 'My Token',
    symbol: 'MTK',
    tokenURI: 'https://example.com/metadata.json',
  })
  .saleConfig({
    initialSupply: parseEther('1000000'),
    numTokensToSell: parseEther('500000'),
    numeraire: wethAddress,
  })
  .poolConfig({ fee: 3000, tickSpacing: 60 })
  .auctionByPriceRange({
    priceRange: { startPrice: 0.0001, endPrice: 0.001 },
    minProceeds: parseEther('100'),
    maxProceeds: parseEther('1000'),
  })
  .withMigration({ type: 'uniswapV2' })
  .withUserAddress('0x...')
  .build();

const dyn = await sdk.factory.createDynamicAuction(dynamicParams);

// Static auction via builder
const staticParams = new StaticAuctionBuilder(base.id)
  .tokenConfig({
    name: 'My Token',
    symbol: 'MTK',
    tokenURI: 'https://example.com/metadata.json',
  })
  .saleConfig({
    initialSupply: parseEther('1000000000'),
    numTokensToSell: parseEther('900000000'),
    numeraire: wethAddress,
  })
  .poolByPriceRange({
    priceRange: { startPrice: 0.0001, endPrice: 0.001 },
    fee: 3000,
  })
  .withMigration({ type: 'uniswapV2' })
  .withUserAddress('0x...')
  .build();

const stat = await sdk.factory.createStaticAuction(staticParams);

Simplified Creation with Defaults

The SDK intelligently applies defaults when parameters are omitted. Here are examples with minimal configuration:

// Minimal static auction via builder
const staticMinimal = new StaticAuctionBuilder(base.id)
  .tokenConfig({
    name: 'My Token',
    symbol: 'MTK',
    tokenURI: 'https://example.com/metadata.json',
  })
  .saleConfig({
    initialSupply: parseEther('1000000000'),
    numTokensToSell: parseEther('900000000'),
    numeraire: '0x...',
  })
  .poolByTicks({ fee: 10000 }) // uses default tick range and numPositions
  .withMigration({ type: 'uniswapV2' })
  .withUserAddress('0x...')
  .build();

const staticResult = await sdk.factory.createStaticAuction(staticMinimal);

// Minimal dynamic auction via builder
const dynamicMinimal = new DynamicAuctionBuilder(base.id)
  .tokenConfig({
    name: 'My Token',
    symbol: 'MTK',
    tokenURI: 'https://example.com/metadata.json',
  })
  .saleConfig({
    initialSupply: parseEther('1000000'),
    numTokensToSell: parseEther('900000'),
    numeraire: '0x...',
  })
  .poolConfig({ fee: 3000, tickSpacing: 60 })
  .auctionByTicks({
    startTick: -92103,
    endTick: -69080,
    minProceeds: parseEther('100'),
    maxProceeds: parseEther('1000'),
  }) // duration/epoch defaults applied; gamma computed automatically
  .withMigration({ type: 'uniswapV4' })
  .withUserAddress('0x...')
  .build();

const dynamicResult = await sdk.factory.createDynamicAuction(dynamicMinimal);

Interacting with Auctions

Static Auction Interactions

// Get a static auction instance
const auction = await sdk.getStaticAuction(poolAddress);

// Get pool information
const poolInfo = await auction.getPoolInfo();
console.log('Current price:', poolInfo.sqrtPriceX96);
console.log('Liquidity:', poolInfo.liquidity);

// Check if ready for migration
const hasGraduated = await auction.hasGraduated();

// Get current price
const price = await auction.getCurrentPrice();

Dynamic Auction Interactions

// Get a dynamic auction instance
const auction = await sdk.getDynamicAuction(hookAddress);

// Get comprehensive hook information
const hookInfo = await auction.getHookInfo();
console.log('Total proceeds:', hookInfo.state.totalProceeds);
console.log('Tokens sold:', hookInfo.state.totalTokensSold);

// Check auction status
const hasEndedEarly = await auction.hasEndedEarly();
const currentEpoch = await auction.getCurrentEpoch();

Multicurve Pool Interactions

Multicurve pools support fee collection and beneficiary claims when configured with pool.beneficiaries and no-op migration.

import { MulticurveFees } from '@whetstone-research/doppler-sdk/evm';

// Get a multicurve pool instance using the asset address (token address)
const pool = await sdk.getMulticurvePool(assetAddress);

// Get pool state
const state = await pool.getState();
console.log('Asset:', state.asset);
console.log('Numeraire:', state.numeraire);
console.log('Fee tier:', state.fee);
console.log('Tick spacing:', state.tickSpacing);
console.log('Hook address:', state.poolKey.hooks);
console.log('Far tick threshold:', state.farTick);
console.log('Pool status:', state.status); // 0=Uninitialized, 1=Initialized, 2=Locked, 3=Exited

// For dynamic-fee multicurve pools, read the live decay fee schedule
const feeSchedule = await pool.getFeeSchedule();
if (feeSchedule) {
  console.log('Fee schedule:', feeSchedule);
}

// Preview pending fees for a beneficiary. This is a read-only call.
const pendingFees = await pool.getPendingFees(beneficiaryAddress);
console.log('Pending fees (token0):', pendingFees.fees0);
console.log('Pending fees (token1):', pendingFees.fees1);

// Preview pending fees for multiple launched tokens. By default this builds
// one multicall for all requested tokens.
const multicurveFees = new MulticurveFees(
  publicClient,
  walletClient,
  [assetAddress, anotherAssetAddress],
  { tokenBatchSize: 25 },
);
const pendingFeesByToken =
  await multicurveFees.getPendingFees(beneficiaryAddress);
for (const pendingFees of pendingFeesByToken) {
  console.log('Asset:', pendingFees.tokenAddress);
  console.log('Pending fees (token0):', pendingFees.fees0);
  console.log('Pending fees (token1):', pendingFees.fees1);
}

// Claim fees from a beneficiary wallet while the pool is locked.
// Any account can call collectFees(), but only a configured beneficiary caller
// receives their pending share.
const { fees0, fees1, transactionHash } = await pool.collectFees();
console.log('Fees collected (token0):', fees0);
console.log('Fees collected (token1):', fees1);
console.log('Transaction:', transactionHash);

// Get token addresses
const tokenAddress = pool.getTokenAddress();
const numeraireAddress = await pool.getNumeraireAddress();

Fee Collection Technical Details:

The SDK handles the complexity of fee collection by:

  1. Retrieving pool configuration from the multicurve initializer contract
  2. Detecting pool status so only locked initializer-side pools proceed
  3. Computing the PoolId from the PoolKey using keccak256(abi.encode(poolKey))
  4. Previewing pending fees with a Multicall3 aggregate that simulates collection and reads beneficiary share/checkpoint data
  5. Calling the initializer with the computed PoolId when a beneficiary claims via collectFees()

Important Notes:

  • Fees accumulate from swap activity on the pool (only if fee tier > 0)
  • MulticurvePool.getPendingFees(beneficiary) returns the beneficiary's pending share for both tokens in one pair
  • MulticurveFees.getPendingFees(beneficiary) returns pending fees for each requested token and uses one multicall by default
  • MulticurveFees accepts tokenBatchSize when large token lists need to be split into smaller multicalls
  • collectFees() sends a transaction; the caller needs a wallet client
  • Anyone can call collectFees(), but only a configured beneficiary caller receives their pending share
  • The collectFees() return values are the newly collected pool fees, not necessarily the caller's beneficiary payout
  • Works exclusively with initializer-side locked pools created with pool.beneficiaries and no-op migration
  • Pools in "Locked" status (status = 2) use the multicurve initializer for collection
  • Pools in "Exited" status (status = 3) are migrated and are not currently supported by MulticurvePool.getPendingFees() or MulticurvePool.collectFees()
  • getFeeSchedule() returns decay schedule details only for dynamic-fee multicurve pools, otherwise null
  • Beneficiaries must be configured at pool creation time and cannot be changed

Common Use Cases:

  • Preview pending fees for a portfolio or paginated token list
  • Set up periodic fee collection (e.g., daily or weekly)
  • Integrate with a bot that automatically collects fees when threshold is reached
  • Allow any beneficiary to trigger collection after significant trading activity
  • Monitor swap events to determine optimal collection timing

See docs/multicurve-fees.md for a focused guide, examples/multicurve-get-pending-fees.ts for batched previews, and examples/multicurve-collect-fees.ts for claims.

Token Management

DERC20 Tokens

The SDK includes full support for DERC20 tokens with vesting functionality:

// Get a DERC20 instance from the SDK (uses its clients)
const token = sdk.getDerc20(tokenAddress);

// Read token information
const name = await token.getName();
const symbol = await token.getSymbol();
const balance = await token.getBalanceOf(address);

// Vesting functionality
const vestingData = await token.getVestingData(address);
console.log('Total vested:', vestingData.totalAmount);
console.log('Released:', vestingData.releasedAmount);

// Release currently available vested tokens
await token.release();

Alternatively, you can instantiate directly if needed:

import { Derc20 } from '@whetstone-research/doppler-sdk/evm';
const tokenDirect = new Derc20(publicClient, walletClient, tokenAddress);

DopplerERC20V1 Tokens

Use the newer DopplerERC20V1 token template by either setting type: 'dopplerERC20V1' explicitly or by passing fields such as maxBalanceLimit with balanceLimitEnd, controller, or excludedFromBalanceLimit. When selected, the SDK uses the configured dopplerERC20V1Factory by default. withTokenFactory(address) is a generic factory override and takes precedence, but it must point to a factory compatible with the selected token path and token data ABI. controller is optional and defaults to the zero address, set it only if early balance-limit disable should be possible. Standard configs without the specific fields still use the legacy standard path, where cliff/allocation vesting routes to legacy DERC20 V2. Keep explicit type: 'dopplerERC20V1' when you want its behavior but have no specific fields to infer from.

When balance limiting is enabled on the default DopplerERC20V1 integration, the SDK encodes user exclusions plus determinable protocol recipients for the selected auction path into deployment-time excludedFromBalanceLimit, including initializers, hooks, PoolManager, migrators, known migration pools, no-op governance, launchpad governance multisigs, and standard GovernanceFactory timelocks for default or custom governance. Custom withTokenFactory(address) paths receive only the excludedFromBalanceLimit entries supplied in tokenConfig, so custom token factory users must provide any required deployment-time exclusions themselves. Custom withGovernanceFactory(address) paths skip standard-governance timelock auto-exclusion, so custom governance factory users must provide any required timelock exclusions themselves. Exclusions cannot be added later through the controller or governance.

DopplerERC20V1 supports vesting through withVesting while staying on the DopplerERC20V1 factory path: use duration with optional cliffDuration for a shared schedule, or allocations for per-beneficiary schedules.

const params = new StaticAuctionBuilder(base.id)
  .tokenConfig({
    name: 'My Doppler Token',
    symbol: 'MDT',
    tokenURI: 'ipfs://doppler-token.json',
    maxBalanceLimit: parseEther('10000'),
    balanceLimitEnd: Math.floor(Date.now() / 1000) + 30 * DAY_SECONDS,
    controller: userAddress, // optional; defaults to zero address when omitted
    excludedFromBalanceLimit: [userAddress], // default DopplerERC20V1 path also adds protocol modules
  })
  .saleConfig({
    initialSupply: parseEther('1000000'),
    numTokensToSell: parseEther('900000'),
    numeraire: wethAddress,
  })
  .poolByTicks({ startTick: -120000, endTick: -60000, fee: 3000 })
  .withVesting({
    duration: 365n * BigInt(DAY_SECONDS),
    cliffDuration: 30 * DAY_SECONDS,
    recipients: [userAddress],
    amounts: [parseEther('100000')],
  })
  .withMigration({ type: 'uniswapV2' })
  .withUserAddress(userAddress)
  .build();

DopplerERC20V1 token data includes schedule vesting and balance-limit controls, but it intentionally omits yearlyMintRate; DopplerERC20V1 tokens do not expose mintInflation or mint-rate update helpers.

const token = sdk.getDopplerERC20V1(tokenAddress);
const scheduleCount = await token.getVestingScheduleCount();

for (let scheduleId = 0n; scheduleId < scheduleCount; scheduleId++) {
  const schedule = await token.getVestingSchedule(scheduleId);
  const available = await token.getAvailableVestedAmountForSchedule(
    userAddress,
    scheduleId,
  );
  console.log(schedule, available);

  // Release half of the available vested amount for one schedule.
  if (available > 0n) await token.releaseSchedule(scheduleId, available / 2n);
}

console.log(await token.getMaxBalanceLimit());
console.log(await token.getBalanceLimitEnd());
console.log(await token.isBalanceLimitActive());

For a runnable example, see examples/doppler-erc20-v1.ts.

Governance Delegation (ERC20Votes)

DERC20 extends OpenZeppelin's ERC20Votes. Voting power is tracked via checkpoints and only updates once an address delegates voting power (typically to itself). The SDK exposes simple read/write helpers for delegation.

Basics:

import { Derc20 } from '@whetstone-research/doppler-sdk/evm';

const token = sdk.getDerc20(tokenAddress);

// Read: who an account delegates to, and current voting power
const currentDelegate = await token.getDelegates(userAddress);
const votes = await token.getVotes(userAddress);

// Self‑delegate to activate vote tracking
await token.delegate(userAddress);

// Or delegate to another address
await token.delegate('0xDelegatee...');

Historical votes:

// OZ v5 uses timepoints (block numbers for block‑based clocks)
const blockNumber = await publicClient.getBlockNumber();
const pastVotes = await token.getPastVotes(userAddress, blockNumber - 1n);

Signature‑based delegation (delegateBySig):

// Signs an EIP‑712 message and submits a transaction calling delegateBySig
// Note: This still submits a transaction from the connected wallet.
const expiry = BigInt(Math.floor(Date.now() / 1000) + 3600); // 1h
await token.delegateBySig('0xDelegatee...', expiry);

Advanced: gasless delegation via relayer

  • The token supports delegateBySig(delegatee, nonce, expiry, v, r, s). A relayer can submit this on behalf of the user if it holds ETH for gas.
  • To do this, have the user sign typed data, then send the signature to your backend that calls the contract.

Client (sign only):

const [nonce, name] = await Promise.all([
  publicClient.readContract({
    address: tokenAddress,
    abi: derc20Abi,
    functionName: 'nonces',
    args: [userAddress],
  }),
  token.getName(),
]);
const chainId = await publicClient.getChainId();
const domain = {
  name,
  version: '1',
  chainId,
  verifyingContract: tokenAddress,
} as const;
const types = {
  Delegation: [
    { name: 'delegatee', type: 'address' },
    { name: 'nonce', type: 'uint256' },
    { name: 'expiry', type: 'uint256' },
  ],
} as const;
const message = { delegatee: '0xDelegatee...', nonce, expiry } as const;

const signature = await walletClient.signTypedData({
  domain,
  types,
  primaryType: 'Delegation',
  message,
  account: userAddress,
});
// POST { signature, delegatee, nonce, expiry } to your relayer

Relayer (submit tx):

function splitSig(sig: `0x${string}`) {
  const r = `0x${sig.slice(2, 66)}` as `0x${string}`;
  const s = `0x${sig.slice(66, 130)}` as `0x${string}`;
  let v = parseInt(sig.slice(130, 132), 16);
  if (v < 27) v += 27;
  return { v, r, s };
}

const { v, r, s } = splitSig(signature);
await relayerWallet.writeContract({
  address: tokenAddress,
  abi: derc20Abi,
  functionName: 'delegateBySig',
  args: ['0xDelegatee...', nonce, expiry, v, r, s],
});

Notes

  • Users must delegate (even to themselves) before votes appear in getVotes.
  • getPastVotes/getPastTotalSupply expect a timepoint; for block‑based clocks, pass a block number that has already been mined.
  • Events you may track: DelegateChanged and DelegateVotesChanged for live updates.

Native ETH

The SDK also provides an ETH wrapper with ERC20-like interface:

import { Eth } from '@whetstone-research/doppler-sdk/evm';

const eth = new Eth(publicClient, walletClient);
const balance = await eth.getBalanceOf(address);

Price Quotes

Get price quotes across Uniswap V2, V3, and V4:

const quoter = sdk.quoter;

// Quote on Uniswap V3
const quote = await quoter.quoteV3ExactInputSingle({
  tokenIn: tokenAddress,
  tokenOut: wethAddress,
  amountIn: parseEther('1000'),
  fee: 3000,
  sqrtPriceLimitX96: 0n,
});

console.log('Expected output:', quote.amountOut);
console.log('Price after swap:', quote.sqrtPriceX96After);

Atomic Create + Pre‑Buy (Bundle)

For static auctions, you can create the pool and execute a pre‑buy in a single transaction via the Bundler.

High‑level flow:

  • Simulate create to get CreateParams and the predicted token address
  • Decide amountOut to buy, simulate amountIn with simulateBundleExactOutput(...)
  • Build Universal Router commands (e.g., via doppler-router)
  • Call factory.bundle(createParams, commands, inputs, { value })

See docs/quotes-and-swaps.md for a full example.

Multicurve Bundler Helpers

Multicurve auctions expose similar helpers that work with the Doppler Bundler once it has been upgraded with multicurve support (selector check added in 0.0.1-alpha.47). The SDK now verifies the bundler bytecode before attempting these flows; if you see Bundler at <address> does not support multicurve bundling, deploy or point at the latest bundler release.

// Prepare multicurve CreateParams up front
const createParams = sdk.factory.encodeCreateMulticurveParams(multicurveConfig);

// Quote an exact-out bundle
const exactOutQuote = await sdk.factory.simulateMulticurveBundleExactOut(
  createParams,
  {
    exactAmountOut: parseEther('100'),
  },
);

// Quote an exact-in bundle
const exactInQuote = await sdk.factory.simulateMulticurveBundleExactIn(
  createParams,
  {
    exactAmountIn: parseEther('25'),
  },
);

console.log('Predicted asset:', exactOutQuote.asset);
console.log('PoolKey:', exactOutQuote.poolKey);
console.log('Input required:', exactOutQuote.amountIn);

The multicurve helpers automatically normalise the returned PoolKey to maintain canonical token ordering and hash the result when collecting fees, so consumers no longer need to manually assemble the PoolId.

Migration Configuration

The SDK supports flexible migration paths after auction completion:

Migrate to Uniswap V2

migration: {
  type: 'uniswapV2',
}

Migrate to Uniswap V4

migration: {
  type: 'uniswapV4',
  fee: 3000,
  tickSpacing: 60,
  streamableFees: {
    lockDuration: 365 * 24 * 60 * 60, // 1 year
    beneficiaries: [
      { beneficiary: '0x...', shares: parseEther('1') }, // 100%
    ],
  },
}

Migrate to Uniswap V2 with Proceeds Split + Top-ups

migration: {
  type: 'uniswapV2Split',
  proceedsSplit: {
    recipient: '0xRecipient...',
    share: parseEther('0.1'), // 10%, capped at 50%
  },
}
  • The split recipient receives the configured share of numeraire proceeds during migration.
  • If the asset/numeraire pair was topped up in TopUpDistributor before migration, the split recipient also receives those top-ups automatically.

Migrate to Uniswap V4 with Proceeds Split + Top-ups

migration: {
  type: 'uniswapV4Split',
  fee: 3000,
  tickSpacing: 8,
  streamableFees: {
    lockDuration: 30 * 24 * 60 * 60,
    beneficiaries: [
      { beneficiary: '0xAirlockOwner...', shares: parseEther('0.05') },
      { beneficiary: '0xTeam...', shares: parseEther('0.95') },
    ],
  },
  proceedsSplit: {
    recipient: '0xRecipient...',
    share: parseEther('0.1'),
  },
}
  • streamableFees is required for uniswapV4Split.
  • Beneficiaries must sum to 1e18, and the Airlock owner must be included with at least 5% shares.
  • The split recipient also receives any TopUpDistributor funds pulled during migration.

TopUpDistributor Top-ups

The SDK exposes sdk.topUpDistributor and sdk.getTopUpDistributor(address?) for building, simulating, and submitting topUp({ asset, numeraire, amount }) transactions where getAddresses(chainId).topUpDistributor is configured. The helper methods accept the same object shape for buildTopUpTransaction({ asset, numeraire, amount }) and simulateTopUp({ asset, numeraire, amount }). ETH top-ups use numeraire = ZERO_ADDRESS and send value = amount; ERC20 top-ups send no native value and require the user to approve the TopUpDistributor before calling topUp.

import { ZERO_ADDRESS } from '@whetstone-research/doppler-sdk/evm';
import { parseEther } from 'viem';

const topUps = sdk.topUpDistributor;

const tx = topUps.buildTopUpTransaction({
  asset: tokenAddress,
  numeraire: ZERO_ADDRESS,
  amount: parseEther('1'),
});

const simulation = await topUps.simulateTopUp({
  asset: tokenAddress,
  numeraire: ZERO_ADDRESS,
  amount: parseEther('1'),
});

await topUps.topUp({
  asset: tokenAddress,
  numeraire: ZERO_ADDRESS,
  amount: parseEther('1'),
});

Split migrators pull any TopUpDistributor balance for the asset/numeraire pair during migration and pay it to the configured split recipient.

Migrate via DopplerHookMigrator (Dynamic Auctions)

Use this mode when you want rehypothecation / custom hook behavior on the migrated V4 pool. This migration type is only supported for dynamic auctions.

const params = sdk
  .buildDynamicAuction()
  .tokenConfig({
    name: 'Example',
    symbol: 'EX',
    tokenURI: 'https://example.com/token.json',
  })
  .saleConfig({
    initialSupply: parseEther('1000000'),
    numTokensToSell: parseEther('500000'),
    numeraire: addresses.weth,
  })
  .withMarketCapRange({
    marketCap: { start: 500_000, min: 50_000 },
    numerairePrice: 3000,
    minProceeds: parseEther('10'),
    maxProceeds: parseEther('1000'),
    fee: 3000,
    tickSpacing: 10,
  })
  .withMigration({
    type: 'dopplerHook',
    fee: 3000,
    tickSpacing: 10,
    lockDuration: 30 * 24 * 60 * 60,
    beneficiaries: [
      { beneficiary: '0xYourBeneficiary...', shares: parseEther('0.95') },
      await sdk.getAirlockBeneficiary(), // required protocol owner entry (>=5%)
    ],
    rehype: {
      buybackDestination: '0xYourBuybackDestination...',
      customFee: 3000,
      feeRoutingMode: 'directBuyback',
      feeDistributionInfo: {
        assetFeesToAssetBuybackWad: parseEther('0.25'),
        assetFeesToNumeraireBuybackWad: parseEther('0.25'),
        assetFeesToBeneficiaryWad: parseEther('0.25'),
        assetFeesToLpWad: parseEther('0.25'),
        numeraireFeesToAssetBuybackWad: parseEther('0.25'),
        numeraireFeesToNumeraireBuybackWad: parseEther('0.25'),
        numeraireFeesToBeneficiaryWad: parseEther('0.25'),
        numeraireFeesToLpWad: parseEther('0.25'),
      },
    },
  })
  .withUserAddress('0xYourAddress...')
  .build();

Note: dopplerHook migrator beneficiaries must include the current Airlock owner with at least 5% shares, and total shares must sum to 1e18. Unlike initializer-side Rehype pools, migrator-side Rehype uses a static customFee; there is no fee decay schedule in this mode.

migration: {
  type: 'dopplerHook',
  fee: 3000,
  useDynamicFee: false,
  tickSpacing: 10,
  lockDuration: 30 * 24 * 60 * 60,
  beneficiaries: [
    { beneficiary: '0xYourBeneficiary...', shares: parseEther('1') },
  ],
  rehype: {
    // optional; defaults to chain rehypeDopplerHookMigrator address
    // hookAddress: '0xRehypeMigratorHook...',
    buybackDestination: '0xYourBuybackDestination...',
    customFee: 3000,
    feeRoutingMode: 'directBuyback',
    feeDistributionInfo: {
      assetFeesToAssetBuybackWad: parseEther('0.25'),
      assetFeesToNumeraireBuybackWad: parseEther('0.25'),
      assetFeesToBeneficiaryWad: parseEther('0.25'),
      assetFeesToLpWad: parseEther('0.25'),
      numeraireFeesToAssetBuybackWad: parseEther('0.25'),
      numeraireFeesToNumeraireBuybackWad: parseEther('0.25'),
      numeraireFeesToBeneficiaryWad: parseEther('0.25'),
      numeraireFeesToLpWad: parseEther('0.25'),
    },
  },
  proceedsSplit: {
    recipient: '0xProceedsRecipient...',
    share: parseEther('0.1'),
  },
}

To make configuring the first beneficiary simpler, the SDK now exposes helpers for resolving the airlock owner and creating the default 5% entry:

import {
  DopplerSDK,
  createAirlockBeneficiary,
  getAirlockOwner,
} from '@whetstone-research/doppler-sdk/evm';
import { parseEther } from 'viem';

const sdk = new DopplerSDK({ publicClient, chainId });

// Get the owner and construct the beneficiary entry (5% by default)
const airlockBeneficiary = await sdk.getAirlockBeneficiary();

// Or build the entry manually if you do not have an SDK instance handy
// (airlockEntry will be equivalent to airlockBeneficiary above)
const owner = await getAirlockOwner(publicClient);
const airlockEntry = createAirlockBeneficiary(owner); // defaults to 5% shares

const migration = {
  type: 'uniswapV4' as const,
  fee: 3000,
  tickSpacing: 60,
  streamableFees: {
    lockDuration: 365 * 24 * 60 * 60,
    beneficiaries: [
      airlockEntry, // or airlockBeneficiary (5%)
      { beneficiary: '0xYourDAO...', shares: parseEther('0.95') }, // 95%
    ],
  },
};

Supported Chains

The SDK exposes runtime constants and TypeScript types for supported chains:

import {
  CHAIN_IDS,
  SUPPORTED_CHAIN_IDS,
  getAddresses,
  isSupportedChainId,
  type SupportedChainId,
  type ChainAddresses,
} from '@whetstone-research/doppler-sdk/evm';

// Validate and narrow a chain ID
function ensureSupported(id: number): SupportedChainId {
  if (!isSupportedChainId(id)) throw new Error('Unsupported chain');
  return id;
}

const chainId = ensureSupported(CHAIN_IDS.BASE);
const addresses: ChainAddresses = getAddresses(chainId);
console.log('Airlock for Base:', addresses.airlock);

// Iterate supported chains
for (const id of SUPPORTED_CHAIN_IDS) {
  console.log('Supported chain id:', id);
}

Advanced Usage

Custom Vesting Configuration

vesting: {
  duration: 180 * 24 * 60 * 60, // 180 days
  recipients: [
    { address: '0x...', amount: parseEther('100000') },
    { address: '0x...', amount: parseEther('50000') },
  ],
}

Vanity Address Mining

The Doppler protocol uses CREATE2 for deterministic deployments, enabling you to find vanity addresses for both tokens and hooks before submitting transactions. The SDK provides a mineTokenAddress utility that mirrors on-chain calculations.

mineTokenAddress supports matching:

  • A prefix (address starts with hex characters)
  • A suffix (address ends with hex characters, useful as an identifier)
  • Both prefix + suffix simultaneously (logical AND)

Mining Token Addresses (Static Auctions)

For static auctions (V3 pools), you can mine vanity token addresses:

import {
  StaticAuctionBuilder,
  mineTokenAddress,
  getAddresses,
} from '@whetstone-research/doppler-sdk/evm';
import { parseEther } from 'viem';
import { base } from 'viem/chains';

const builder = new StaticAuctionBuilder(base.id)
  .tokenConfig({
    name: 'Vanity Token',
    symbol: 'VNY',
    tokenURI: 'https://example.com/token.json',
  })
  .saleConfig({
    initialSupply: parseEther('1000000'),
    numTokensToSell: parseEther('750000'),
    numeraire: '0x...',
  })
  .poolByTicks({ startTick: -92100, endTick: -69060, fee: 3000 })
  .withGovernance({ type: 'default' })
  .withMigration({ type: 'uniswapV4', fee: 3000, tickSpacing: 60 })
  .withUserAddress('0x...');

const staticParams = builder.build();
// Fetch the encoded create() payload without sending the transaction
const createParams =
  await sdk.factory.encodeCreateStaticAuctionParams(staticParams);
const addresses = getAddresses(base.id);

const { salt, tokenAddress, iterations } = mineTokenAddress({
  prefix: 'dead', // omit 0x prefix
  tokenFactory: createParams.tokenFactory,
  initialSupply: createParams.initialSupply,
  recipient: addresses.airlock,
  owner: addresses.airlock,
  tokenData: createParams.tokenFactoryData,
  maxIterations: 1_000_000, // optional safety cap
});

console.log(
  `Vanity token ${tokenAddress} found after ${iterations} iterations`,
);
// Now submit airlock.create({ ...createParams, salt }) when ready to deploy

You can also mine an identifier at the end of the address using suffix:

const { salt, tokenAddress, iterations } = mineTokenAddress({
  prefix: '',
  suffix: 'beef', // 1-4 hex chars is typically practical
  tokenFactory: createParams.tokenFactory,
  initialSupply: createParams.initialSupply,
  recipient: addresses.airlock,
  owner: addresses.airlock,
  tokenData: createParams.tokenFactoryData,
  maxIterations: 1_000_000,
});

Mining Hook and Token Addresses (Dynamic Auctions)

For dynamic auctions (V4 pools), you can mine both hook and token addresses simultaneously. The miner ensures proper Uniswap V4 hook flags and correct token ordering relative to the numeraire:

import {
  DynamicAuctionBuilder,
  mineTokenAddress,
  getAddresses,
  DopplerBytecode,
  DAY_SECONDS,
} from '@whetstone-research/doppler-sdk/evm';
import { parseEther, keccak256, encodePacked, encodeAbiParameters } from 'viem';
import { base } from 'viem/chains';

const builder = new DynamicAuctionBuilder(base.id)
  .tokenConfig({
    name: 'My Token',
    symbol: 'MTK',
    tokenURI: 'https://example.com/token.json',
  })
  .saleConfig({
    initialSupply: parseEther('1000000'),
    numTokensToSell: parseEther('900000'),
    numeraire: '0x...',
  })
  .poolConfig({ fee: 3000, tickSpacing: 60 })
  .auctionByTicks({
    duration: 7 * DAY_SECONDS,
    epochLength: 3600,
    startTick: -92103,
    endTick: -69080,
    minProceeds: parseEther('100'),
    maxProceeds: parseEther('1000'),
  })
  .withMigration({ type: 'uniswapV4', fee: 3000, tickSpacing: 60 })
  .withUserAddress('0x...');

const dynamicParams = builder.build();
const { createParams } =
  await sdk.factory.encodeCreateDynamicAuctionParams(dynamicParams);
const addresses = getAddresses(base.id);

// Compute hook init code hash (required for hook mining)
const hookInitHashData = encodeAbiParameters(
  [
    { type: 'address' },
    { type: 'uint256' },
    { type: 'uint256' },
    { type: 'uint256' },
    { type: 'uint256' },
    { type: 'uint256' },
    { type: 'int24' },
    { type: 'int24' },
    { type: 'uint256' },
    { type: 'int24' },
    { type: 'bool' },
    { type: 'uint256' },
    { type: 'address' },
    { type: 'uint24' },
  ],
  [
    addresses.poolManager,
    dynamicParams.sale.numTokensToSell,
    dynamicParams.auction.minProceeds,
    dynamicParams.auction.maxProceeds,
    /* startingTime, endingTime, startTick, endTick, epochLength, gamma, isToken0, numPDSlugs */
    /* poolInitializer, fee - extract from createParams */
  ],
);

const hookInitHash = keccak256(
  encodePacked(['bytes', 'bytes'], [DopplerBytecode, hookInitHashData]),
);

const result = mineTokenAddress({
  prefix: 'cafe', // Token prefix
  tokenFactory: createParams.tokenFactory,
  initialSupply: createParams.initialSupply,
  recipient: addresses.airlock,
  owner: addresses.airlock,
  tokenData: createParams.tokenFactoryData,
  tokenVariant: 'standard', // or 'doppler404'
  maxIterations: 1_000_000,
  // Optional: mine hook address with specific prefix too
  hook: {
    deployer: addresses.dopplerDeployer,
    initCodeHash: hookInitHash,
    prefix: '00', // Hook prefix for gas optimization
  },
});

console.log('Token address:', result.tokenAddress);
console.log('Hook address:', result.hookAddress); // only if hook config provided
console.log(`Found after ${result.iterations} iterations`);

Mining Token Addresses (Multicurve Auctions)

For multicurve auctions, you can mine vanity token addresses by computing the CreateParams manually with your mined salt. Unlike static and dynamic auctions, multicurve doesn't automatically mine token addresses:

import {
  MulticurveBuilder,
  mineTokenAddress,
  getAddresses,
} from '@whetstone-research/doppler-sdk/evm';
import { parseEther } from 'viem';
import { base } from 'viem/chains';

const builder = new MulticurveBuilder(base.id)
  .tokenConfig({
    name: 'Vanity Multicurve',
    symbol: 'VMC',
    tokenURI: 'https://example.com/token.json',
  })
  .saleConfig({
    initialSupply: parseEther('1000000'),
    numTokensToSell: parseEther('900000'),
    numeraire: '0x...',
  })
  .poolConfig({
    fee: 3000,
    tickSpacing: 60,
    curves: [
      {
        tickLower: 0,
        tickUpper: 240000,
        numPositions: 10,
        shares: parseEther('0.5'),
      },
      {
        tickLower: 16000,
        tickUpper: 240000,
        numPositions: 10,
        shares: parseEther('0.5'),
      },
    ],
  })
  .withGovernance({ type: 'default' })
  .withMigration({ type: 'uniswapV2' })
  .withUserAddress('0x...');

const multicurveParams = builder.build();
const addresses = getAddresses(base.id);

// Get CreateParams without calling create
const createParams = sdk.factory.encodeCreateMulticurveParams(multicurveParams);

// Mine a vanity token address
const { salt, tokenAddress, iterations } = mineTokenAddress({
  prefix: 'feed',
  tokenFactory: createParams.tokenFactory,
  initialSupply: createParams.initialSupply,
  recipient: addresses.airlock,
  owner: addresses.airlock,
  tokenData: createParams.tokenFactoryData,
  maxIterations: 500_000,
});

console.log(
  `Vanity token ${tokenAddress} found after ${iterations} iterations`,
);

// Use the mined salt in createParams
const vanityCreateParams = { ...createParams, salt };

// Now submit the transaction manually with the vanity salt
await publicClient.writeContract({
  address: addresses.airlock,
  abi: airlockAbi,
  functionName: 'create',
  args: [vanityCreateParams],
  account: walletClient.account,
});

Important: Since encodeCreateMulticurveParams generates a random salt internally, you must construct the final CreateParams manually with your mined salt. The high-level createMulticurve method will replace any provided salt.

Dual-Prefix Mining

When you provide both a token prefix AND a hook configuration with its own prefix, the miner will search for a salt that satisfies both requirements simultaneously. This is useful for V4 deployments where you want:

  • A vanity token address (e.g., starting with cafe)
  • An optimized hook address (e.g., starting with 00 for gas savings)

Note: Dual-prefix mining takes significantly longer than single-prefix mining. Consider using shorter prefixes or higher iteration limits.

Mining Notes

  • Prefix format: Omit the 0x prefix (e.g., use 'dead' not '0x dead')
  • Case insensitive: 'DEAD', 'dead', and 'DeAd' are equivalent
  • Iteration limit: Longer prefixes require more iterations. A 4-character hex prefix takes ~65,000 attempts on average.
  • Token variants: Set tokenVariant: 'standard-v2' or tokenVariant: 'dopplerERC20V1' with v2Implementation for clone templates, or tokenVariant: 'doppler404' for DN404-style tokens
  • Salt preservation: High-level helpers like createStaticAuction and createDynamicAuction recompute salts internally to ensure proper token ordering. To use a mined salt, call encodeCreate*Params and submit the transaction manually via publicClient.writeContract
  • Hook flags: The miner automatically ensures V4 hooks have the correct permission flags for Doppler operations

API Reference

DopplerSDK

The main SDK class providing access to all functionality.

class DopplerSDK {
  constructor(config: DopplerSDKConfig);

  // Properties
  factory: DopplerFactory;
  quoter: Quoter;

  // Methods
  getStaticAuction(poolAddress: Address): Promise<StaticAuction>;
  getDynamicAuction(hookAddress: Address): Promise<DynamicAuction>;
  // Multicurve helper
  buildMulticurveAuction(): MulticurveBuilder;
  getPoolInfo(poolAddress: Address): Promise<PoolInfo>;
  getHookInfo(hookAddress: Address): Promise<HookInfo>;
}

Types

Key types are exported for use in your applications:

import type {
  CreateStaticAuctionParams,
  CreateDynamicAuctionParams,
  CreateMulticurveParams,
  MulticurveInitializerConfig,
  MulticurveDecayFeeSchedule,
  MigrationConfig,
  PoolInfo,
  HookInfo,
  VestingConfig,
} from '@whetstone-research/doppler-sdk/evm';

Development

# Install dependencies
pnpm install

# Build the SDK
pnpm build

# Run all tests
pnpm test

# Run specific test suite
pnpm test:whitelisting

# Run tests in watch mode
pnpm test:watch

# Development mode with watch
pnpm dev

Testing

The SDK includes comprehensive tests covering:

  • Airlock Whitelisting: Verifies that all modules are properly whitelisted on Ethereum Mainnet, Monad Mainnet, Base Mainnet, and Base Sepolia
  • Multicurve Functionality: Tests multicurve auction creation and quoting
  • Token Address Mining: Tests for generating optimized token addresses

To run whitelisting tests:

# Canonical whitelist audit
pnpm test:whitelisting

# With Alchemy fallback (faster and more reliable)
ALCHEMY_API_KEY=your_key_here pnpm test:whitelisting

# Limit to specific whitelist-audit chains when needed
TEST_CHAINS=mainnet,base,base-sepolia,monad-mainnet pnpm test:whitelisting

The whitelisting suite is scoped to the release-audit chains: Ethereum Mainnet, Monad Mainnet, Base Mainnet, and Base Sepolia.

Whitelisting test RPC priority is:

  1. Chain-specific RPC URL env var (ETH_MAINNET_RPC_URL, BASE_RPC_URL, BASE_SEPOLIA_RPC_URL, MONAD_MAINNET_RPC_URL)
  2. ALCHEMY_API_KEY fallback for supported Alchemy networks, including Monad Mainnet
  3. Public/default RPC URL

To run fork tests (Anvil):

# all fork tests
ALCHEMY_API_KEY=your_key_here pnpm test:fork

# chain-specific fork tests
ALCHEMY_API_KEY=your_key_here TEST_CHAIN=base pnpm test:fork
ALCHEMY_API_KEY=your_key_here TEST_CHAIN=base-sepolia pnpm test:fork
ALCHEMY_API_KEY=your_key_here TEST_CHAIN=mainnet pnpm test:fork
ALCHEMY_API_KEY=your_key_here TEST_CHAIN=eth-sepolia pnpm test:fork

You can also provide chain-specific RPC URLs directly:

ETH_MAINNET_RPC_URL=https://... TEST_CHAIN=mainnet pnpm test:fork
ETH_SEPOLIA_RPC_URL=https://... TEST_CHAIN=eth-sepolia pnpm test:fork

Migration from Previous SDKs

If you're migrating from doppler-v3-sdk or doppler-v4-sdk, see our Migration Guide.

Contributing

Contributions are welcome! Please see our Contributing Guide for details.

License

MIT License - see LICENSE for details.