DEVELOPERS
Build with Otomate
Launch a token, buy it on its bonding curve, and sell it from your own wallet. Start with the shared TypeScript setup below, then follow the action you want to implement.
Start here
| I want to… | Tutorial | Result |
|---|---|---|
| Launch a token | Launching a token | A confirmed transaction and the new token and curve addresses. |
| Buy a token | Buying a token | A quote, any required approval, and a confirmed purchase. |
| Sell a token | Selling a token | An exact token allowance and a confirmed sale. |
| Buy during creation | Launch and buy in one transaction | Creation and an initial purchase in the same transaction. |
| Discover tokens | Discover tokens on-chain | Factory events and verified project records. |
| Integrate collections | Collections, rewards, and indexing | Collection lifecycle, claims and events. |
These examples target Launcher105 on Ink (chain ID 57073). Use the contract addresses and integration ABI files below.
Addresses and ABI downloads
Use the proxy addresses below for calls and transactions. These addresses were read from Otomate's public launch configuration and their proxy/implementation code hashes were checked on Ink at block 56,408,226 on 20 September 2026. This verifies deployment identity; it is not a live transaction test of every example.
| Contract | Ink address |
|---|---|
| Project factory | 0x9F317a4307E7496dfb839CfE8aa063a94c99aECF |
| Token launch contract | 0xd63c58450E5b5E8DbB681641e84449aA25730919 |
Download the deployment identity record. Resolve each project's token and curve from the factory; there is no single curve address for all tokens.
| Contract | JSON ABI | TypeScript ABI |
|---|---|---|
| Curve: quote, buy, sell and trade events | JSON | TypeScript |
| Factory: project reads and creation events | JSON | TypeScript |
| Creation: commitment, predictions and launch tuples | JSON | TypeScript |
| Token launch contract: platform quote and launch | JSON | TypeScript |
| Launch configurations | JSON | TypeScript |
| Quote assets and presets | JSON | TypeScript |
| Market readiness and pool discovery | JSON | TypeScript |
| Companion rewards and claims | JSON | TypeScript |
These are integration ABI subsets. They include launch-time fee inputs and fee reads needed to quote transactions, and transaction events. Token and curve addresses are resolved per project with factory.project(projectId).
Set up your integration
The examples use TypeScript and viem. Use the same actions with a browser wallet or a server signer for a bot. In your own integration project, install viem:
npm install viem
Download the TypeScript ABI files above into an abis/ directory in your integration project. Copy this setup and the action examples you need into your own module. No kit or Otomate repository checkout is required.
import {
createPublicClient, createWalletClient, custom, http, erc20Abi,
encodeAbiParameters, encodeFunctionData, getAbiItem, keccak256,
stringToHex, toHex, parseUnits, parseEventLogs,
type Address, type Hex, type EIP1193Provider,
type ContractFunctionArgs, type WalletClient, type Account, parseAbi,
BaseError, ContractFunctionRevertedError,
} from 'viem';
import { ink } from 'viem/chains';
import { LaunchConfigurationAbi105 as configurationAbi } from './abis/LaunchConfiguration';
import { LocalStockQuoteRegistryAbi105 as registryAbi } from './abis/LocalStockQuoteRegistry';
import { LauncherMarketCoordinator035Abi105 as marketAbi } from './abis/LauncherMarketCoordinator035';
import { LauncherProjectFactory037Abi105 as factoryAbi }
from './abis/LauncherProjectFactory037';
import { ILauncherProjectFactory105Abi105 as creationAbi }
from './abis/ILauncherProjectFactory105';
import { LauncherProjectEntitlement105Abi105 as entitlementAbi }
from './abis/LauncherProjectEntitlement105';
const curveAbi = parseAbi([
"function buyNativeWithFeePolicy103(uint256 minimum, uint256 deadline, address recipient, bytes32 expected, uint16 maximumOrdinaryBps) payable returns ((uint256 grossQuoteUsed, uint256 grossQuoteRefund, uint256 principalIn, uint256 feeAmount, uint256 tokensOut))",
"function buyWithFeePolicy103(uint256 gross, uint256 minimum, uint256 deadline, address recipient, bytes32 expected, uint16 maximumOrdinaryBps) returns ((uint256 grossQuoteUsed, uint256 grossQuoteRefund, uint256 principalIn, uint256 feeAmount, uint256 tokensOut))",
"function currentFeeWitness103() view returns (bytes32 hash, uint64 version, uint16 totalBps)",
"function graduated() view returns (bool)",
"function launchActive104() view returns (bool active)",
"function quoteAsset() view returns (address)",
"function quoteBuyFor(uint256 gross, address recipient) view returns ((uint256 grossQuoteUsed, uint256 grossQuoteRefund, uint256 principalIn, uint256 feeAmount, uint256 tokensOut))",
"function quoteSell(uint256 amount) view returns ((uint256 tokensIn, uint256 grossQuoteOut, uint256 feeAmount, uint256 netQuoteOut))",
"function sellNativeWithFeePolicy103(uint256 amount, uint256 minimum, uint256 deadline, address recipient, bytes32 expected, uint16 maximumOrdinaryBps) returns ((uint256 tokensIn, uint256 grossQuoteOut, uint256 feeAmount, uint256 netQuoteOut))",
"function sellWithFeePolicy103(uint256 amount, uint256 minimum, uint256 deadline, address recipient, bytes32 expected, uint16 maximumOrdinaryBps) returns ((uint256 tokensIn, uint256 grossQuoteOut, uint256 feeAmount, uint256 netQuoteOut))",
"function token() view returns (address)",
"event TokensBought(address indexed payer, address indexed recipient, uint256 grossQuoteUsed, uint256 feeAmount, uint256 tokensOut, uint256 realQuoteReserve, uint256 tokenReserve)",
"event TokensSold(address indexed seller, address indexed recipient, uint256 tokensIn, uint256 grossQuoteOut, uint256 feeAmount, uint256 netQuoteOut, uint256 realQuoteReserve, uint256 tokenReserve)"
]);
// reserve must atomically insert a unique key and commit before returning true.
// Keep failed/uncertain attempts until their transaction has been reconciled.
export type AttemptStore = {
reserve(key: string, transaction: { to: Address; data: Hex; value: string }): Promise<boolean>;
submitted(key: string, hash: Hex): Promise<void>;
confirmed(key: string, hash: Hex): Promise<void>;
};
export type Context = {
client: ReturnType<typeof createPublicClient<ReturnType<typeof http>, typeof ink>>;
wallet: WalletClient;
account: Address;
signer: Account | Address;
attempts: AttemptStore;
};
export async function connectOtomate(provider: EIP1193Provider, rpcUrl: string,
attempts: AttemptStore): Promise<Context> {
const client = createPublicClient({ chain: ink, transport: http(rpcUrl) });
const wallet = createWalletClient({ chain: ink, transport: custom(provider) });
await wallet.switchChain({ id: ink.id });
const [account] = await wallet.requestAddresses();
if (!account || await client.getChainId() !== ink.id) throw new Error('Connect to Ink');
return { client, wallet, account, signer: account, attempts };
}
export async function connectBot(signer: Account, rpcUrl: string,
attempts: AttemptStore): Promise<Context> {
const client = createPublicClient({ chain: ink, transport: http(rpcUrl) });
if (await client.getChainId() !== ink.id) throw new Error('Wrong RPC chain');
const wallet = createWalletClient({ account: signer, chain: ink, transport: http(rpcUrl) });
return { client, wallet, account: signer.address, signer, attempts };
}
type Transaction = { to: Address; data: Hex; value: bigint };
// A persistent attempt ID prevents this example from blindly sending twice.
// Keep the same ID when inspecting or recovering the same attempted action.
async function sendOnce(ctx: Context, attemptId: string, tx: Transaction) {
const key = `otomate:${ink.id}:${ctx.account}:${attemptId}`;
const [selected] = await ctx.wallet.getAddresses();
if (selected?.toLowerCase() !== ctx.account.toLowerCase()
|| await ctx.wallet.getChainId() !== ink.id
|| await ctx.client.getChainId() !== ink.id) {
throw new Error('Wallet or network changed');
}
await ctx.client.call({ ...tx, account: ctx.account });
const estimate = await ctx.client.estimateGas({ ...tx, account: ctx.account });
const gas = (estimate * 120n + 99n) / 100n;
const block = await ctx.client.getBlock();
if (gas > 10_000_000n || gas > block.gasLimit) {
throw new Error('Transaction exceeds the normal gas budget');
}
if (!await ctx.attempts.reserve(key, {
to: tx.to, data: tx.data, value: tx.value.toString(),
})) throw new Error('Reconcile the existing attempt');
const hash = await ctx.wallet.sendTransaction({ ...tx, account: ctx.signer, chain: ink, gas });
await ctx.attempts.submitted(key, hash);
const receipt = await ctx.client.waitForTransactionReceipt({ hash, confirmations: 2 });
if (receipt.status !== 'success') throw new Error(`Transaction reverted: ${hash}`);
const canonical = await ctx.client.getBlock({ blockNumber: receipt.blockNumber });
if (canonical.hash !== receipt.blockHash) throw new Error('Receipt was reorganized');
await ctx.attempts.confirmed(key, hash);
return receipt;
}
async function approveExact(ctx: Context, token: Address, spender: Address,
amount: bigint, attemptId: string) {
const allowance = await ctx.client.readContract({ address: token,
abi: erc20Abi, functionName: 'allowance', args: [ctx.account, spender] });
if (allowance >= amount) return;
// Supports admitted tokens that require clearing a nonzero allowance first.
if (allowance > 0n) await sendOnce(ctx, `${attemptId}:reset`, {
to: token, data: encodeFunctionData({ abi: erc20Abi,
functionName: 'approve', args: [spender, 0n] }), value: 0n,
});
await sendOnce(ctx, `${attemptId}:approve`, {
to: token, data: encodeFunctionData({ abi: erc20Abi,
functionName: 'approve', args: [spender, amount] }), value: 0n,
});
}
function minimumAfterSlippage(output: bigint, slippageBps: bigint) {
if (slippageBps < 0n || slippageBps >= 10_000n) throw new Error('Invalid slippage');
const minimum = output * (10_000n - slippageBps) / 10_000n;
if (minimum <= 0n) throw new Error('Output is too small');
return minimum;
}
Call connectOtomate from a user-initiated Connect button with the wallet's EIP-1193 provider, your Ink RPC URL and your attempt store. If Ink is not configured in the wallet, add it through your wallet integration first. Each action below receives the returned context.
For a bot, call connectBot(signer, rpcUrl, attempts) with a viem Account backed by your server's signing system, then pass that context to launchToken, buyToken or sellToken. Keep signing credentials on the server. Serialize submissions per signing wallet to avoid nonce conflicts.
Implement AttemptStore using your durable storage: a unique key must reserve each action atomically before sending. Persist the action ID before a Telegram command is processed, including across retries. If sending or saving the hash fails, reconcile the wallet nonce and transaction before another send. Two confirmations are this example's threshold, not a universal finality guarantee.
Fill in your configuration
| Input | Where it comes from |
|---|---|
| Ink RPC URL | Your configured provider for chain 57073. |
| Factory and entitlement | The proxy address table above. |
| Token and curve | factory.project(projectId) for the selected project. |
| Quote asset | curve.quoteAsset(); use its own ERC-20 decimals. |
| Native/WETH eligibility | The deployment's verified wrapped-native asset; never assume any ERC-20 pair accepts native ETH. |
| Recipe ID, hash and preset | Read available launch configurations. |
| Metadata and economics | The metadata URIs you supply and the fee configuration accepted by the selected recipe. |
Match runtime code, proxy implementations where applicable, chain and ABI to that registry before calling the examples. A matching method name alone does not establish compatibility. Token symbols are not identifiers.
Handle amounts and wallet changes
Use parseUnits('0.01', decimals) and bigint, never floating point. 100n basis points means 1% slippage. The examples calculate a five-minute deadline from the latest block's timestamp.
Display the input, estimated output, fees, refund if any, recipient and minimum before invoking a send. The functions below perform a fresh quote immediately before submission; an application should present that quote for approval and restart the review if the wallet, amount, recipient or network changes.
Generation105 retains some method names ending in 103. Use those exact selectors. The creation domain is OTOMATE_LAUNCHER_PROJECT_CREATE_105_V2; the old V1 payload is incompatible.
Launching a token
Create a token through the entitlement contract. It collects the platform payment and calls the factory to build the project. Use payAndCreate for creation alone, or payAndCreateAndBuy to include your initial purchase.
Flow: prepare metadata → select an approved recipe/preset → configure creator allocations → build the request → read the platform fee and admission state → encode → simulate → sign → extract ProjectCreated → read the new project.
Reuse the imports and submission helpers from Set up your integration. The example calls the contracts directly for both launch paths.
Prepare your launch inputs
| Field | What to supply |
|---|---|
factory, entitlement | Verified addresses for the same admitted deployment. |
recipeHash | The reviewed hash of the selected recipe. The function compares it with the factory. |
intent.nonce | A new 32-byte nonce for this project; persist it before preparation and reuse it for recovery. |
intent.recipe | Approved recipe ID as bigint. |
intent.quote | Approved quote-asset contract. For a native-funded launch this is the configured wrapped-native asset, not the zero address. |
intent.preset, intent.presetHash | The selected asset preset and its reviewed hash. |
intent.mode | 0 for token-only, 1 for token with Companions; this is not the economics creationMode. |
intent.name, intent.symbol | Your token's name and symbol, matching the prepared metadata. |
metadataURI | Your token metadata URI, satisfying the selected contract’s URI requirements. |
hiddenURI, finalDirectionCommitment | The prepared sealed-art metadata and direction commitment required by the selected collection mode. Do not substitute arbitrary placeholders. |
economics | The complete creation fee configuration described below. |
maximumPlatformFee | Maximum native platform payment you accept, in wei; not the trading fee. |
Generate a nonce once with toHex(crypto.getRandomValues(new Uint8Array(32))). Persist it with the chosen recipe, name, symbol, metadata and fee settings before launching. The tutorial reads the current factory revision and sets a five-minute transaction deadline.
For example, the editable token identity can be name: 'Example Token' and symbol: 'EXAMPLE'. Recipe IDs, presets, hashes, metadata commitments and contract addresses must match the selected deployment and the metadata you supply, not an example copied from another project.
Read available launch configurations
Resolve the market and quote registry from the factory. Recipe IDs come from RecipeAdded events; they are not preset IDs. Scan from the factory's deployment block, or resume from your saved cursor, in bounded ranges. Use a confirmed toBlock and reconcile reorgs before persisting results.
export async function readLaunchConfigurations(ctx: Context, factory: Address,
fromBlock: bigint, toBlock: bigint) {
if (fromBlock < 0n || toBlock < fromBlock) throw new Error('Invalid block range');
const infrastructure = await ctx.client.readContract({ address: factory,
abi: factoryAbi, functionName: 'infrastructure', blockNumber: toBlock });
const registry = await ctx.client.readContract({ address: infrastructure.market,
abi: marketAbi, functionName: 'stockRegistry', blockNumber: toBlock });
const recipes = new Map<bigint, Hex>();
for (let from = fromBlock; from <= toBlock; from += 1_000n) {
const end = from + 999n < toBlock ? from + 999n : toBlock;
const logs = await ctx.client.getContractEvents({ address: factory,
abi: factoryAbi, eventName: 'RecipeAdded', fromBlock: from, toBlock: end,
strict: true });
for (const log of logs) recipes.set(log.args.version, log.args.recipeHash);
}
const availableRecipes = [];
for (const [id, expectedHash] of recipes) {
const enabled = await ctx.client.readContract({ address: factory,
abi: factoryAbi, functionName: 'recipeEnabled', args: [id], blockNumber: toBlock });
if (!enabled) continue;
const hash = await ctx.client.readContract({ address: factory,
abi: factoryAbi, functionName: 'recipeHash', args: [id], blockNumber: toBlock });
if (hash !== expectedHash) throw new Error('Recipe identity mismatch');
const config = await ctx.client.readContract({ address: factory,
abi: configurationAbi, functionName: 'recipeConfig103', args: [id], blockNumber: toBlock });
availableRecipes.push({ id, hash, config });
}
const count = await ctx.client.readContract({ address: registry,
abi: registryAbi, functionName: 'presetCount', blockNumber: toBlock });
const block = await ctx.client.getBlock({ blockNumber: toBlock });
const presets = [];
for (let id = 1n; id <= count; id++) {
const hash = await ctx.client.readContract({ address: registry,
abi: registryAbi, functionName: 'configHash', args: [id], blockNumber: toBlock });
// A contract revert marks an unavailable preset. RPC failures must propagate.
try {
const [asset, economics] = await ctx.client.readContract({ address: registry,
abi: registryAbi, functionName: 'validateCreation',
args: [id, hash, block.timestamp + 300n], blockNumber: toBlock });
presets.push({ id, hash, quote: asset.quote, asset, economics });
} catch (error) {
if (error instanceof BaseError && error.walk(e => e instanceof ContractFunctionRevertedError)
instanceof ContractFunctionRevertedError) continue;
throw error;
}
}
const wrappedNative = await ctx.client.readContract({ address: registry,
abi: registryAbi, functionName: 'weth', blockNumber: toBlock });
return { market: infrastructure.market, registry, wrappedNative, recipes: availableRecipes, presets };
}
Import BaseError and ContractFunctionRevertedError from viem alongside the setup imports. Reads use one block so the list is internally consistent. Refresh the selected configuration immediately before signing; final launch simulation checks whether the chosen recipe, preset and mode can be combined.
Select a returned recipe and preset explicitly. Set intent.recipe = recipe.id, recipeHash = recipe.hash, intent.preset = preset.id, intent.presetHash = preset.hash, and intent.quote = preset.quote. Pass recipe.config.tokenTrading to tokenOnlyEconomics below. Companion launches use companionTrading and companionRoyalty instead. The selected preset supplies curve economics; the recipe supplies the launch fee allocations. Keep those two configurations separate.
Choose and keep your launch salt
The creation ABI calls the user-supplied value intent.nonce: a nonzero bytes32. This is your launch's unique seed, not your wallet's transaction nonce. There is no separate salt field to add to payAndCreate or the factory request.
Generate it once and save it before metadata preparation:
export function createLaunchNonce(): Hex {
let nonce: Hex;
do { nonce = toHex(crypto.getRandomValues(new Uint8Array(32))); }
while (nonce === `0x${'0'.repeat(64)}`);
return nonce;
}
Put that value in request.intent.nonce. The entitlement derives the project ID from the component domain, chain ID, entitlement address, owner and nonce. The deployment contracts derive their own CREATE2 salts from that project identity and each component's role.
Keep the same nonce throughout preparation, signing and recovery. Changing it creates a different project identity and invalidates receiver predictions tied to the original ID. It is not a replacement for checking an uncertain transaction.
For receiver addresses, first read entitlementId(owner, nonce), then call factory.predictedReceivers105(projectId, creatorCash, paired) with the selected project mode. Bind the returned receiver addresses into the prepared fee configuration before computing the commitment. Do not treat the creator-only fee-settings helper below as a complete deployment-ready receiver configuration.
Configure launch fees
The economics tuple contains trading, royalty, creatorExtraBps, creatorExtraBuybackBps, baseCreatorBuybackBps and creationMode. Both policies contain totalBps, six allocationBps, three creatorWeights, six recipients and companions.
allocationBpsare fee rates assigned to each destination; their sum is the policy'stotalBps.creatorWeightssplit the creator allocation between wallet, project buyback and Companion rewards. The weights sum to 10,000.creatorExtraBpsis the additional creator trading rate.100basis points is 1%.- Buyback settings split a creator allocation; they do not add that percentage to the whole trade.
creationModedistinguishes token-only (0), Companions (1) and token-only with collection attachment configured (2). It is separate from the lifecycle mode above.
Use the recipe's approved rates and receiver configuration. Do not fill every recipient slot with an arbitrary wallet or invent the protocol allocations. Build the token-only configuration from the approved recipe's trading rates:
export function tokenOnlyEconomics(owner: Address, approvedTrading: {
totalBps: number;
allocationBps: readonly [number, number, number, number, number, number];
}): Economics {
const recipients = [owner, owner, owner, owner, owner, owner] as const;
return {
trading: { ...approvedTrading, creatorWeights: [10_000, 0, 0],
recipients, companions: false },
royalty: { totalBps: 0, allocationBps: [0, 0, 0, 0, 0, 0],
creatorWeights: [10_000, 0, 0], recipients, companions: false },
creatorExtraBps: 0, creatorExtraBuybackBps: 0,
baseCreatorBuybackBps: 0, creationMode: 0,
};
}
This example keeps the recipe's creator trading allocation and adds no extra fee or buyback. launchToken below replaces the receiver placeholders with the contract's predicted addresses before calculating the commitment. Companion launches require their selected recipe's corresponding trading and royalty configurations.
Submit the launch
The following code builds the actual ABI payload, pays the platform fee, optionally funds a first buy, and returns the created project. It uses the domain-prefixed factory payload required by Launcher105 V2.
type CreateArgs = ContractFunctionArgs<typeof creationAbi, 'nonpayable', 'createReserved105'>;
type CreateRequest = CreateArgs[0];
type Economics = CreateArgs[1];
type LaunchInput = {
factory: Address; entitlement: Address; recipeHash: Hex;
request: Omit<CreateRequest, 'factoryRevision' | 'deadline'>;
economics: Economics; maximumPlatformFee: bigint; attemptId: string;
initialBuy?: { amount: string; native: boolean; wrappedNative: Address; slippageBps: bigint };
};
export async function launchToken(ctx: Context, input: LaunchInput) {
const { client, account } = ctx;
const { factory, entitlement } = input;
let economics = input.economics;
const quoteAsset = input.request.intent.quote;
const firstBuy = input.initialBuy;
let amount = 0n;
if (firstBuy) {
if (firstBuy.native && quoteAsset.toLowerCase() !== firstBuy.wrappedNative.toLowerCase()) {
throw new Error('Selected quote asset does not support native funding');
}
const decimals = firstBuy.native ? 18 : await client.readContract({
address: quoteAsset, abi: erc20Abi, functionName: 'decimals',
});
amount = parseUnits(firstBuy.amount, decimals);
if (amount <= 0n) throw new Error('Initial purchase must be positive');
if (!firstBuy.native) {
const balance = await client.readContract({ address: quoteAsset,
abi: erc20Abi, functionName: 'balanceOf', args: [account] });
if (balance < amount) throw new Error('Insufficient initial-buy balance');
// Atomic launch uses ENTITLEMENT as spender, not the future curve.
await approveExact(ctx, quoteAsset, entitlement, amount, input.attemptId);
}
}
const block = await client.getBlock();
const factoryRead = { address: factory, abi: factoryAbi, blockNumber: block.number } as const;
const entitlementRead = { address: entitlement, abi: entitlementAbi, blockNumber: block.number } as const;
const [fee, feeVersion, allowed, enabled, recipeHash, factoryAdmission, entitlementAdmission] = await Promise.all([
client.readContract({ ...entitlementRead, functionName: 'fee' }),
client.readContract({ ...entitlementRead, functionName: 'feeVersion' }),
client.readContract({ ...entitlementRead, functionName: 'factoryAllowed', args: [factory] }),
client.readContract({ ...factoryRead, functionName: 'recipeEnabled', args: [input.request.intent.recipe] }),
client.readContract({ ...factoryRead, functionName: 'recipeHash', args: [input.request.intent.recipe] }),
client.readContract({ ...factoryRead, functionName: 'requireAdmission' }),
client.readContract({ ...entitlementRead, functionName: 'requireAdmission' }),
]);
if (!allowed || !enabled || recipeHash.toLowerCase() !== input.recipeHash.toLowerCase()) {
throw new Error('Factory or recipe is not admitted');
}
if (fee > input.maximumPlatformFee) throw new Error('Platform fee exceeds your limit');
const deadline = block.timestamp + 300n;
const request: CreateRequest = { ...input.request, factoryRevision: factoryAdmission[0], deadline };
const projectId = await client.readContract({ ...entitlementRead,
functionName: 'entitlementId', args: [account, request.intent.nonce] });
const receivers = await client.readContract({ address: factory, abi: creationAbi,
functionName: 'predictedReceivers105',
args: [projectId, economics.trading.recipients[0], economics.creationMode !== 0],
blockNumber: block.number });
economics = { ...economics,
trading: { ...economics.trading, recipients: receivers },
royalty: { ...economics.royalty,
recipients: [economics.royalty.recipients[0], receivers[1], receivers[2],
receivers[3], receivers[4], receivers[5]] },
};
const commitment = await client.readContract({ address: factory, abi: creationAbi,
functionName: 'commitmentFor105', args: [account, request.intent, economics],
blockNumber: block.number });
const create = getAbiItem({ abi: creationAbi, name: 'createReserved105' });
const creationData = encodeAbiParameters(
[{ type: 'bytes32' }, ...create.inputs],
[keccak256(stringToHex('OTOMATE_LAUNCHER_PROJECT_CREATE_105_V2')), request, economics],
);
const feeQuote = {
nonce: request.intent.nonce, commitment, factory, feeVersion,
factoryRevision: factoryAdmission[0], entitlementRevision: entitlementAdmission[0],
maxFee: input.maximumPlatformFee, deadline,
};
let data: Hex;
if (firstBuy) {
const quote = await client.readContract({ address: factory, abi: creationAbi,
functionName: 'quoteInitialBuy105',
args: [account, request.intent, economics, amount, deadline], blockNumber: block.number,
});
if (quote.grossQuoteUsed <= 0n) throw new Error('No executable initial-buy quote');
const minimum = minimumAfterSlippage(quote.tokensOut, firstBuy.slippageBps);
data = encodeFunctionData({ abi: entitlementAbi, functionName: 'payAndCreateAndBuy',
args: [feeQuote, creationData, {
quoteAsset, amountIn: amount,
minimumTokensOut: minimum * amount / quote.grossQuoteUsed,
deadline, nativePayment: firstBuy.native,
}],
});
} else {
data = encodeFunctionData({ abi: entitlementAbi, functionName: 'payAndCreate',
args: [feeQuote, creationData] });
}
const receipt = await sendOnce(ctx, `${input.attemptId}:launch`, {
to: entitlement, data, value: fee + (firstBuy?.native ? amount : 0n),
});
const events = parseEventLogs({ abi: factoryAbi, eventName: 'ProjectCreated',
logs: receipt.logs.filter(log => log.address.toLowerCase() === factory.toLowerCase()),
});
const event = events.find(item => item.args.entitlementId === projectId
&& item.args.owner.toLowerCase() === account.toLowerCase()
&& item.args.commitment === commitment);
if (!event) throw new Error(`Inspect launch receipt ${receipt.transactionHash}`);
const project = await client.readContract({ address: factory, abi: factoryAbi,
functionName: 'project', args: [projectId], blockNumber: receipt.blockNumber });
if (project.token.toLowerCase() !== event.args.token.toLowerCase()) {
throw new Error('Created token does not match the project record');
}
return { hash: receipt.transactionHash, projectId, token: project.token,
curve: project.curve, project };
}
Call launchToken(ctx, input) after reviewing the inputs. With initialBuy omitted, it submits payAndCreate and attaches only the platform fee. The returned token and curve are the addresses to save and pass to the trading examples. Do not infer the new token address from the transaction's destination: that destination is the entitlement contract.
Use it from your app
Call this handler after the user finishes the creation form and reviews the prepared configuration. preparedLaunch contains the fields from the input table above, including the saved nonce and attempt ID. No transaction is sent while the form is being edited.
export async function launchFromYourApp(
provider: EIP1193Provider, rpcUrl: string, attempts: AttemptStore, preparedLaunch: LaunchInput,
) {
const ctx = await connectOtomate(provider, rpcUrl, attempts);
const { hash, projectId, token, curve } = await launchToken(ctx, preparedLaunch);
// Save these values in your project record and show the token to the user.
return { hash, projectId, token, curve };
}
Launch and buy in one transaction
To purchase at creation, supply the same LaunchInput with:
export function withInitialEthBuy(input: LaunchInput, wrappedNative: Address): LaunchInput {
return { ...input, initialBuy: {
amount: '0.01', native: true, wrappedNative, slippageBps: 100n,
} };
}
Pass the result to launchToken. It quotes the initial buy, calculates its bound and calls payAndCreateAndBuy with value = platformFee + 0.01 ETH. Use this only with the admitted wrapped-native quote asset.
For an ERC-20-funded first purchase, set native: false. The same function reads the quote token's decimals, approves the entitlement, and sends only the platform fee as native value. An approval may require its own transaction even though creation and the purchase are atomic.
Verify your new token
A successful receipt must include ProjectCreated from the expected factory, for the expected owner, project ID and commitment. Read factory.project(projectId) and retain the complete graph, especially token, curve, quote and any Companion addresses.
Check curve.launchActive104() before trying to trade. Token creation does not prove collection artwork is ready. For standard generated collections, full generation becomes eligible after graduation; mint/reveal readiness is tracked separately.
If the response is lost, recover the existing transaction from your wallet/journal and read the project for the same owner and nonce. Do not generate a second project nonce as an automatic retry.
Read markets and prepare trades
A token trades on its curve before graduation and on its pool afterwards. The buy and sell examples below target the curve. Reuse the imports and helpers from Set up your integration.
Buying a token
Flow: resolve the project → read the quote asset → convert the input to raw units → approve if spending ERC-20 → get a fresh quote and fee witness → simulate → submit → check TokensBought.
- For an ETH-funded purchase, use
buyNativeWithFeePolicy103and attach ETH asvalue. This is available only for the verified wrapped-native pair. - For WETH or another admitted ERC-20, approve the curve, then call
buyWithFeePolicy103. Nativevalueis zero. - Always quote for the actual recipient. Launch protection can depend on that recipient.
export async function buyToken(ctx: Context, input: {
curve: Address; recipient: Address; amount: string; native: boolean;
wrappedNative: Address; slippageBps: bigint; attemptId: string;
}) {
const { client } = ctx;
const read = { address: input.curve, abi: curveAbi } as const;
const quoteAsset = await client.readContract({ ...read, functionName: 'quoteAsset' });
if (input.native && quoteAsset.toLowerCase() !== input.wrappedNative.toLowerCase()) {
throw new Error('This pair does not accept native ETH');
}
const decimals = input.native ? 18 : await client.readContract({
address: quoteAsset, abi: erc20Abi, functionName: 'decimals',
});
const amount = parseUnits(input.amount, decimals);
if (amount <= 0n) throw new Error('Enter a positive amount');
if (!input.native) {
const balance = await client.readContract({ address: quoteAsset,
abi: erc20Abi, functionName: 'balanceOf', args: [ctx.account] });
if (balance < amount) throw new Error('Insufficient quote-asset balance');
await approveExact(ctx, quoteAsset, input.curve, amount, input.attemptId);
}
// Quote AFTER approval: its transaction may have changed the current block.
const block = await client.getBlock();
const [graduated, active, quote, witness] = await Promise.all([
client.readContract({ ...read, functionName: 'graduated', blockNumber: block.number }),
client.readContract({ ...read, functionName: 'launchActive104', blockNumber: block.number }),
client.readContract({ ...read, functionName: 'quoteBuyFor',
args: [amount, input.recipient], blockNumber: block.number }),
client.readContract({ ...read, functionName: 'currentFeeWitness103', blockNumber: block.number }),
]);
if (graduated || !active) throw new Error('Curve trading is unavailable');
if (quote.grossQuoteUsed <= 0n) throw new Error('No executable quote');
const effectiveMinimum = minimumAfterSlippage(quote.tokensOut, input.slippageBps);
// Raw 105 ABI minima are relative to the offered input, including partial fills.
const minimum = effectiveMinimum * amount / quote.grossQuoteUsed;
const deadline = block.timestamp + 300n;
const [policyHash, , maximumOrdinaryBps] = witness;
const data = input.native
? encodeFunctionData({ abi: curveAbi, functionName: 'buyNativeWithFeePolicy103',
args: [minimum, deadline, input.recipient, policyHash, maximumOrdinaryBps] })
: encodeFunctionData({ abi: curveAbi, functionName: 'buyWithFeePolicy103',
args: [amount, minimum, deadline, input.recipient, policyHash, maximumOrdinaryBps] });
const receipt = await sendOnce(ctx, `${input.attemptId}:buy`, {
to: input.curve, data, value: input.native ? amount : 0n,
});
const fills = parseEventLogs({ abi: curveAbi, eventName: 'TokensBought',
logs: receipt.logs.filter(log => log.address.toLowerCase() === input.curve.toLowerCase()),
});
if (fills.length !== 1 || fills[0].args.recipient.toLowerCase() !== input.recipient.toLowerCase()) {
throw new Error(`Inspect purchase receipt ${receipt.transactionHash}`);
}
return { hash: receipt.transactionHash, quoted: quote, executed: fills[0].args };
}
Call buyToken with the connected context, the verified project's curve, your recipient, amount: '0.01', slippageBps: 100n and a persisted unique attempt ID. Set native: true to spend 0.01 ETH on a supported native pair, or native: false to spend 0.01 units of the curve's ERC-20 quote asset. wrappedNative must come from the verified deployment configuration.
Use it from your app
This click handler buys 0.01 ETH worth of tokens for the connected wallet. Supply your RPC URL, the selected token's curve and the configured wrapped-native address. Save attemptId with the user's purchase before calling it.
export async function buyFromYourApp(
provider: EIP1193Provider, rpcUrl: string, attempts: AttemptStore,
curve: Address, wrappedNative: Address, attemptId: string,
) {
const ctx = await connectOtomate(provider, rpcUrl, attempts);
const result = await buyToken(ctx, {
curve,
recipient: ctx.account,
amount: '0.01',
native: true,
wrappedNative,
slippageBps: 100n,
attemptId,
});
// Use the hash for your explorer link and the fill for the success screen.
return result;
}
The result contains the transaction hash and actual event amounts. A quoted output is not the executed output. If receipt verification fails after submission, inspect that hash; do not buy again to recover the result.
Selling a token
Flow: resolve the project token → read its decimals and balance → approve the curve to spend it → quote the sale → simulate → submit → check TokensSold.
The approval asset is now the project token, not the quote asset. sellWithFeePolicy103 returns the ERC-20 quote asset. For the supported wrapped-native pair, sellNativeWithFeePolicy103 returns native ETH. Neither sell call attaches ETH as trade input; keep ETH for gas.
export async function sellToken(ctx: Context, input: {
curve: Address; recipient: Address; amount: string; native: boolean;
wrappedNative: Address; slippageBps: bigint; attemptId: string;
}) {
const { client } = ctx;
const read = { address: input.curve, abi: curveAbi } as const;
const [token, quoteAsset] = await Promise.all([
client.readContract({ ...read, functionName: 'token' }),
client.readContract({ ...read, functionName: 'quoteAsset' }),
]);
if (input.native && quoteAsset.toLowerCase() !== input.wrappedNative.toLowerCase()) {
throw new Error('This pair cannot return native ETH');
}
const [decimals, balance] = await Promise.all([
client.readContract({ address: token, abi: erc20Abi, functionName: 'decimals' }),
client.readContract({ address: token, abi: erc20Abi,
functionName: 'balanceOf', args: [ctx.account] }),
]);
const amount = parseUnits(input.amount, decimals);
if (amount <= 0n || amount > balance) throw new Error('Invalid token amount');
await approveExact(ctx, token, input.curve, amount, input.attemptId);
const block = await client.getBlock();
const [graduated, active, quote, witness] = await Promise.all([
client.readContract({ ...read, functionName: 'graduated', blockNumber: block.number }),
client.readContract({ ...read, functionName: 'launchActive104', blockNumber: block.number }),
client.readContract({ ...read, functionName: 'quoteSell', args: [amount], blockNumber: block.number }),
client.readContract({ ...read, functionName: 'currentFeeWitness103', blockNumber: block.number }),
]);
if (graduated || !active) throw new Error('Curve trading is unavailable');
const minimum = minimumAfterSlippage(quote.netQuoteOut, input.slippageBps);
const [policyHash, , maximumOrdinaryBps] = witness;
const data = encodeFunctionData({ abi: curveAbi,
functionName: input.native ? 'sellNativeWithFeePolicy103' : 'sellWithFeePolicy103',
args: [amount, minimum, block.timestamp + 300n, input.recipient, policyHash, maximumOrdinaryBps],
});
const receipt = await sendOnce(ctx, `${input.attemptId}:sell`, {
to: input.curve, data, value: 0n,
});
const fills = parseEventLogs({ abi: curveAbi, eventName: 'TokensSold',
logs: receipt.logs.filter(log => log.address.toLowerCase() === input.curve.toLowerCase()),
});
if (fills.length !== 1 || fills[0].args.recipient.toLowerCase() !== input.recipient.toLowerCase()) {
throw new Error(`Inspect sale receipt ${receipt.transactionHash}`);
}
return { hash: receipt.transactionHash, quoted: quote, executed: fills[0].args };
}
For example, amount: '1000' sells 1,000 project tokens after conversion using that token's decimals. A sell minimum is the minimum net quote asset received and uses absolute-output semantics; do not apply the buy normalization to it.
Getting a quote
Read the quote and currentFeeWitness103() at the same block as shown above. The witness supplies the hash and fee ceiling required by the protected trade call. A witness mismatch or failed simulation requires a fresh quote and another review.
| Quote field | Meaning |
|---|---|
grossQuoteUsed | Buy input that the quoted fill would consume. |
grossQuoteRefund | Unused buy input, including a purchase larger than the remaining curve allocation. |
principalIn | Buy input credited to the curve after fees. |
feeAmount | Fee in raw quote-asset units. |
tokensOut | Project tokens returned by a buy. |
grossQuoteOut | Sell proceeds before fees. |
netQuoteOut | Sell proceeds after fees; use this to calculate the sell minimum. |
Partial buys near graduation
Generation105 can fill only the remaining curve allocation and refund unused input. For a raw ABI buy, execution enforces:
tokensOut >= ceil(minimum * actualGrossQuoteUsed / offeredGross)
When the quote is already partial, encode floor(effectiveMinimum * offeredGross / quotedGrossQuoteUsed), as in buyToken. Example: offer 10 units, quote uses 4 and returns 100 tokens. At 1% slippage the effective minimum is 99; the raw bound is floor(99 × 10 / 4) = 247. An execution using 4 units must return at least ceil(247 × 4 / 10) = 99 tokens.
The repository helper prepareCurveTrade105 already performs this normalization. Do not apply it again to that helper's minimumOutput. This page's direct ABI example does not use the helper.
Trading after graduation
Once graduated() is true, stop submitting curve trades. Resolve the coordinator's marketReady(token) and the project's admitted pool/router. Quote the pool route, obtain its current spender and prepare the pool transaction through that router. A curve approval is not a pool-router approval.
readyToGraduate() only indicates curve readiness. The existing Otomate operator completes the pool transition; an ordinary buyer should not send a graduation transaction before every trade. During the transition, wait until the market is ready.
Resolve the market from the factory and read the pool key from its graduator. These addresses are discovered on-chain, so no hardcoded pool address is required.
const graduatorAbi = parseAbi([
'function poolKey(address curve) view returns ((address currency0, address currency1, uint24 fee, int24 tickSpacing, address hooks))',
]);
export async function readGraduatedPool(ctx: Context, factory: Address, projectId: Hex) {
const blockNumber = await ctx.client.getBlockNumber();
const infrastructure = await ctx.client.readContract({ address: factory,
abi: factoryAbi, functionName: 'infrastructure', blockNumber });
const project = await ctx.client.readContract({ address: factory,
abi: factoryAbi, functionName: 'project', args: [projectId], blockNumber });
const market = infrastructure.market;
const ready = await ctx.client.readContract({ address: market,
abi: marketAbi, functionName: 'marketReady', args: [project.token], blockNumber });
if (!ready) throw new Error('Pool is not ready');
const graduator = await ctx.client.readContract({ address: market,
abi: marketAbi, functionName: 'graduator', blockNumber });
const key = await ctx.client.readContract({ address: graduator,
abi: graduatorAbi, functionName: 'poolKey', args: [project.curve], blockNumber });
const currencies = [key.currency0.toLowerCase(), key.currency1.toLowerCase()];
if (!currencies.includes(project.token.toLowerCase())
|| !currencies.includes(project.quote.toLowerCase())) throw new Error('Pool asset mismatch');
const poolId = keccak256(encodeAbiParameters([
{ type: 'address' }, { type: 'address' }, { type: 'uint24' },
{ type: 'int24' }, { type: 'address' },
], [key.currency0, key.currency1, key.fee, key.tickSpacing, key.hooks]));
return { token: project.token, quote: project.quote, curve: project.curve, market, key, poolId };
}
Pass this exact pool key to a compatible Uniswap v4 routing integration. Preserve its hook and tick spacing; do not reconstruct them from assumptions. The quote and execution must target the same pool and use the router's required allowance spender, minimum output and deadline. Simulate the exact route before sending: pool readiness alone does not prove a router is admitted by the hook.
This section identifies the graduated pool. A verified production router address and its executable swap example are still pending publication; do not use a local test router or reuse curve calls for pool trades.
Discover tokens on-chain
Read ProjectCreated events from the verified factory, starting at its deployment block. Each event supplies the project ID and token address. Resolve the curve and quote asset with factory.project(projectId) before offering a trade.
export async function discoverTokens(ctx: Context, factory: Address,
fromBlock: bigint, toBlock: bigint) {
if (toBlock < fromBlock || toBlock - fromBlock > 999n) {
throw new Error('Read at most 1,000 blocks per request');
}
const events = await ctx.client.getContractEvents({
address: factory, abi: factoryAbi, eventName: 'ProjectCreated',
fromBlock, toBlock, strict: true,
});
const projects = [];
for (const event of events) {
const project = await ctx.client.readContract({
address: factory, abi: factoryAbi, functionName: 'project',
args: [event.args.entitlementId], blockNumber: toBlock,
});
projects.push({ projectId: event.args.entitlementId,
token: project.token, curve: project.curve, quoteAsset: project.quote,
transactionHash: event.transactionHash, blockHash: event.blockHash,
logIndex: event.logIndex });
}
return projects;
}
Scan bounded ranges ending at your chosen confirmed block. Persist the next block to scan and each event's block hash, transaction hash and log index. Reduce the range when your RPC provider imposes a smaller log limit. Reconcile reorgs before counting events permanently. The example resolves projects sequentially to bound RPC concurrency.
For a Telegram bot, store this cursor and the project records in the bot's database. Subsequent commands can resolve a selected token's curve and use the buy/sell contract calls above.
Recovery and common errors
| Situation | Next step |
|---|---|
| Approval confirmed, buy or sell not sent | Fetch a new quote, then submit the trade; approval is not a swap. |
LaunchInactive104 | Wait for the project to become active. |
InvalidFeeWitness | Read a fresh quote and witness at one block, then review again. |
| Expired deadline or output below minimum | Requote with the current market; keep the user's chosen slippage. |
| Unsupported native asset | Use the ERC-20 path for the actual quote asset. |
| Receipt timeout | Inspect the saved hash and wallet nonce before doing anything else. |
| Curve graduated | Resolve a supported pool route. |
Decode errors with the target deployment's ABI. Do not retry a transaction simply because the RPC request timed out. Preserve (chainId, account, target, calldata, value, nonce, hash) where available and reconcile replacements or reorgs.
Collections, rewards, and indexing
Display collection progress
Read collection availability separately from market graduation. A confirmed token launch or graduated curve does not establish that the collection is ready to reveal.
Use the collection's mint, opening and reveal state and its ERC-721 transfers to update your interface. Holders can mint sealed Companions after graduation when the contract permits it. Reveal requires inventory activation; show a pending state until the opening and artwork assignment are confirmed.
Minting burns the required project tokens. Opening does not repeat that burn. Read the selected collection's current requirements and simulate the exact call before requesting a signature.
Integrate reward claims
Rewards106 accounts for each incoming supported asset separately. Do not convert or aggregate raw balances across assets. The contract's WETH identity anchor is not an instruction to convert every reward into WETH.
Claims use claimAsset106(asset, tokenIds, holder). Token IDs must be sorted, unique, owned by the holder, and limited to 100 per transaction. Revalidate ownership and claim state before submission. Claim each asset independently.
Resolve the reward contract from the factory project, then read each asset's claimable amount and submit one asset at a time:
import { LauncherWeightedRewards106Abi105 as rewardsAbi } from './abis/LauncherWeightedRewards106';
const nftAbi = parseAbi(['function ownerOf(uint256 tokenId) view returns (address)']);
export async function claimRewards(ctx: Context, factory: Address, projectId: Hex,
asset: Address, tokenIds: bigint[], attemptId: string) {
const ids = [...new Set(tokenIds)].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
if (!ids.length || ids.length > 100 || ids.some(id => id < 0n)) throw new Error('Invalid NFT IDs');
const blockNumber = await ctx.client.getBlockNumber();
const project = await ctx.client.readContract({ address: factory, abi: factoryAbi,
functionName: 'project', args: [projectId], blockNumber });
if (project.rewards === '0x0000000000000000000000000000000000000000') throw new Error('No rewards contract');
const read = { address: project.rewards, abi: rewardsAbi, blockNumber } as const;
const companion = await ctx.client.readContract({ ...read, functionName: 'companion' });
if (companion.toLowerCase() !== project.companion.toLowerCase()) throw new Error('Collection mismatch');
const assets = await ctx.client.readContract({ ...read, functionName: 'assets106' });
if (!assets.some(a => a.toLowerCase() === asset.toLowerCase())) throw new Error('Unsupported reward asset');
let pending = 0n;
for (const id of ids) {
const owner = await ctx.client.readContract({ address: companion, abi: nftAbi,
functionName: 'ownerOf', args: [id], blockNumber });
if (owner.toLowerCase() !== ctx.account.toLowerCase()) throw new Error('NFT ownership changed');
pending += await ctx.client.readContract({ ...read, functionName: 'pendingAsset106', args: [asset, id] });
}
if (pending === 0n) throw new Error('No claimable rewards');
const receipt = await sendOnce(ctx, attemptId, { to: project.rewards, value: 0n,
data: encodeFunctionData({ abi: rewardsAbi, functionName: 'claimAsset106',
args: [asset, ids, ctx.account] }) });
const claims = parseEventLogs({ abi: rewardsAbi, eventName: 'AssetClaimed106',
logs: receipt.logs.filter(log => log.address.toLowerCase() === project.rewards.toLowerCase()), strict: true });
return { hash: receipt.transactionHash, claims };
}
Obtain NFT IDs from ERC-721 Transfer events for the project's collection and recheck ownerOf before a claim, as above. Split larger inventories into batches of at most 100 IDs with distinct persisted action IDs. This example targets Rewards106; use the deployment's matching ABI for older collections.
Unclaimed rewards follow the NFT. A UI must not attribute a transferable NFT's pending rewards permanently to a previous wallet. Pagination totals describe their page, not necessarily all NFTs owned by the user.
Historical047 WETH rewards retain their original claimTokens path. Reading a new reward contract does not replace historical claims.
Update your app from events
Use the ABI matching each project generation. Useful event families include:
| Event | What to record |
|---|---|
ProjectCreated | Entitlement ID, owner, token, commitment, configuration hash, and recipe. |
TokensBought | Actual gross quote used, fee, token output, and reserve update. |
TokensSold | Token input, gross quote, fee, net output, and reserve update. |
TradeFeePolicy103 | The execution's fee-policy witness. |
MarketGraduated | Token, pool ID, LP position ID, and liquidity. |
| Collection ERC-721 transfers | Mint, current ownership, transfers, and burns for inventory. |
| Reward claim events | Asset, holder, NFT IDs, and settled amount according to the matching ABI. |
Persist observations under (chainId, blockHash, transactionHash, logIndex). Track canonicality and finality separately, remove orphaned observations after a reorganization, and avoid applying the same canonical event twice.
Index bounded block ranges and resume from a durable cursor. Limit RPC concurrency, honor rate limits, and distinguish delayed indexing from an empty balance. Event indexing is a discovery mechanism; verify the current contract state before preparing a transaction.
Index actual trade fills
Read curve events for each discovered project. Index actual fills instead of treating the requested amount as the executed amount, especially near graduation.
export async function readTradeFills(ctx: Context, curve: Address,
fromBlock: bigint, toBlock: bigint) {
if (fromBlock < 0n || toBlock < fromBlock || toBlock - fromBlock >= 1_000n) {
throw new Error('Use a range of at most 1000 blocks');
}
const [buys, sells] = await Promise.all([
ctx.client.getContractEvents({ address: curve, abi: curveAbi,
eventName: 'TokensBought', fromBlock, toBlock, strict: true }),
ctx.client.getContractEvents({ address: curve, abi: curveAbi,
eventName: 'TokensSold', fromBlock, toBlock, strict: true }),
]);
return [...buys, ...sells].sort((a, b) =>
a.blockNumber < b.blockNumber ? -1 : a.blockNumber > b.blockNumber ? 1 : a.logIndex - b.logIndex);
}
Process successive confirmed ranges with a durable cursor. A Telegram buy alert can use TokensBought.args.recipient, tokensOut, grossQuoteUsed, and feeAmount; a sell alert uses TokensSold.args.seller, tokensIn, and netQuoteOut. Format each amount using its own token decimals. These curve events cover pre-graduation trades only; pool trades require the pool manager's swap events filtered by the resolved pool ID.
Test your integration
Before a release, cover wrong-network and changed-wallet requests, stale quotes, insufficient raw balance, approval versus execution, partial final buys, graduation transition, pending and replaced transactions, paginated NFT ownership, and per-asset reward claims.
Local fixtures and source-matched ABIs establish only the behavior they exercised. Publish an audit or deployment claim only with its matching report, scope, and deployed contract identity.