jito-distributor-sdk
Version:
TypeScript SDK for JITO Merkle Distributor with production-ready versioning and double-hashing support
447 lines (387 loc) • 15.1 kB
text/typescript
import { Connection, PublicKey, GetProgramAccountsFilter } from '@solana/web3.js';
import { createHash } from 'crypto';
import BigNumber from 'bignumber.js';
/**
* Constants for the versioning system
*/
export const VERSIONING_CONSTANTS = {
MAX_DEPLOYMENTS_PER_USER: 65536, // 2^16 (0 to 65535 inclusive)
USER_FINGERPRINT_BITS: 48,
SEQUENCE_BITS: 16,
MERKLE_DISTRIBUTOR_DISCRIMINATOR: "MerkleDistributor",
MERKLE_DISTRIBUTOR_ACCOUNT_SIZE: 177, // Size of MerkleDistributor account
MAX_RETRY_ATTEMPTS: 3,
RETRY_DELAY_MS: 500,
} as const;
/**
* Offsets for filtering MerkleDistributor accounts
* TODO: Replace with proper Borsh parsing to avoid brittleness
*/
const ACCOUNT_OFFSETS = {
MINT: 41, // Offset where mint pubkey is stored
ADMIN: 145, // Offset where admin pubkey is stored
} as const;
/**
* Custom error types for versioning system
*/
export class VersioningError extends Error {
constructor(message: string, public code: string) {
super(message);
this.name = 'VersioningError';
}
}
export class DeploymentLimitExceededError extends VersioningError {
constructor(currentCount: number, maxAllowed: number) {
super(
`This wallet has exceeded its allocated deployment limit (${maxAllowed}). ` +
`Current deployments: ${currentCount}. Please use a different wallet to continue deploying distributions.`,
'DEPLOYMENT_LIMIT_EXCEEDED'
);
}
}
export class RaceConditionError extends VersioningError {
constructor(version: string) {
super(
`Version ${version} is already in use. This can happen when deploying from multiple tabs simultaneously.`,
'RACE_CONDITION'
);
}
}
/**
* Result type for version generation
*/
export interface VersionGenerationResult {
version: BigNumber;
sequence: number;
userFingerprint: BigNumber;
deploymentCount: number;
}
/**
* Configuration for the versioning system
*/
export interface VersioningConfig {
connection: Connection;
programId: PublicKey;
mintAddress: PublicKey;
}
/**
* Deployment result with retry information
*/
export interface DeploymentAttemptResult extends VersionGenerationResult {
attempt: number;
retried: boolean;
}
/**
* In-flight deployment tracker for race condition prevention
*/
class DeploymentTracker {
private static inFlight = new Set<string>();
static lock(walletKey: string): boolean {
if (this.inFlight.has(walletKey)) {
return false; // Already locked
}
this.inFlight.add(walletKey);
return true; // Successfully locked
}
static unlock(walletKey: string): void {
this.inFlight.delete(walletKey);
}
static isLocked(walletKey: string): boolean {
return this.inFlight.has(walletKey);
}
}
/**
* Main versioning system class for JITO Merkle Distributor
*/
export class DeterministicVersioning {
private connection: Connection;
private programId: PublicKey;
private mintAddress: PublicKey;
constructor(config: VersioningConfig) {
this.connection = config.connection;
this.programId = config.programId;
this.mintAddress = config.mintAddress;
}
/**
* Generates a deterministic user fingerprint from wallet public key
* Takes the first 48 bits of SHA-256 hash of the wallet pubkey
*/
private generateUserFingerprint(walletPubkey: PublicKey): BigNumber {
// Hash the full 32-byte Ed25519 public key
const hash = createHash('sha256').update(walletPubkey.toBuffer()).digest();
// Take the first 6 bytes (48 bits) as the user fingerprint
const fingerprintBytes = hash.subarray(0, 6);
// Convert to BigNumber (big-endian)
const fingerprintHex = fingerprintBytes.toString('hex');
return new BigNumber(fingerprintHex, 16);
}
/**
* Queries the blockchain to count existing distributor accounts for a user
* This gives us the reliable sequence number for next deployment
*/
async getUserDeploymentCount(walletPubkey: PublicKey): Promise<number> {
try {
const filters: GetProgramAccountsFilter[] = [
// Filter by account size (MerkleDistributor accounts)
{
dataSize: VERSIONING_CONSTANTS.MERKLE_DISTRIBUTOR_ACCOUNT_SIZE
},
// Filter by mint address
{
memcmp: {
offset: ACCOUNT_OFFSETS.MINT,
bytes: this.mintAddress.toBase58()
}
},
// Filter by admin (wallet address)
{
memcmp: {
offset: ACCOUNT_OFFSETS.ADMIN,
bytes: walletPubkey.toBase58()
}
}
];
const accounts = await this.connection.getProgramAccounts(this.programId, {
filters,
commitment: 'confirmed'
});
return accounts.length;
} catch (error) {
throw new VersioningError(
`Failed to query deployment count for wallet ${walletPubkey.toBase58()}: ${error}`,
'QUERY_FAILED'
);
}
}
/**
* Generates a deterministic, collision-free version number
* Format: [48-bit user fingerprint][16-bit sequence number]
*/
private generateVersionNumber(userFingerprint: BigNumber, sequence: number): BigNumber {
// Validate sequence is within 16-bit range (0 to 65535 inclusive)
if (sequence < 0 || sequence >= VERSIONING_CONSTANTS.MAX_DEPLOYMENTS_PER_USER) {
throw new VersioningError(
`Sequence number ${sequence} is out of valid range [0, ${VERSIONING_CONSTANTS.MAX_DEPLOYMENTS_PER_USER - 1}]`,
'INVALID_SEQUENCE'
);
}
// Validate user fingerprint fits in 48 bits
const maxFingerprint = new BigNumber(2).pow(VERSIONING_CONSTANTS.USER_FINGERPRINT_BITS).minus(1);
if (userFingerprint.gt(maxFingerprint)) {
throw new VersioningError(
'User fingerprint exceeds 48-bit limit',
'INVALID_FINGERPRINT'
);
}
// Combine: (fingerprint << 16) | sequence
const shiftedFingerprint = userFingerprint.multipliedBy(new BigNumber(2).pow(VERSIONING_CONSTANTS.SEQUENCE_BITS));
const sequenceBN = new BigNumber(sequence);
return shiftedFingerprint.plus(sequenceBN);
}
/**
* Main function: Get the next version number for a user's deployment
* Combines on-chain sequence tracking with deterministic generation
*/
async getNextVersion(walletPubkey: PublicKey): Promise<VersionGenerationResult> {
// Step 1: Query current deployment count from blockchain
const deploymentCount = await this.getUserDeploymentCount(walletPubkey);
// Step 2: Check if user has exceeded deployment limit
if (deploymentCount >= VERSIONING_CONSTANTS.MAX_DEPLOYMENTS_PER_USER) {
throw new DeploymentLimitExceededError(deploymentCount, VERSIONING_CONSTANTS.MAX_DEPLOYMENTS_PER_USER);
}
// Step 3: Generate user fingerprint from wallet pubkey
const userFingerprint = this.generateUserFingerprint(walletPubkey);
// Step 4: Generate deterministic version number
const version = this.generateVersionNumber(userFingerprint, deploymentCount);
return {
version,
sequence: deploymentCount,
userFingerprint,
deploymentCount
};
}
/**
* Race-condition safe deployment version generation with auto-retry
* This is the recommended method for UI usage
*/
async getNextVersionWithRetry(walletPubkey: PublicKey): Promise<DeploymentAttemptResult> {
const walletKey = walletPubkey.toBase58();
// Tier 1: Soft lock to prevent same-device races
if (!DeploymentTracker.lock(walletKey)) {
throw new VersioningError(
'A deployment is already in progress for this wallet. Please wait for it to complete.',
'DEPLOYMENT_IN_PROGRESS'
);
}
try {
// Tier 2: Auto-retry with fresh sequence queries
for (let attempt = 0; attempt < VERSIONING_CONSTANTS.MAX_RETRY_ATTEMPTS; attempt++) {
const result = await this.getNextVersion(walletPubkey);
// Return result with retry metadata
return {
...result,
attempt: attempt + 1,
retried: attempt > 0
};
}
throw new VersioningError(
`Failed to generate unique version after ${VERSIONING_CONSTANTS.MAX_RETRY_ATTEMPTS} attempts`,
'MAX_RETRIES_EXCEEDED'
);
} finally {
DeploymentTracker.unlock(walletKey);
}
}
/**
* Utility: Verify a version number was generated correctly
* Useful for debugging and validation
*/
async verifyVersion(walletPubkey: PublicKey, version: BigNumber): Promise<boolean> {
try {
const result = await this.getNextVersion(walletPubkey);
return result.version.eq(version);
} catch {
return false;
}
}
/**
* Utility: Decode a version number back to its components
* Useful for debugging and analytics
*/
static decodeVersion(version: BigNumber): { userFingerprint: BigNumber; sequence: number } {
// Extract sequence (lower 16 bits) using modulo
const sequenceMask = new BigNumber(2).pow(VERSIONING_CONSTANTS.SEQUENCE_BITS);
const sequence = version.modulo(sequenceMask).toNumber();
// Extract user fingerprint (upper 48 bits) using integer division
const userFingerprint = version.dividedToIntegerBy(sequenceMask);
return { userFingerprint, sequence };
}
/**
* Utility: Convert version to little-endian Buffer for Solana
* Ensures proper endianness for PDA derivation
*/
static versionToLEBuffer(version: BigNumber): Buffer {
// Convert to BigInt to avoid precision loss
const versionBigInt = BigInt(version.toString());
// Create 8-byte buffer and write as little-endian
const buffer = Buffer.alloc(8);
buffer.writeBigUInt64LE(versionBigInt, 0);
return buffer;
}
/**
* Utility: Get deployment history for a user
* Returns all versions this user has deployed
*/
async getUserDeploymentHistory(walletPubkey: PublicKey): Promise<BigNumber[]> {
const deploymentCount = await this.getUserDeploymentCount(walletPubkey);
const userFingerprint = this.generateUserFingerprint(walletPubkey);
const versions: BigNumber[] = [];
for (let seq = 0; seq < deploymentCount; seq++) {
const version = this.generateVersionNumber(userFingerprint, seq);
versions.push(version);
}
return versions;
}
/**
* Utility: Estimate collision probability for given user count
* Returns probability as percentage for easier monitoring
*/
static estimateCollisionProbability(userCount: number, deploymentsPerUser: number = 260): {
probability: number;
probabilityPercent: string;
isAcceptable: boolean
} {
const totalDeployments = userCount * deploymentsPerUser;
const spaceSize = Math.pow(2, VERSIONING_CONSTANTS.USER_FINGERPRINT_BITS);
// Birthday bound approximation: N(N-1)/(2 * space_size)
const probability = (totalDeployments * (totalDeployments - 1)) / (2 * spaceSize);
const probabilityPercent = (probability * 100).toExponential(2);
const isAcceptable = probability < 1e-6; // Less than 1 in a million
return { probability, probabilityPercent, isAcceptable };
}
}
/**
* Simple deterministic versioning utilities (from legacy deterministic-version.ts)
* Kept for backwards compatibility and simple use cases
*/
/**
* Generate a simple deterministic version number unique to the deployer's wallet
* This ensures no collisions with other deployers while being reproducible
*/
export function getDeterministicVersion(
deployerPubkey: PublicKey,
salt: string = 'default'
): bigint {
// Combine deployer pubkey + salt for deterministic but unique versioning
const input = deployerPubkey.toBuffer().toString('hex') + salt;
// Hash the input to get deterministic output
const hash = createHash('sha256').update(input).digest();
// Take first 8 bytes and convert to u64 (but avoid 0)
const versionBytes = hash.slice(0, 8);
let version = versionBytes.readBigUInt64BE(0);
// Ensure version is never 0 (add 1 if it is)
if (version === 0n) {
version = 1n;
}
return version;
}
/**
* Generate a deterministic version with custom salt for multiple distributions
*/
export function getDistributionVersion(
deployerPubkey: PublicKey,
distributionName: string
): bigint {
return getDeterministicVersion(deployerPubkey, distributionName);
}
/**
* Generate a time-based deterministic version (changes daily)
*/
export function getDailyVersion(
deployerPubkey: PublicKey,
date: Date = new Date()
): bigint {
const dateString = date.toISOString().split('T')[0]; // YYYY-MM-DD
return getDeterministicVersion(deployerPubkey, `daily-${dateString}`);
}
/**
* Generate a sequential version for the same deployer
*/
export function getSequentialVersion(
deployerPubkey: PublicKey,
sequence: number
): bigint {
return getDeterministicVersion(deployerPubkey, `seq-${sequence}`);
}
/**
* Convenience function for quick version generation with race protection
* Recommended for React UI usage with JITO Merkle Distributor
*/
export async function generateNextVersionSafe(
connection: Connection,
programId: PublicKey,
mintAddress: PublicKey,
walletPubkey: PublicKey
): Promise<DeploymentAttemptResult> {
const versioning = new DeterministicVersioning({
connection,
programId,
mintAddress
});
return versioning.getNextVersionWithRetry(walletPubkey);
}
/**
* Utility to detect if error is from account collision
*/
export function isAccountCollisionError(error: any): boolean {
const errorMessage = error?.message?.toLowerCase() || '';
return errorMessage.includes('account already in use') ||
errorMessage.includes('already in use') ||
errorMessage.includes('custom program error: 0x0'); // Common Solana collision error
}
/**
* Sleep utility for retry delays
*/
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}