UNPKG

jito-distributor-sdk

Version:

TypeScript SDK for JITO Merkle Distributor with production-ready versioning and double-hashing support

342 lines (341 loc) 14.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.DeterministicVersioning = exports.RaceConditionError = exports.DeploymentLimitExceededError = exports.VersioningError = exports.VERSIONING_CONSTANTS = void 0; exports.getDeterministicVersion = getDeterministicVersion; exports.getDistributionVersion = getDistributionVersion; exports.getDailyVersion = getDailyVersion; exports.getSequentialVersion = getSequentialVersion; exports.generateNextVersionSafe = generateNextVersionSafe; exports.isAccountCollisionError = isAccountCollisionError; const crypto_1 = require("crypto"); const bignumber_js_1 = __importDefault(require("bignumber.js")); /** * Constants for the versioning system */ exports.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, }; /** * 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 }; /** * Custom error types for versioning system */ class VersioningError extends Error { code; constructor(message, code) { super(message); this.code = code; this.name = 'VersioningError'; } } exports.VersioningError = VersioningError; class DeploymentLimitExceededError extends VersioningError { constructor(currentCount, maxAllowed) { 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'); } } exports.DeploymentLimitExceededError = DeploymentLimitExceededError; class RaceConditionError extends VersioningError { constructor(version) { super(`Version ${version} is already in use. This can happen when deploying from multiple tabs simultaneously.`, 'RACE_CONDITION'); } } exports.RaceConditionError = RaceConditionError; /** * In-flight deployment tracker for race condition prevention */ class DeploymentTracker { static inFlight = new Set(); static lock(walletKey) { if (this.inFlight.has(walletKey)) { return false; // Already locked } this.inFlight.add(walletKey); return true; // Successfully locked } static unlock(walletKey) { this.inFlight.delete(walletKey); } static isLocked(walletKey) { return this.inFlight.has(walletKey); } } /** * Main versioning system class for JITO Merkle Distributor */ class DeterministicVersioning { connection; programId; mintAddress; constructor(config) { 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 */ generateUserFingerprint(walletPubkey) { // Hash the full 32-byte Ed25519 public key const hash = (0, crypto_1.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_js_1.default(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) { try { const filters = [ // Filter by account size (MerkleDistributor accounts) { dataSize: exports.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] */ generateVersionNumber(userFingerprint, sequence) { // Validate sequence is within 16-bit range (0 to 65535 inclusive) if (sequence < 0 || sequence >= exports.VERSIONING_CONSTANTS.MAX_DEPLOYMENTS_PER_USER) { throw new VersioningError(`Sequence number ${sequence} is out of valid range [0, ${exports.VERSIONING_CONSTANTS.MAX_DEPLOYMENTS_PER_USER - 1}]`, 'INVALID_SEQUENCE'); } // Validate user fingerprint fits in 48 bits const maxFingerprint = new bignumber_js_1.default(2).pow(exports.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_js_1.default(2).pow(exports.VERSIONING_CONSTANTS.SEQUENCE_BITS)); const sequenceBN = new bignumber_js_1.default(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) { // 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 >= exports.VERSIONING_CONSTANTS.MAX_DEPLOYMENTS_PER_USER) { throw new DeploymentLimitExceededError(deploymentCount, exports.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) { 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 < exports.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 ${exports.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, version) { 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) { // Extract sequence (lower 16 bits) using modulo const sequenceMask = new bignumber_js_1.default(2).pow(exports.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) { // 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) { const deploymentCount = await this.getUserDeploymentCount(walletPubkey); const userFingerprint = this.generateUserFingerprint(walletPubkey); const versions = []; 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, deploymentsPerUser = 260) { const totalDeployments = userCount * deploymentsPerUser; const spaceSize = Math.pow(2, exports.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 }; } } exports.DeterministicVersioning = DeterministicVersioning; /** * 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 */ function getDeterministicVersion(deployerPubkey, salt = 'default') { // 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 = (0, crypto_1.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 */ function getDistributionVersion(deployerPubkey, distributionName) { return getDeterministicVersion(deployerPubkey, distributionName); } /** * Generate a time-based deterministic version (changes daily) */ function getDailyVersion(deployerPubkey, date = new Date()) { const dateString = date.toISOString().split('T')[0]; // YYYY-MM-DD return getDeterministicVersion(deployerPubkey, `daily-${dateString}`); } /** * Generate a sequential version for the same deployer */ function getSequentialVersion(deployerPubkey, sequence) { return getDeterministicVersion(deployerPubkey, `seq-${sequence}`); } /** * Convenience function for quick version generation with race protection * Recommended for React UI usage with JITO Merkle Distributor */ async function generateNextVersionSafe(connection, programId, mintAddress, walletPubkey) { const versioning = new DeterministicVersioning({ connection, programId, mintAddress }); return versioning.getNextVersionWithRetry(walletPubkey); } /** * Utility to detect if error is from account collision */ function isAccountCollisionError(error) { 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) { return new Promise(resolve => setTimeout(resolve, ms)); }