@whetstone-research/doppler-sdk
v1.0.23
Published
A unified TypeScript SDK for interacting with the Doppler Protocol across EVM and Solana/SVM deployments.
Keywords
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 viemUse 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 bothstartTickandendTickare 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()forCreateOpeningAuctionParamssdk.factory.simulateCreateOpeningAuction(params)andsdk.factory.createOpeningAuction(params)sdk.getOpeningAuction(hookAddress)for hook reads +settleAuction()/claimIncentives()sdk.factory.simulateCompleteOpeningAuction(...)andsdk.factory.completeOpeningAuction(...)for handoff to Dopplersdk.getOpeningAuctionLifecycle(initializerAddress?)for initializer-level state + complete/recover/sweep helperssdk.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
positionIdfor incentives viaopening.getPositionId(...)oropening.claimIncentivesByPositionKey(...)(no log parsing required)
- Resolve the address via
Base caveat: on Base mainnet (
chainId = 8453),openingAuctionInitializerandopeningAuctionPositionManagerdefault to0x0000000000000000000000000000000000000000until 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
tokenBatchSizetoMulticurveFeeswhen 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
gasfield onCreateStaticAuctionParams/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 returngasEstimateso 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:
- Retrieving pool configuration from the multicurve initializer contract
- Detecting pool status so only locked initializer-side pools proceed
- Computing the PoolId from the PoolKey using
keccak256(abi.encode(poolKey)) - Previewing pending fees with a Multicall3 aggregate that simulates collection and reads beneficiary share/checkpoint data
- 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 pairMulticurveFees.getPendingFees(beneficiary)returns pending fees for each requested token and uses one multicall by defaultMulticurveFeesacceptstokenBatchSizewhen large token lists need to be split into smaller multicallscollectFees()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.beneficiariesand 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()orMulticurvePool.collectFees() getFeeSchedule()returns decay schedule details only for dynamic-fee multicurve pools, otherwisenull- 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 relayerRelayer (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/getPastTotalSupplyexpect a timepoint; for block‑based clocks, pass a block number that has already been mined.- Events you may track:
DelegateChangedandDelegateVotesChangedfor 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
CreateParamsand the predicted token address - Decide
amountOutto buy, simulateamountInwithsimulateBundleExactOutput(...) - 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
TopUpDistributorbefore 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'),
},
}streamableFeesis required foruniswapV4Split.- Beneficiaries must sum to
1e18, and the Airlock owner must be included with at least 5% shares. - The split recipient also receives any
TopUpDistributorfunds 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 deployYou 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
00for 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
0xprefix (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'ortokenVariant: 'dopplerERC20V1'withv2Implementationfor clone templates, ortokenVariant: 'doppler404'for DN404-style tokens - Salt preservation: High-level helpers like
createStaticAuctionandcreateDynamicAuctionrecompute salts internally to ensure proper token ordering. To use a mined salt, callencodeCreate*Paramsand submit the transaction manually viapublicClient.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 devTesting
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:whitelistingThe whitelisting suite is scoped to the release-audit chains: Ethereum Mainnet, Monad Mainnet, Base Mainnet, and Base Sepolia.
Whitelisting test RPC priority is:
- Chain-specific RPC URL env var (
ETH_MAINNET_RPC_URL,BASE_RPC_URL,BASE_SEPOLIA_RPC_URL,MONAD_MAINNET_RPC_URL) ALCHEMY_API_KEYfallback for supported Alchemy networks, including Monad Mainnet- 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:forkYou 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:forkMigration 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.
