UNPKG

jito-distributor-sdk

Version:

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

257 lines (213 loc) 7.91 kB
import { createHash } from 'crypto'; import { PublicKey } from '@solana/web3.js'; /** * JITO Merkle Tree implementation that exactly matches the Solana program * Fixes InvalidProof errors by implementing proper double-hashing */ // Prefixes to prevent second pre-image attacks const LEAF_PREFIX = Buffer.from([0]); const INTERMEDIATE_PREFIX = Buffer.from([1]); export interface AirdropRecipient { address: PublicKey; unlockedAmount: number; lockedAmount: number; } /** * Production-ready Merkle Tree for JITO Merkle Distributor * Uses double-hashing to match Solana program expectations exactly */ export class JitoMerkleTree { private leaves: Buffer[] = []; private tree: Buffer[][] = []; private root: Buffer | null = null; constructor(recipients: AirdropRecipient[]) { // Create leaves from recipient data this.leaves = recipients.map(recipient => this.createLeaf(recipient)); this.buildTree(); } private createLeaf(recipient: AirdropRecipient): Buffer { // Create leaf data exactly like TreeNode.hash(): address (32 bytes) + unlocked (8 bytes LE) + locked (8 bytes LE) const addressBytes = recipient.address.toBuffer(); const unlockedBytes = Buffer.alloc(8); unlockedBytes.writeBigUInt64LE(BigInt(recipient.unlockedAmount)); const lockedBytes = Buffer.alloc(8); lockedBytes.writeBigUInt64LE(BigInt(recipient.lockedAmount)); const leafData = Buffer.concat([addressBytes, unlockedBytes, lockedBytes]); // FIXED: Match Rust program's DOUBLE HASHING exactly // Step 1: Hash the raw data (like Rust's first hashv call) const firstHash = createHash('sha256').update(leafData).digest(); // Step 2: Hash the result with LEAF_PREFIX (like Rust's second hashv call) const finalHash = createHash('sha256').update(Buffer.concat([LEAF_PREFIX, firstHash])).digest(); return finalHash; } private hashLeaf(data: Buffer): Buffer { // For verification, we need to match the Rust program's approach // Step 1: Hash the raw data first const firstHash = createHash('sha256').update(data).digest(); // Step 2: Hash with LEAF_PREFIX const finalHash = createHash('sha256').update(Buffer.concat([LEAF_PREFIX, firstHash])).digest(); return finalHash; } private hashIntermediate(left: Buffer, right: Buffer): Buffer { // Equivalent to hash_intermediate! macro: hashv(&[INTERMEDIATE_PREFIX, left, right]) return createHash('sha256').update(Buffer.concat([INTERMEDIATE_PREFIX, left, right])).digest(); } private buildTree() { if (this.leaves.length === 0) { throw new Error('Cannot build tree with no leaves'); } let currentLevel = [...this.leaves]; this.tree = [currentLevel]; while (currentLevel.length > 1) { const nextLevel: Buffer[] = []; for (let i = 0; i < currentLevel.length; i += 2) { const left = currentLevel[i]; const right = i + 1 < currentLevel.length ? currentLevel[i + 1] : left; // Duplicate if odd // Use sorted hashing like Rust airdrop_merkle_tree.rs (sorted_hashes = true) if (left.compare(right) <= 0) { nextLevel.push(this.hashIntermediate(left, right)); } else { nextLevel.push(this.hashIntermediate(right, left)); } } currentLevel = nextLevel; this.tree.push(currentLevel); } this.root = currentLevel[0]; } getRoot(): Buffer { if (!this.root) { throw new Error('Tree not built yet'); } return this.root; } getProof(index: number): Buffer[] { if (index >= this.leaves.length) { throw new Error('Index out of bounds'); } const proof: Buffer[] = []; let currentIndex = index; for (let level = 0; level < this.tree.length - 1; level++) { const currentLevel = this.tree[level]; const isRightNode = currentIndex % 2 === 1; const siblingIndex = isRightNode ? currentIndex - 1 : currentIndex + 1; if (siblingIndex < currentLevel.length) { proof.push(currentLevel[siblingIndex]); } else { // Duplicate the current node if sibling doesn't exist proof.push(currentLevel[currentIndex]); } currentIndex = Math.floor(currentIndex / 2); } return proof; } verifyProof(index: number, leafData: Buffer, proof: Buffer[]): boolean { // Match Rust program's verification exactly with double hashing let computedHash = this.hashLeaf(leafData); for (const proofElement of proof) { // Use sorting like Rust verify.rs if (computedHash.compare(proofElement) <= 0) { computedHash = this.hashIntermediate(computedHash, proofElement); } else { computedHash = this.hashIntermediate(proofElement, computedHash); } } return computedHash.equals(this.getRoot()); } // Helper method to get raw leaf data for a specific recipient (before hashing) getRawLeafForRecipient(recipient: AirdropRecipient): Buffer { // Return the raw leaf data (before any hashing) const addressBytes = recipient.address.toBuffer(); const unlockedBytes = Buffer.alloc(8); unlockedBytes.writeBigUInt64LE(BigInt(recipient.unlockedAmount)); const lockedBytes = Buffer.alloc(8); lockedBytes.writeBigUInt64LE(BigInt(recipient.lockedAmount)); return Buffer.concat([addressBytes, unlockedBytes, lockedBytes]); } // Helper method to find index of a recipient findRecipientIndex(recipients: AirdropRecipient[], targetAddress: PublicKey): number { return recipients.findIndex(r => r.address.equals(targetAddress)); } } /** * Creates a JITO-compatible merkle tree with the specified recipients */ export function createJitoMerkleTree(recipients: AirdropRecipient[]): { tree: JitoMerkleTree; root: Uint8Array; recipients: AirdropRecipient[]; } { const tree = new JitoMerkleTree(recipients); const root = new Uint8Array(tree.getRoot()); return { tree, root, recipients }; } /** * Generates a merkle proof for a specific recipient */ export function generateProofForRecipient( tree: JitoMerkleTree, recipients: AirdropRecipient[], targetAddress: PublicKey ): { proof: Uint8Array[]; index: number; recipient: AirdropRecipient; } { const index = tree.findRecipientIndex(recipients, targetAddress); if (index === -1) { throw new Error(`Recipient ${targetAddress.toString()} not found in merkle tree`); } const recipient = recipients[index]; const proof = tree.getProof(index); return { proof: proof.map(p => new Uint8Array(p)), index, recipient }; } /** * Validates that a merkle proof is properly formatted * @param proof Array of hex strings or Uint8Arrays * @returns boolean */ export function validateMerkleProof(proof: (string | Uint8Array)[]): boolean { for (const element of proof) { if (typeof element === 'string') { try { const bytes = hexToUint8Array(element); if (bytes.length !== 32) return false; } catch { return false; } } else if (element instanceof Uint8Array) { if (element.length !== 32) return false; } else { return false; } } return true; } /** * Convert hex string to Uint8Array */ export function hexToUint8Array(hex: string): Uint8Array { // Remove 0x prefix if present const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex; // Ensure even length const paddedHex = cleanHex.length % 2 === 0 ? cleanHex : '0' + cleanHex; const bytes = new Uint8Array(paddedHex.length / 2); for (let i = 0; i < paddedHex.length; i += 2) { bytes[i / 2] = parseInt(paddedHex.substr(i, 2), 16); } return bytes; } /** * Convert Uint8Array to hex string */ export function uint8ArrayToHex(bytes: Uint8Array): string { return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); }