diff --git a/.github/workflows/js-v2.yml b/.github/workflows/js-v2.yml index 01db3b449e..c3f71f74dd 100644 --- a/.github/workflows/js-v2.yml +++ b/.github/workflows/js-v2.yml @@ -79,12 +79,12 @@ jobs: done echo "Tests passed on attempt $attempt" - - name: Run compressed-token legacy tests with V2 + - name: Run compressed-token unit tests with V2 run: | - echo "Running compressed-token legacy tests with retry logic (max 2 attempts)..." + echo "Running compressed-token unit tests with retry logic (max 2 attempts)..." attempt=1 max_attempts=2 - until npx nx test @lightprotocol/compressed-token; do + until npx nx run @lightprotocol/compressed-token:test:unit:all:v2; do attempt=$((attempt + 1)) if [ $attempt -gt $max_attempts ]; then echo "Tests failed after $max_attempts attempts" diff --git a/cli/package.json b/cli/package.json index 57a1e2518f..8c173538fa 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@lightprotocol/zk-compression-cli", - "version": "0.27.1-alpha.2", + "version": "0.27.1-alpha.7", "description": "ZK Compression: Secure Scaling on Solana", "maintainers": [ { diff --git a/cli/src/utils/constants.ts b/cli/src/utils/constants.ts index e2f527654d..e3a9137737 100644 --- a/cli/src/utils/constants.ts +++ b/cli/src/utils/constants.ts @@ -24,7 +24,7 @@ export const PHOTON_VERSION = "0.51.2"; // Set these to override Photon requirements with a specific git commit: export const USE_PHOTON_FROM_GIT = true; // If true, will show git install command instead of crates.io. export const PHOTON_GIT_REPO = "https://github.com/lightprotocol/photon.git"; -export const PHOTON_GIT_COMMIT = "711c47b20330c6bb78feb0a2c15e8292fcd0a7b0"; // If empty, will use main branch. +export const PHOTON_GIT_COMMIT = "ac7df6c388db847b7693a7a1cb766a7c9d7809b5"; // If empty, will use main branch. export const LIGHT_PROTOCOL_PROGRAMS_DIR_ENV = "LIGHT_PROTOCOL_PROGRAMS_DIR"; export const BASE_PATH = "../../bin/"; diff --git a/cli/src/utils/downloadProverBinary.ts b/cli/src/utils/downloadProverBinary.ts index 7f565b389b..bf747e5cc9 100644 --- a/cli/src/utils/downloadProverBinary.ts +++ b/cli/src/utils/downloadProverBinary.ts @@ -4,8 +4,9 @@ import https from "https"; import http from "http"; import { pipeline } from "stream/promises"; -const PROVER_VERSION = "2.0.6"; -const GITHUB_RELEASES_BASE_URL = `https://github.com/Lightprotocol/light-protocol/releases/download/light-prover-v${PROVER_VERSION}`; +const PROVER_RELEASE_TAG = "2.0.6"; +const PROVER_BINARY_VERSION = "2.0.0"; // Version string the binary actually reports +const GITHUB_RELEASES_BASE_URL = `https://github.com/Lightprotocol/light-protocol/releases/download/light-prover-v${PROVER_RELEASE_TAG}`; const MAX_REDIRECTS = 10; interface DownloadOptions { @@ -151,5 +152,5 @@ async function downloadFile( } export function getProverVersion(): string { - return PROVER_VERSION; + return PROVER_BINARY_VERSION; } diff --git a/cli/src/utils/initTestEnv.ts b/cli/src/utils/initTestEnv.ts index 6492414fa3..6438af9f41 100644 --- a/cli/src/utils/initTestEnv.ts +++ b/cli/src/utils/initTestEnv.ts @@ -11,6 +11,7 @@ import { import path from "path"; import { downloadBinIfNotExists } from "../psp-utils"; import { + confirmRpcReadiness, confirmServerStability, executeCommand, killProcess, @@ -172,16 +173,21 @@ export async function initTestEnv({ }); await waitForServers([{ port: rpcPort, path: "/health" }]); await confirmServerStability(`http://127.0.0.1:${rpcPort}/health`); + await confirmRpcReadiness(`http://127.0.0.1:${rpcPort}`); if (indexer) { const config = getConfig(); config.indexerUrl = `http://127.0.0.1:${indexerPort}`; setConfig(config); + const proverUrlForIndexer = prover + ? `http://127.0.0.1:${proverPort}` + : undefined; await startIndexer( `http://127.0.0.1:${rpcPort}`, indexerPort, checkPhotonVersion, photonDatabaseUrl, + proverUrlForIndexer, ); } diff --git a/cli/src/utils/process.ts b/cli/src/utils/process.ts index ffdc4003e5..e3d1a241f5 100644 --- a/cli/src/utils/process.ts +++ b/cli/src/utils/process.ts @@ -285,3 +285,73 @@ export async function confirmServerStability( throw error; } } + +/** + * Confirms that the Solana RPC is fully ready to process requests. + * This goes beyond HTTP availability and verifies the RPC can handle actual Solana requests. + * + * @param rpcUrl - The RPC endpoint URL + * @param maxAttempts - Maximum number of attempts (default: 30) + * @param delayMs - Delay between attempts in milliseconds (default: 500ms) + * @throws Error if RPC doesn't become ready within maxAttempts + */ +export async function confirmRpcReadiness( + rpcUrl: string, + maxAttempts: number = 30, + delayMs: number = 500, +) { + let lastError: Error | unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const response = await axios.post( + rpcUrl, + { + jsonrpc: "2.0", + id: 1, + method: "getHealth", + params: [], + }, + { + headers: { "Content-Type": "application/json" }, + timeout: 3000, + }, + ); + + if (response.data?.result === "ok") { + console.log( + `RPC is ready after ${attempt} attempt${attempt > 1 ? "s" : ""}.`, + ); + return; + } + + // Response received but not "ok" + lastError = new Error( + `RPC returned unexpected result: ${JSON.stringify(response.data)}`, + ); + } catch (error) { + lastError = error; + + // Log connection errors only on later attempts to reduce noise + if (attempt > 5 && attempt % 5 === 0) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.log( + `RPC not ready yet (attempt ${attempt}/${maxAttempts}): ${errorMsg}`, + ); + } + } + + // Don't sleep after the last attempt + if (attempt < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + + // If we get here, all attempts failed + const errorMsg = + lastError instanceof Error ? lastError.message : String(lastError); + const totalTime = Math.round((maxAttempts * delayMs) / 1000); + throw new Error( + `RPC failed to become ready after ${maxAttempts} attempts (~${totalTime}s). Last error: ${errorMsg}`, + ); +} diff --git a/cli/src/utils/processPhotonIndexer.ts b/cli/src/utils/processPhotonIndexer.ts index a2407f82f3..1c883a121d 100644 --- a/cli/src/utils/processPhotonIndexer.ts +++ b/cli/src/utils/processPhotonIndexer.ts @@ -41,6 +41,7 @@ export async function startIndexer( indexerPort: number, checkPhotonVersion: boolean = true, photonDatabaseUrl?: string, + proverUrl?: string, ) { await killIndexer(); const resolvedOrNull = which.sync("photon", { nothrow: true }); @@ -61,6 +62,9 @@ export async function startIndexer( if (photonDatabaseUrl) { args.push("--db-url", photonDatabaseUrl); } + if (proverUrl) { + args.push("--prover-url", proverUrl); + } spawnBinary(INDEXER_PROCESS_NAME, args); await waitForServers([{ port: indexerPort, path: "/getIndexerHealth" }]); diff --git a/cli/src/utils/processProverServer.ts b/cli/src/utils/processProverServer.ts index 3e91b30256..c163f07dd6 100644 --- a/cli/src/utils/processProverServer.ts +++ b/cli/src/utils/processProverServer.ts @@ -1,4 +1,5 @@ import path from "path"; +import os from "os"; import fs from "fs"; import { execSync } from "child_process"; import { @@ -7,13 +8,15 @@ import { spawnBinary, waitForServers, } from "./process"; -import { LIGHT_PROVER_PROCESS_NAME, BASE_PATH } from "./constants"; +import { LIGHT_PROVER_PROCESS_NAME } from "./constants"; import { downloadProverBinary, getProverVersion as getExpectedProverVersion, } from "./downloadProverBinary"; -const KEYS_DIR = "proving-keys/"; +const LIGHT_CONFIG_DIR = path.join(os.homedir(), ".config", "light"); +const PROVER_BIN_DIR = path.join(LIGHT_CONFIG_DIR, "bin"); +const KEYS_DIR = path.join(LIGHT_CONFIG_DIR, "proving-keys"); export async function killProver() { await killProcess(getProverNameByArch()); @@ -32,11 +35,13 @@ function getInstalledProverVersion(): string | null { } try { - const version = execSync(`"${binaryPath}" version`, { + const output = execSync(`"${binaryPath}" version`, { encoding: "utf-8", timeout: 5000, }).trim(); - return version; + // Extract version number (handles "v2.0.6", "light-prover v2.0.6", "2.0.6", etc.) + const match = output.match(/(\d+\.\d+\.\d+)/); + return match ? match[1] : null; } catch (error) { return null; } @@ -85,10 +90,9 @@ export async function startProver(proverPort: number, redisUrl?: string) { await killProver(); await killProcessByPort(proverPort); - const keysDir = path.join(path.resolve(__dirname, BASE_PATH), KEYS_DIR); const args = ["start"]; - args.push("--keys-dir", keysDir); + args.push("--keys-dir", KEYS_DIR + "/"); args.push("--prover-address", `0.0.0.0:${proverPort}`); args.push("--auto-download", "true"); @@ -128,11 +132,8 @@ export function getProverNameByArch(): string { } export function getProverPathByArch(): string { - let binaryName = getProverNameByArch(); - const binDir = path.resolve(__dirname, BASE_PATH); - binaryName = path.join(binDir, binaryName); - - return binaryName; + const binaryName = getProverNameByArch(); + return path.join(PROVER_BIN_DIR, binaryName); } export async function healthCheck( diff --git a/js/compressed-token/package.json b/js/compressed-token/package.json index aea4e9cafc..5a0c03e7fa 100644 --- a/js/compressed-token/package.json +++ b/js/compressed-token/package.json @@ -1,6 +1,6 @@ { "name": "@lightprotocol/compressed-token", - "version": "0.22.1-alpha.2", + "version": "0.22.1-alpha.5", "description": "JS client to interact with the compressed-token program", "sideEffects": false, "main": "dist/cjs/node/index.cjs", @@ -89,10 +89,10 @@ "vitest": "^2.1.1" }, "scripts": { - "test": "pnpm test:e2e:legacy:all", - "test-ci": "pnpm test:v1 && pnpm test:v2 && LIGHT_PROTOCOL_VERSION=V2 pnpm test:e2e:ctoken:all", + "test": "vitest run tests/unit && if [ \"$LIGHT_PROTOCOL_VERSION\" = \"V1\" ]; then pnpm test:e2e:legacy:all; else pnpm test:e2e:ctoken:all; fi", + "test-ci": "pnpm test:v1 && pnpm test:v2", "test:v1": "pnpm build:v1 && LIGHT_PROTOCOL_VERSION=V1 vitest run tests/unit && LIGHT_PROTOCOL_VERSION=V1 pnpm test:e2e:legacy:all", - "test:v2": "pnpm build:v2 && LIGHT_PROTOCOL_VERSION=V2 vitest run tests/unit && LIGHT_PROTOCOL_VERSION=V2 pnpm test:e2e:legacy:all", + "test:v2": "pnpm build:v2 && LIGHT_PROTOCOL_VERSION=V2 vitest run tests/unit && LIGHT_PROTOCOL_VERSION=V2 pnpm test:e2e:ctoken:all", "test:v2:ctoken": "pnpm build:v2 && LIGHT_PROTOCOL_VERSION=V2 pnpm test:e2e:ctoken:all", "test-all": "vitest run", "test:unit:all": "EXCLUDE_E2E=true vitest run", @@ -127,7 +127,7 @@ "test:e2e:rpc-token-interop": "pnpm test-validator && vitest run tests/e2e/rpc-token-interop.test.ts --reporter=verbose", "test:e2e:rpc-multi-trees": "pnpm test-validator && vitest run tests/e2e/rpc-multi-trees.test.ts --reporter=verbose", "test:e2e:multi-pool": "pnpm test-validator && vitest run tests/e2e/multi-pool.test.ts --reporter=verbose", - "test:e2e:legacy:all": "pnpm test-validator && vitest run tests/e2e/create-mint.test.ts && vitest run tests/e2e/mint-to.test.ts && vitest run tests/e2e/transfer.test.ts && vitest run tests/e2e/delegate.test.ts && vitest run tests/e2e/transfer-delegated.test.ts && vitest run tests/e2e/multi-pool.test.ts && vitest run tests/e2e/decompress-delegated.test.ts && pnpm test-validator-skip-prover && vitest run tests/e2e/compress.test.ts && vitest run tests/e2e/compress-spl-token-account.test.ts && vitest run tests/e2e/decompress.test.ts && vitest run tests/e2e/create-token-pool.test.ts && vitest run tests/e2e/approve-and-mint-to.test.ts && vitest run tests/e2e/rpc-token-interop.test.ts && vitest run tests/e2e/rpc-multi-trees.test.ts && vitest run tests/e2e/layout.test.ts && vitest run tests/e2e/select-accounts.test.ts", + "test:e2e:legacy:all": "pnpm test-validator && vitest run tests/e2e/create-mint.test.ts && vitest run tests/e2e/mint-to.test.ts && vitest run tests/e2e/transfer.test.ts && vitest run tests/e2e/delegate.test.ts && vitest run tests/e2e/transfer-delegated.test.ts && vitest run tests/e2e/multi-pool.test.ts && vitest run tests/e2e/decompress-delegated.test.ts && vitest run tests/e2e/merge-token-accounts.test.ts && pnpm test-validator-skip-prover && vitest run tests/e2e/compress.test.ts && vitest run tests/e2e/compress-spl-token-account.test.ts && vitest run tests/e2e/decompress.test.ts && vitest run tests/e2e/create-token-pool.test.ts && vitest run tests/e2e/approve-and-mint-to.test.ts && vitest run tests/e2e/rpc-token-interop.test.ts && vitest run tests/e2e/rpc-multi-trees.test.ts && vitest run tests/e2e/layout.test.ts && vitest run tests/e2e/select-accounts.test.ts", "test:e2e:wrap": "pnpm test-validator && vitest run tests/e2e/wrap.test.ts --reporter=verbose", "test:e2e:get-mint-interface": "pnpm test-validator && vitest run tests/e2e/get-mint-interface.test.ts --reporter=verbose", "test:e2e:get-or-create-ata-interface": "pnpm test-validator && vitest run tests/e2e/get-or-create-ata-interface.test.ts --reporter=verbose", @@ -137,7 +137,8 @@ "test:e2e:load-ata-combined": "pnpm test-validator && vitest run tests/e2e/load-ata-combined.test.ts --reporter=verbose", "test:e2e:load-ata-spl-t22": "pnpm test-validator && vitest run tests/e2e/load-ata-spl-t22.test.ts --reporter=verbose", "test:e2e:load-ata:all": "pnpm test-validator && vitest run tests/e2e/load-ata-standard.test.ts --bail=1 && pnpm test-validator && vitest run tests/e2e/load-ata-unified.test.ts --bail=1 && pnpm test-validator && vitest run tests/e2e/load-ata-combined.test.ts --bail=1 && pnpm test-validator && vitest run tests/e2e/load-ata-spl-t22.test.ts --bail=1", - "test:e2e:ctoken:all": "pnpm test-validator && vitest run tests/e2e/create-compressed-mint.test.ts --bail=1 && vitest run tests/e2e/create-associated-ctoken.test.ts --bail=1 && vitest run tests/e2e/mint-to-ctoken.test.ts --bail=1 && pnpm test-validator && vitest run tests/e2e/mint-to-compressed.test.ts --bail=1 && vitest run tests/e2e/mint-to-interface.test.ts --bail=1 && vitest run tests/e2e/mint-workflow.test.ts --bail=1 && vitest run tests/e2e/update-mint.test.ts --bail=1 && vitest run tests/e2e/update-metadata.test.ts --bail=1 && vitest run tests/e2e/compressible-load.test.ts --bail=1 && vitest run tests/e2e/wrap.test.ts --bail=1 && vitest run tests/e2e/get-mint-interface.test.ts --bail=1 && vitest run tests/e2e/get-account-interface.test.ts --bail=1 && pnpm test-validator && vitest run tests/e2e/load-ata-standard.test.ts --bail=1 && pnpm test-validator && vitest run tests/e2e/load-ata-unified.test.ts --bail=1 && pnpm test-validator && vitest run tests/e2e/load-ata-combined.test.ts --bail=1 && pnpm test-validator && vitest run tests/e2e/load-ata-spl-t22.test.ts --bail=1", + "test:e2e:ctoken:all": "pnpm test-validator && vitest run tests/e2e/create-compressed-mint.test.ts --bail=1 && vitest run tests/e2e/create-associated-ctoken.test.ts --bail=1 && vitest run tests/e2e/mint-to-ctoken.test.ts --bail=1 && pnpm test-validator && vitest run tests/e2e/mint-to-compressed.test.ts --bail=1 && vitest run tests/e2e/mint-to-interface.test.ts --bail=1 && vitest run tests/e2e/mint-workflow.test.ts --bail=1 && vitest run tests/e2e/update-mint.test.ts --bail=1 && vitest run tests/e2e/update-metadata.test.ts --bail=1 && vitest run tests/e2e/compressible-load.test.ts --bail=1 && vitest run tests/e2e/wrap.test.ts --bail=1 && vitest run tests/e2e/get-mint-interface.test.ts --bail=1 && vitest run tests/e2e/get-account-interface.test.ts --bail=1 && vitest run tests/e2e/create-mint-interface.test.ts --bail=1 && vitest run tests/e2e/create-ata-interface.test.ts --bail=1 && vitest run tests/e2e/get-or-create-ata-interface.test.ts --bail=1 && vitest run tests/e2e/transfer-interface.test.ts --bail=1 && vitest run tests/e2e/unwrap.test.ts --bail=1 && vitest run tests/e2e/decompress2.test.ts --bail=1 && vitest run tests/e2e/payment-flows.test.ts --bail=1 && pnpm test-validator && vitest run tests/e2e/load-ata-standard.test.ts --bail=1 && pnpm test-validator && vitest run tests/e2e/load-ata-unified.test.ts --bail=1 && pnpm test-validator && vitest run tests/e2e/load-ata-combined.test.ts --bail=1 && pnpm test-validator && vitest run tests/e2e/load-ata-spl-t22.test.ts --bail=1", + "test:e2e:all": "pnpm test:e2e:legacy:all && pnpm test:e2e:ctoken:all", "pull-idl": "../../scripts/push-compressed-token-idl.sh", "build": "if [ \"$LIGHT_PROTOCOL_VERSION\" = \"V2\" ]; then LIGHT_PROTOCOL_VERSION=V2 pnpm build:bundle; else LIGHT_PROTOCOL_VERSION=V1 pnpm build:bundle; fi", "build:bundle": "rimraf dist && rollup -c", diff --git a/js/compressed-token/src/actions/merge-token-accounts.ts b/js/compressed-token/src/actions/merge-token-accounts.ts index 8ca4844c82..a38766a626 100644 --- a/js/compressed-token/src/actions/merge-token-accounts.ts +++ b/js/compressed-token/src/actions/merge-token-accounts.ts @@ -15,8 +15,18 @@ import { import { CompressedTokenProgram } from '../program'; /** - * Merge multiple compressed token accounts for a given mint into a single - * account + * Max input accounts per merge. + * + * Even though V2 supports larger merges, we keep this at 4 to avoid oversized + * transactions / RPC payload limits under heavy test load. + */ +const MAX_MERGE_ACCOUNTS = 4; + +/** + * Merge multiple compressed token accounts for a given mint into fewer + * accounts. Each call merges up to 4 accounts (V1) or 8 accounts (V2) at a + * time. Call repeatedly until only 1 account remains if full consolidation + * is needed. * * @param rpc RPC connection to use * @param payer Fee payer @@ -44,33 +54,30 @@ export async function mergeTokenAccounts( ); } - const instructions = [ - ComputeBudgetProgram.setComputeUnitLimit({ units: 1_000_000 }), - ]; + if (compressedTokenAccounts.items.length === 1) { + throw new Error('Only one token account exists, nothing to merge'); + } - for ( - let i = 0; - i < compressedTokenAccounts.items.slice(0, 8).length; - i += 4 - ) { - const batch = compressedTokenAccounts.items.slice(i, i + 4); + // Take up to MAX_MERGE_ACCOUNTS to merge in this transaction + const batch = compressedTokenAccounts.items.slice(0, MAX_MERGE_ACCOUNTS); - const proof = await rpc.getValidityProof( - batch.map(account => bn(account.compressedAccount.hash)), - ); + const proof = await rpc.getValidityProof( + batch.map(account => bn(account.compressedAccount.hash)), + ); - const batchInstructions = - await CompressedTokenProgram.mergeTokenAccounts({ - payer: payer.publicKey, - owner: owner.publicKey, - inputCompressedTokenAccounts: batch, - mint, - recentValidityProof: proof.compressedProof, - recentInputStateRootIndices: proof.rootIndices, - }); + const mergeInstructions = await CompressedTokenProgram.mergeTokenAccounts({ + payer: payer.publicKey, + owner: owner.publicKey, + inputCompressedTokenAccounts: batch, + mint, + recentValidityProof: proof.compressedProof, + recentInputStateRootIndices: proof.rootIndices, + }); - instructions.push(...batchInstructions); - } + const instructions = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: 1_000_000 }), + ...mergeInstructions, + ]; const { blockhash } = await rpc.getLatestBlockhash(); const additionalSigners = dedupeSigner(payer, [owner]); diff --git a/js/compressed-token/src/program.ts b/js/compressed-token/src/program.ts index 5e9b7e5be3..c788912f07 100644 --- a/js/compressed-token/src/program.ts +++ b/js/compressed-token/src/program.ts @@ -1463,8 +1463,11 @@ export class CompressedTokenProgram { recentValidityProof, recentInputStateRootIndices, }: MergeTokenAccountsParams): Promise { - if (inputCompressedTokenAccounts.length > 4) { - throw new Error('Cannot merge more than 4 token accounts at once'); + const maxAccounts = featureFlags.isV2() ? 8 : 4; + if (inputCompressedTokenAccounts.length > maxAccounts) { + throw new Error( + `Cannot merge more than ${maxAccounts} token accounts at once`, + ); } checkMint(inputCompressedTokenAccounts, mint); diff --git a/js/compressed-token/src/v3/actions/create-mint-interface.ts b/js/compressed-token/src/v3/actions/create-mint-interface.ts index 81f7c0a0c2..3b7ec2848f 100644 --- a/js/compressed-token/src/v3/actions/create-mint-interface.ts +++ b/js/compressed-token/src/v3/actions/create-mint-interface.ts @@ -17,13 +17,15 @@ import { getBatchAddressTreeInfo, DerivationMode, CTOKEN_PROGRAM_ID, + getDefaultAddressTreeInfo, } from '@lightprotocol/stateless.js'; import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token'; import { createMintInstruction, TokenMetadataInstructionData, } from '../instructions/create-mint'; -import { findMintAddress } from '../derivation'; +import { setDevnetCompatFromEndpoint } from '../layout/devnet-compat'; +import { deriveCMintAddress, findMintAddress } from '../derivation'; import { createMint } from '../../actions/create-mint'; export { TokenMetadataInstructionData }; @@ -81,6 +83,14 @@ export async function createMintInterface( 'mintAuthority must be a Signer for compressed token mints', ); } + if ( + addressTreeInfo && + !addressTreeInfo.tree.equals(getDefaultAddressTreeInfo().tree) + ) { + throw new Error( + `addressTreeInfo ${addressTreeInfo?.tree.toString()} must be the default address tree info: ${getDefaultAddressTreeInfo().tree.toString()}`, + ); + } const resolvedFreezeAuthority = freezeAuthority && 'secretKey' in freezeAuthority @@ -92,17 +102,25 @@ export async function createMintInterface( outputStateTreeInfo ?? selectStateTreeInfo(await rpc.getStateTreeInfos()); + const compressedMintAddress = deriveCMintAddress( + keypair.publicKey, + addressTreeInfo, + ); + const validityProof = await rpc.getValidityProofV2( [], [ { - address: findMintAddress(keypair.publicKey)[0].toBytes(), + address: Uint8Array.from(compressedMintAddress), treeInfo: addressTreeInfo, }, ], - DerivationMode.compressible, + DerivationMode.standard, ); + // TODO: Remove after devnet program update + setDevnetCompatFromEndpoint(rpc.rpcEndpoint); + const ix = createMintInstruction( keypair.publicKey, decimals, @@ -123,10 +141,8 @@ export async function createMintInterface( blockhash, additionalSigners, ); - const txId = await sendAndConfirmTx(rpc, tx, { - ...confirmOptions, - skipPreflight: true, - }); + const txId = await sendAndConfirmTx(rpc, tx); + console.log('txId', txId); const mint = findMintAddress(keypair.publicKey); return { mint: mint[0], transactionSignature: txId }; diff --git a/js/compressed-token/src/v3/actions/get-or-create-ata-interface.ts b/js/compressed-token/src/v3/actions/get-or-create-ata-interface.ts index 905400638d..efcba8c649 100644 --- a/js/compressed-token/src/v3/actions/get-or-create-ata-interface.ts +++ b/js/compressed-token/src/v3/actions/get-or-create-ata-interface.ts @@ -67,7 +67,7 @@ export async function getOrCreateAtaInterface( allowOwnerOffCurve = false, commitment?: Commitment, confirmOptions?: ConfirmOptions, - programId = TOKEN_PROGRAM_ID, + programId = CTOKEN_PROGRAM_ID, associatedTokenProgramId = getAtaProgramId(programId), ): Promise { return _getOrCreateAtaInterface( @@ -140,6 +140,7 @@ export async function _getOrCreateAtaInterface( commitment, confirmOptions, wrap, + allowOwnerOffCurve, ); } @@ -179,6 +180,7 @@ async function getOrCreateCTokenAta( commitment?: Commitment, confirmOptions?: ConfirmOptions, wrap = false, + allowOwnerOffCurve = false, ): Promise { const ownerPubkey = getOwnerPublicKey(owner); const ownerIsSigner = isSigner(owner); @@ -197,6 +199,7 @@ async function getOrCreateCTokenAta( commitment, CTOKEN_PROGRAM_ID, wrap, + allowOwnerOffCurve, ); // Check if we have a hot account @@ -228,6 +231,7 @@ async function getOrCreateCTokenAta( commitment, CTOKEN_PROGRAM_ID, wrap, + allowOwnerOffCurve, ); hasHotAccount = true; } else { @@ -300,6 +304,7 @@ async function getOrCreateCTokenAta( commitment, CTOKEN_PROGRAM_ID, wrap, + allowOwnerOffCurve, ); } } diff --git a/js/compressed-token/src/v3/actions/load-ata.ts b/js/compressed-token/src/v3/actions/load-ata.ts index d6a9f7868a..4496732c9e 100644 --- a/js/compressed-token/src/v3/actions/load-ata.ts +++ b/js/compressed-token/src/v3/actions/load-ata.ts @@ -4,6 +4,8 @@ import { buildAndSignTx, sendAndConfirmTx, dedupeSigner, + bn, + ParsedTokenAccount, } from '@lightprotocol/stateless.js'; import { PublicKey, @@ -18,10 +20,13 @@ import { TOKEN_2022_PROGRAM_ID, getAssociatedTokenAddressSync, createAssociatedTokenAccountIdempotentInstruction, + TokenAccountNotFoundError, } from '@solana/spl-token'; import { AccountInterface, getAtaInterface as _getAtaInterface, + TokenAccountSource, + TokenAccountSourceType, } from '../get-account-interface'; import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-ata-interface'; @@ -34,6 +39,69 @@ import { import { getAtaProgramId, checkAtaAddress, AtaType } from '../ata-utils'; import { InterfaceOptions } from './transfer-interface'; +function getCompressedTokenAccountsFromAtaSources( + sources: TokenAccountSource[], +): ParsedTokenAccount[] { + const coldTypes = new Set([ + TokenAccountSourceType.CTokenCold, + TokenAccountSourceType.SplCold, + TokenAccountSourceType.Token2022Cold, + ]); + + return sources + .filter(source => source.loadContext !== undefined) + .filter(source => coldTypes.has(source.type)) + .map(source => { + const fullData = source.accountInfo.data; + const discriminatorBytes = fullData.subarray( + 0, + Math.min(8, fullData.length), + ); + const accountDataBytes = + fullData.length > 8 ? fullData.subarray(8) : Buffer.alloc(0); + + const compressedAccount = { + treeInfo: source.loadContext!.treeInfo, + hash: source.loadContext!.hash, + leafIndex: source.loadContext!.leafIndex, + proveByIndex: source.loadContext!.proveByIndex, + owner: source.accountInfo.owner, + lamports: bn(source.accountInfo.lamports), + address: null, + data: + fullData.length === 0 + ? null + : { + discriminator: Array.from(discriminatorBytes), + data: Buffer.from(accountDataBytes), + dataHash: new Array(32).fill(0), + }, + readOnly: false, + }; + + const state = !source.parsed.isInitialized + ? 0 + : source.parsed.isFrozen + ? 2 + : 1; + + return { + compressedAccount: compressedAccount as any, + parsed: { + mint: source.parsed.mint, + owner: source.parsed.owner, + amount: bn(source.parsed.amount.toString()), + delegate: source.parsed.delegate, + state, + tlv: + source.parsed.tlvData.length > 0 + ? source.parsed.tlvData + : null, + }, + } satisfies ParsedTokenAccount; + }); +} + // Re-export types moved to instructions export { ParsedAccountInfoInterface, @@ -77,23 +145,31 @@ export async function createLoadAtaInstructions( // Validation happens inside getAtaInterface via checkAtaAddress helper: // - Always validates ata matches mint+owner derivation // - For wrap=true, additionally requires c-token ATA - const ataInterface = await _getAtaInterface( - rpc, - ata, - owner, - mint, - undefined, - undefined, - wrap, - ); - return createLoadAtaInstructionsFromInterface( - rpc, - payer, - ataInterface, - options, - wrap, - ata, - ); + try { + const ataInterface = await _getAtaInterface( + rpc, + ata, + owner, + mint, + undefined, + undefined, + wrap, + ); + return createLoadAtaInstructionsFromInterface( + rpc, + payer, + ataInterface, + options, + wrap, + ata, + ); + } catch (error) { + // If account doesn't exist, there's nothing to load + if (error instanceof TokenAccountNotFoundError) { + return []; + } + throw error; + } } // Re-export AtaType for backwards compatibility @@ -256,9 +332,8 @@ export async function createLoadAtaInstructionsFromInterface( // 4. Decompress compressed tokens to c-token ATA if (coldBalance > BigInt(0) && ctokenColdSource) { - const compressedResult = - await rpc.getCompressedTokenAccountsByOwner(owner, { mint }); - const compressedAccounts = compressedResult.items; + const compressedAccounts = + getCompressedTokenAccountsFromAtaSources(sources); if (compressedAccounts.length > 0) { const proof = await rpc.getValidityProofV0( @@ -284,9 +359,8 @@ export async function createLoadAtaInstructionsFromInterface( // STANDARD MODE: Decompress to target ATA type if (coldBalance > BigInt(0) && ctokenColdSource) { - const compressedResult = - await rpc.getCompressedTokenAccountsByOwner(owner, { mint }); - const compressedAccounts = compressedResult.items; + const compressedAccounts = + getCompressedTokenAccountsFromAtaSources(sources); if (compressedAccounts.length > 0) { const proof = await rpc.getValidityProofV0( diff --git a/js/compressed-token/src/v3/actions/mint-to-compressed.ts b/js/compressed-token/src/v3/actions/mint-to-compressed.ts index db09d5bddc..8167ea2be9 100644 --- a/js/compressed-token/src/v3/actions/mint-to-compressed.ts +++ b/js/compressed-token/src/v3/actions/mint-to-compressed.ts @@ -16,6 +16,7 @@ import { TreeInfo, } from '@lightprotocol/stateless.js'; import { createMintToCompressedInstruction } from '../instructions/mint-to-compressed'; +import { setDevnetCompatFromEndpoint } from '../layout/devnet-compat'; import { getMintInterface } from '../get-mint-interface'; /** @@ -40,6 +41,9 @@ export async function mintToCompressed( tokenAccountVersion: number = 3, confirmOptions?: ConfirmOptions, ): Promise { + // TODO: Remove after devnet program update + setDevnetCompatFromEndpoint(rpc.rpcEndpoint); + const mintInfo = await getMintInterface( rpc, mint, diff --git a/js/compressed-token/src/v3/actions/mint-to.ts b/js/compressed-token/src/v3/actions/mint-to.ts index 70238a8ee6..dcc2f2355d 100644 --- a/js/compressed-token/src/v3/actions/mint-to.ts +++ b/js/compressed-token/src/v3/actions/mint-to.ts @@ -16,6 +16,7 @@ import { TreeInfo, } from '@lightprotocol/stateless.js'; import { createMintToInstruction } from '../instructions/mint-to'; +import { setDevnetCompatFromEndpoint } from '../layout/devnet-compat'; import { getMintInterface } from '../get-mint-interface'; export async function mintTo( @@ -28,6 +29,9 @@ export async function mintTo( outputQueue?: PublicKey, confirmOptions?: ConfirmOptions, ): Promise { + // TODO: Remove after devnet program update + setDevnetCompatFromEndpoint(rpc.rpcEndpoint); + const mintInfo = await getMintInterface( rpc, mint, diff --git a/js/compressed-token/src/v3/actions/transfer-interface.ts b/js/compressed-token/src/v3/actions/transfer-interface.ts index 4675f584ca..e16f5fb24d 100644 --- a/js/compressed-token/src/v3/actions/transfer-interface.ts +++ b/js/compressed-token/src/v3/actions/transfer-interface.ts @@ -68,7 +68,9 @@ function calculateComputeUnits( // SPL/T22 wrap operations cu += splWrapCount * 5_000; - return cu; + // TODO: dynamic + // return cu; + return 200_000; } /** @@ -327,7 +329,6 @@ export async function transferInterface( destination, owner.publicKey, amountBigInt, - payer.publicKey, ), ); diff --git a/js/compressed-token/src/v3/actions/update-mint.ts b/js/compressed-token/src/v3/actions/update-mint.ts index 838328e573..86c1d4c266 100644 --- a/js/compressed-token/src/v3/actions/update-mint.ts +++ b/js/compressed-token/src/v3/actions/update-mint.ts @@ -17,6 +17,7 @@ import { createUpdateMintAuthorityInstruction, createUpdateFreezeAuthorityInstruction, } from '../instructions/update-mint'; +import { setDevnetCompatFromEndpoint } from '../layout/devnet-compat'; import { getMintInterface } from '../get-mint-interface'; /** @@ -37,6 +38,9 @@ export async function updateMintAuthority( newMintAuthority: PublicKey | null, confirmOptions?: ConfirmOptions, ): Promise { + // TODO: Remove after devnet program update + setDevnetCompatFromEndpoint(rpc.rpcEndpoint); + const mintInterface = await getMintInterface( rpc, mint, @@ -104,6 +108,9 @@ export async function updateFreezeAuthority( newFreezeAuthority: PublicKey | null, confirmOptions?: ConfirmOptions, ): Promise { + // TODO: Remove after devnet program update + setDevnetCompatFromEndpoint(rpc.rpcEndpoint); + const mintInterface = await getMintInterface( rpc, mint, diff --git a/js/compressed-token/src/v3/ata-utils.ts b/js/compressed-token/src/v3/ata-utils.ts index 482d75f5e2..1ffb3e758a 100644 --- a/js/compressed-token/src/v3/ata-utils.ts +++ b/js/compressed-token/src/v3/ata-utils.ts @@ -34,24 +34,26 @@ export interface AtaValidationResult { * * Pass programId for fast path. * - * @param ata ATA address to check - * @param mint Mint address - * @param owner Owner address - * @param programId Optional: if known, only check this program's ATA - * @returns Result with detected type, or throws on mismatch + * @param ata ATA address to check + * @param mint Mint address + * @param owner Owner address + * @param programId Optional: if known, only check this program's ATA + * @param allowOwnerOffCurve Allow the owner to be off-curve (PDA) + * @returns Result with detected type, or throws on mismatch */ export function checkAtaAddress( ata: PublicKey, mint: PublicKey, owner: PublicKey, programId?: PublicKey, + allowOwnerOffCurve = false, ): AtaValidationResult { // fast path if (programId) { const expected = getAssociatedTokenAddressSync( mint, owner, - false, + allowOwnerOffCurve, programId, getAtaProgramId(programId), ); @@ -76,7 +78,7 @@ export function checkAtaAddress( ctokenExpected = getAssociatedTokenAddressSync( mint, owner, - false, + allowOwnerOffCurve, CTOKEN_PROGRAM_ID, getAtaProgramId(CTOKEN_PROGRAM_ID), ); @@ -92,7 +94,7 @@ export function checkAtaAddress( splExpected = getAssociatedTokenAddressSync( mint, owner, - false, + allowOwnerOffCurve, TOKEN_PROGRAM_ID, getAtaProgramId(TOKEN_PROGRAM_ID), ); @@ -104,7 +106,7 @@ export function checkAtaAddress( t22Expected = getAssociatedTokenAddressSync( mint, owner, - false, + allowOwnerOffCurve, TOKEN_2022_PROGRAM_ID, getAtaProgramId(TOKEN_2022_PROGRAM_ID), ); diff --git a/js/compressed-token/src/v3/get-account-interface.ts b/js/compressed-token/src/v3/get-account-interface.ts index c54c437410..a2f7ddf757 100644 --- a/js/compressed-token/src/v3/get-account-interface.ts +++ b/js/compressed-token/src/v3/get-account-interface.ts @@ -220,13 +220,14 @@ export async function getAccountInterface( /** * Retrieve associated token account for a given owner and mint. * - * @param rpc RPC connection - * @param ata Associated token address - * @param owner Owner public key - * @param mint Mint public key - * @param commitment Optional commitment level - * @param programId Optional program ID - * @param wrap Include SPL/T22 balances (default: false) + * @param rpc RPC connection + * @param ata Associated token address + * @param owner Owner public key + * @param mint Mint public key + * @param commitment Optional commitment level + * @param programId Optional program ID + * @param wrap Include SPL/T22 balances (default: false) + * @param allowOwnerOffCurve Allow owner to be off-curve (PDA) * @returns AccountInterface with ATA metadata */ export async function getAtaInterface( @@ -237,11 +238,18 @@ export async function getAtaInterface( commitment?: Commitment, programId?: PublicKey, wrap = false, + allowOwnerOffCurve = false, ): Promise { // Invariant: ata MUST match a valid derivation from mint+owner. // Hot path: if programId provided, only validate against that program. // For wrap=true, additionally require c-token ATA. - const validation = checkAtaAddress(ata, mint, owner, programId); + const validation = checkAtaAddress( + ata, + mint, + owner, + programId, + allowOwnerOffCurve, + ); if (wrap && validation.type !== 'ctoken') { throw new Error( @@ -574,10 +582,7 @@ async function getUnifiedAccountInterface( // account not found if (sources.length === 0) { - const triedPrograms = wrap - ? 'TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, and CTOKEN_PROGRAM_ID (both onchain and compressed)' - : 'CTOKEN_PROGRAM_ID (both onchain and compressed)'; - throw new Error(`Token account not found. Tried ${triedPrograms}.`); + throw new TokenAccountNotFoundError(); } // priority order: c-token hot > c-token cold > SPL/T22 diff --git a/js/compressed-token/src/v3/instructions/create-mint.ts b/js/compressed-token/src/v3/instructions/create-mint.ts index df737eb7b0..cd15abb504 100644 --- a/js/compressed-token/src/v3/instructions/create-mint.ts +++ b/js/compressed-token/src/v3/instructions/create-mint.ts @@ -203,6 +203,25 @@ export function createMintInstruction( metadata, }); + return buildCreateMintIx( + mintSigner, + mintAuthority, + payer, + outputStateTreeInfo, + addressTreeInfo, + data, + ); +} + +/** @internal */ +function buildCreateMintIx( + mintSigner: PublicKey, + mintAuthority: PublicKey, + payer: PublicKey, + outputStateTreeInfo: TreeInfo, + addressTreeInfo: AddressTreeInfo, + data: Buffer, +): TransactionInstruction { const sys = defaultStaticAccountsStruct(); const keys = [ { diff --git a/js/compressed-token/src/v3/instructions/transfer-interface.ts b/js/compressed-token/src/v3/instructions/transfer-interface.ts index 71aa2c57e1..8cefbae9ac 100644 --- a/js/compressed-token/src/v3/instructions/transfer-interface.ts +++ b/js/compressed-token/src/v3/instructions/transfer-interface.ts @@ -16,9 +16,8 @@ const CTOKEN_TRANSFER_DISCRIMINATOR = 3; * * @param source Source c-token account * @param destination Destination c-token account - * @param owner Owner of the source account (signer) + * @param owner Owner of the source account (signer, also pays for compressible extension top-ups) * @param amount Amount to transfer - * @param payer Payer for compressible extension top-up (optional) * @returns Transaction instruction for c-token transfer */ export function createCTokenTransferInstruction( @@ -26,29 +25,20 @@ export function createCTokenTransferInstruction( destination: PublicKey, owner: PublicKey, amount: number | bigint, - payer?: PublicKey, ): TransactionInstruction { - // Instruction data format (from CTOKEN_TRANSFER.md): + // Instruction data format: // byte 0: discriminator (3) - // byte 1: padding (0) - // bytes 2-9: amount (u64 LE) - SPL TokenInstruction::Transfer format - const data = Buffer.alloc(10); + // bytes 1-8: amount (u64 LE) + const data = Buffer.alloc(9); data.writeUInt8(CTOKEN_TRANSFER_DISCRIMINATOR, 0); - data.writeUInt8(0, 1); // padding - data.writeBigUInt64LE(BigInt(amount), 2); + data.writeBigUInt64LE(BigInt(amount), 1); const keys = [ { pubkey: source, isSigner: false, isWritable: true }, { pubkey: destination, isSigner: false, isWritable: true }, - { pubkey: owner, isSigner: true, isWritable: false }, + { pubkey: owner, isSigner: true, isWritable: true }, // owner is also payer for top-ups ]; - // Add payer as 4th account if provided and different from owner - // (for compressible extension top-up) - if (payer && !payer.equals(owner)) { - keys.push({ pubkey: payer, isSigner: true, isWritable: true }); - } - return new TransactionInstruction({ programId: CTOKEN_PROGRAM_ID, keys, @@ -64,7 +54,6 @@ export function createCTokenTransferInstruction( * @param destination Destination token account * @param owner Owner of the source account (signer) * @param amount Amount to transfer - * @param payer Payer for compressible top-up (optional) * @returns instruction for c-token transfer */ export function createTransferInterfaceInstruction( @@ -74,7 +63,6 @@ export function createTransferInterfaceInstruction( amount: number | bigint, multiSigners: (Signer | PublicKey)[] = [], programId: PublicKey = CTOKEN_PROGRAM_ID, - payer?: PublicKey, ): TransactionInstruction { if (programId.equals(CTOKEN_PROGRAM_ID)) { if (multiSigners.length > 0) { @@ -87,7 +75,6 @@ export function createTransferInterfaceInstruction( destination, owner, amount, - payer, ); } diff --git a/js/compressed-token/src/v3/layout/devnet-compat.ts b/js/compressed-token/src/v3/layout/devnet-compat.ts new file mode 100644 index 0000000000..945c11e3d1 --- /dev/null +++ b/js/compressed-token/src/v3/layout/devnet-compat.ts @@ -0,0 +1,29 @@ +/** + * @internal + * Temporary devnet compatibility config. + * TODO: Remove after devnet program update (deployed at slot 426761768, Dec 8, 2025). + */ + +let _useDevnetFormat = false; + +/** + * Enable V1 instruction format for devnet compatibility. + * Call this before any mint operations when targeting devnet. + */ +export function setDevnetCompat(enabled: boolean): void { + _useDevnetFormat = enabled; +} + +/** + * Check if devnet compatibility mode is enabled. + */ +export function isDevnetCompat(): boolean { + return _useDevnetFormat; +} + +/** + * Auto-detect and set devnet compat from RPC endpoint. + */ +export function setDevnetCompatFromEndpoint(endpoint: string): void { + _useDevnetFormat = endpoint.toLowerCase().includes('devnet'); +} diff --git a/js/compressed-token/src/v3/layout/index.ts b/js/compressed-token/src/v3/layout/index.ts index 15d7eeae67..01dc4b8332 100644 --- a/js/compressed-token/src/v3/layout/index.ts +++ b/js/compressed-token/src/v3/layout/index.ts @@ -2,4 +2,6 @@ export * from './layout-mint'; export * from './layout-transfer2'; export * from './layout-token-metadata'; export * from './layout-mint-action'; +// TODO: Remove after devnet program update +export * from './devnet-compat'; export * from './serde'; diff --git a/js/compressed-token/src/v3/layout/layout-mint-action.ts b/js/compressed-token/src/v3/layout/layout-mint-action.ts index 4c2c8ce53d..275ab82c3b 100644 --- a/js/compressed-token/src/v3/layout/layout-mint-action.ts +++ b/js/compressed-token/src/v3/layout/layout-mint-action.ts @@ -23,6 +23,7 @@ import { rustEnum, } from '@coral-xyz/borsh'; import { bn } from '@lightprotocol/stateless.js'; +import { isDevnetCompat } from './devnet-compat'; export const MINT_ACTION_DISCRIMINATOR = Buffer.from([103]); @@ -171,6 +172,48 @@ export const MintActionCompressedInstructionDataLayout = struct([ option(CompressedMintInstructionDataLayout, 'mint'), ]); +// TODO: Remove V1 layouts after devnet program update +const ActionLayoutV1 = rustEnum([ + MintToCompressedActionLayout.replicate('mintToCompressed'), + UpdateAuthorityLayout.replicate('updateMintAuthority'), + UpdateAuthorityLayout.replicate('updateFreezeAuthority'), + CreateSplMintActionLayout.replicate('createSplMint'), + MintToCTokenActionLayout.replicate('mintToCToken'), + UpdateMetadataFieldActionLayout.replicate('updateMetadataField'), + UpdateMetadataAuthorityActionLayout.replicate('updateMetadataAuthority'), + RemoveMetadataKeyActionLayout.replicate('removeMetadataKey'), +]); +// TODO: Remove V1 layouts after devnet program update +const CompressedMintMetadataLayoutV1 = struct([ + u8('version'), + bool('splMintInitialized'), + publicKey('mint'), +]); +// TODO: Remove V1 layouts after devnet program update +const CompressedMintInstructionDataLayoutV1 = struct([ + u64('supply'), + u8('decimals'), + CompressedMintMetadataLayoutV1.replicate('metadata'), + option(publicKey(), 'mintAuthority'), + option(publicKey(), 'freezeAuthority'), + option(vec(ExtensionInstructionDataLayout), 'extensions'), +]); +// TODO: Remove V1 layouts after devnet program update +const MintActionCompressedInstructionDataLayoutV1 = struct([ + u32('leafIndex'), + bool('proveByIndex'), + u16('rootIndex'), + array(u8(), 32, 'compressedAddress'), + u8('tokenPoolBump'), + u8('tokenPoolIndex'), + u16('maxTopUp'), + option(CreateMintLayout, 'createMint'), + vec(ActionLayoutV1, 'actions'), + option(CompressedProofLayout, 'proof'), + option(CpiContextLayout, 'cpiContext'), + CompressedMintInstructionDataLayoutV1.replicate('mint'), // V1: not optional +]); + export interface ValidityProof { a: number[]; b: number[]; @@ -313,48 +356,83 @@ export interface MintActionCompressedInstructionData { export function encodeMintActionInstructionData( data: MintActionCompressedInstructionData, ): Buffer { + const useV1 = isDevnetCompat(); + // Convert bigint fields to BN for Borsh encoding - const encodableData = { - ...data, - mint: data.mint - ? { - ...data.mint, - supply: bn(data.mint.supply.toString()), - } - : null, - actions: data.actions.map(action => { - // Handle MintToCompressed action with recipients - if ('mintToCompressed' in action && action.mintToCompressed) { - return { - mintToCompressed: { - ...action.mintToCompressed, - recipients: action.mintToCompressed.recipients.map( - r => ({ - ...r, - amount: bn(r.amount.toString()), - }), - ), - }, - }; - } - // Handle MintToCToken action (c-token mint-to) - if ('mintToCToken' in action && action.mintToCToken) { - return { - mintToCToken: { - ...action.mintToCToken, - amount: bn(action.mintToCToken.amount.toString()), - }, - }; - } - return action; - }), - }; - - const buffer = Buffer.alloc(10000); // Generous allocation - const len = MintActionCompressedInstructionDataLayout.encode( - encodableData, - buffer, - ); + // TODO: Remove V1 layouts after devnet program update + const convertedActions = data.actions.map(action => { + if ('mintToCompressed' in action && action.mintToCompressed) { + return { + mintToCompressed: { + ...action.mintToCompressed, + recipients: action.mintToCompressed.recipients.map(r => ({ + ...r, + amount: bn(r.amount.toString()), + })), + }, + }; + } + if ('mintToCToken' in action && action.mintToCToken) { + return { + mintToCToken: { + ...action.mintToCToken, + amount: bn(action.mintToCToken.amount.toString()), + }, + }; + } + // V1 doesn't support new action types + if ( + useV1 && + ('decompressMint' in action || 'compressAndCloseCMint' in action) + ) { + throw new Error( + 'decompressMint/compressAndCloseCMint not supported on devnet', + ); + } + return action; + }); + + const buffer = Buffer.alloc(10000); + let len: number; + + // TODO: Remove V1 branch after devnet program update + if (useV1) { + if (!data.mint) { + throw new Error('V1 format requires mint data (cannot be null)'); + } + const encodableDataV1 = { + ...data, + actions: convertedActions, + mint: { + ...data.mint, + supply: bn(data.mint.supply.toString()), + metadata: { + version: data.mint.metadata.version, + splMintInitialized: data.mint.metadata.cmintDecompressed, + mint: data.mint.metadata.mint, + }, + }, + }; + len = MintActionCompressedInstructionDataLayoutV1.encode( + encodableDataV1, + buffer, + ); + } else { + const encodableData = { + ...data, + actions: convertedActions, + mint: data.mint + ? { + ...data.mint, + supply: bn(data.mint.supply.toString()), + } + : null, + }; + len = MintActionCompressedInstructionDataLayout.encode( + encodableData, + buffer, + ); + } return Buffer.concat([MINT_ACTION_DISCRIMINATOR, buffer.subarray(0, len)]); } diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index 5571c1f918..d83d7dfa79 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -7,6 +7,7 @@ import { PublicKey, Signer, ConfirmOptions, Commitment } from '@solana/web3.js'; import { Rpc, CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; import BN from 'bn.js'; +import { TokenAccountNotFoundError } from '@solana/spl-token'; import { getAtaInterface as _getAtaInterface, @@ -133,7 +134,7 @@ export async function loadAta( confirmOptions?: ConfirmOptions, interfaceOptions?: InterfaceOptions, ) { - return _loadAta( + const signature = await _loadAta( rpc, ata, owner, @@ -143,6 +144,17 @@ export async function loadAta( interfaceOptions, true, ); + + // Unified semantics: if the canonical c-token ATA does not exist at all, + // treat this as an error (caller has no balances to load and no ATA). + if (signature === null) { + const accountInfo = await rpc.getAccountInfo(ata); + if (!accountInfo) { + throw new TokenAccountNotFoundError(); + } + } + + return signature; } /** diff --git a/js/compressed-token/tests/e2e/create-compressed-mint.test.ts b/js/compressed-token/tests/e2e/create-compressed-mint.test.ts index 875f837458..fcb4cdfa24 100644 --- a/js/compressed-token/tests/e2e/create-compressed-mint.test.ts +++ b/js/compressed-token/tests/e2e/create-compressed-mint.test.ts @@ -16,6 +16,7 @@ import { CTOKEN_PROGRAM_ID, DerivationMode, selectStateTreeInfo, + TreeType, } from '@lightprotocol/stateless.js'; import { createMintInstruction, @@ -136,7 +137,7 @@ describe('createMintInterface (compressed)', () => { tree: new PublicKey('amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx'), queue: new PublicKey('amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx'), cpiContext: undefined, - treeType: 1, + treeType: TreeType.AddressV2, nextTreeInfo: null, }; diff --git a/js/compressed-token/tests/e2e/get-account-interface.test.ts b/js/compressed-token/tests/e2e/get-account-interface.test.ts index 5700919869..e7e97876bb 100644 --- a/js/compressed-token/tests/e2e/get-account-interface.test.ts +++ b/js/compressed-token/tests/e2e/get-account-interface.test.ts @@ -421,7 +421,7 @@ describe('get-account-interface', () => { // getAccountInterface auto-detect cannot find minted-compressed tokens await expect( getAccountInterface(rpc, ctokenAta, 'confirmed'), - ).rejects.toThrow(/Token account not found/); + ).rejects.toThrow(TokenAccountNotFoundError); // Use getAtaInterface for minted-compressed tokens const result = await getAtaInterface( @@ -464,7 +464,7 @@ describe('get-account-interface', () => { await expect( getAccountInterface(rpc, nonExistentAta, 'confirmed'), - ).rejects.toThrow(/Token account not found/); + ).rejects.toThrow(TokenAccountNotFoundError); }); }); }); @@ -774,7 +774,7 @@ describe('get-account-interface', () => { owner.publicKey, ctokenMint, ), - ).rejects.toThrow(/Token account not found/); + ).rejects.toThrow(TokenAccountNotFoundError); }); }); diff --git a/js/compressed-token/tests/e2e/get-or-create-ata-interface.test.ts b/js/compressed-token/tests/e2e/get-or-create-ata-interface.test.ts index 93c7c218f4..57be60ed29 100644 --- a/js/compressed-token/tests/e2e/get-or-create-ata-interface.test.ts +++ b/js/compressed-token/tests/e2e/get-or-create-ata-interface.test.ts @@ -806,39 +806,37 @@ describe('getOrCreateAtaInterface', () => { }); }); - describe('default programId (TOKEN_PROGRAM_ID)', () => { - let splMint: PublicKey; + describe('default programId (CTOKEN_PROGRAM_ID)', () => { + let ctokenMint: PublicKey; beforeAll(async () => { const mintAuthority = Keypair.generate(); - splMint = await createMint( + const result = await createMintInterface( rpc, payer, - mintAuthority.publicKey, + mintAuthority, null, 9, - undefined, - undefined, - TOKEN_PROGRAM_ID, ); + ctokenMint = result.mint; }); - it('should default to TOKEN_PROGRAM_ID when programId not specified', async () => { + it('should default to CTOKEN_PROGRAM_ID when programId not specified', async () => { const owner = Keypair.generate(); const expectedAddress = getAssociatedTokenAddressSync( - splMint, + ctokenMint, owner.publicKey, false, - TOKEN_PROGRAM_ID, - ASSOCIATED_TOKEN_PROGRAM_ID, + CTOKEN_PROGRAM_ID, + CTOKEN_PROGRAM_ID, // c-token uses CTOKEN_PROGRAM_ID as ATA program ); // Call without specifying programId const account = await getOrCreateAtaInterface( rpc, payer, - splMint, + ctokenMint, owner.publicKey, ); @@ -846,9 +844,9 @@ describe('getOrCreateAtaInterface', () => { expectedAddress.toBase58(), ); - // Verify it's owned by TOKEN_PROGRAM_ID + // Verify it's owned by CTOKEN_PROGRAM_ID const info = await rpc.getAccountInfo(expectedAddress); - expect(info?.owner.toBase58()).toBe(TOKEN_PROGRAM_ID.toBase58()); + expect(info?.owner.toBase58()).toBe(CTOKEN_PROGRAM_ID.toBase58()); }); }); diff --git a/js/compressed-token/tests/e2e/load-ata-standard.test.ts b/js/compressed-token/tests/e2e/load-ata-standard.test.ts index 0f8791cc24..87fe645aa0 100644 --- a/js/compressed-token/tests/e2e/load-ata-standard.test.ts +++ b/js/compressed-token/tests/e2e/load-ata-standard.test.ts @@ -22,6 +22,7 @@ import { TOKEN_PROGRAM_ID, createAssociatedTokenAccount, getAccount, + TokenAccountNotFoundError, } from '@solana/spl-token'; import { createMint, mintTo, decompress } from '../../src/actions'; import { @@ -286,22 +287,21 @@ describe('loadAta - Standard Path (wrap=false)', () => { }); describe('createLoadAtaInstructions', () => { - it('should throw when no accounts exist', async () => { + it('should return empty when no accounts exist', async () => { const owner = Keypair.generate(); const ata = getAssociatedTokenAddressInterface( mint, owner.publicKey, ); - await expect( - createLoadAtaInstructions( - rpc, - ata, - owner.publicKey, - mint, - payer.publicKey, - ), - ).rejects.toThrow('Token account not found'); + const ixs = await createLoadAtaInstructions( + rpc, + ata, + owner.publicKey, + mint, + payer.publicKey, + ); + expect(ixs.length).toBe(0); }); it('should return empty when hot exists but no cold', async () => { diff --git a/js/compressed-token/tests/e2e/load-ata-unified.test.ts b/js/compressed-token/tests/e2e/load-ata-unified.test.ts index 1cb71d3fd4..0b22c71cd0 100644 --- a/js/compressed-token/tests/e2e/load-ata-unified.test.ts +++ b/js/compressed-token/tests/e2e/load-ata-unified.test.ts @@ -22,6 +22,7 @@ import { createAssociatedTokenAccount, getOrCreateAssociatedTokenAccount, getAccount, + TokenAccountNotFoundError, } from '@solana/spl-token'; import { createMint, mintTo, decompress } from '../../src/actions'; import { @@ -361,7 +362,7 @@ describe('loadAta - Unified Path (wrap=true)', () => { owner as unknown as Signer, mint, ), - ).rejects.toThrow('Token account not found'); + ).rejects.toThrow(TokenAccountNotFoundError); }); it('should return null when only hot balance exists', async () => { diff --git a/js/compressed-token/tests/e2e/merge-token-accounts.test.ts b/js/compressed-token/tests/e2e/merge-token-accounts.test.ts index e63a4a7434..06338f9e40 100644 --- a/js/compressed-token/tests/e2e/merge-token-accounts.test.ts +++ b/js/compressed-token/tests/e2e/merge-token-accounts.test.ts @@ -57,7 +57,7 @@ describe('mergeTokenAccounts', () => { } }); - it.only('should merge all token accounts', async () => { + it('should merge all token accounts', async () => { const preAccounts = await rpc.getCompressedTokenAccountsByOwner( owner.publicKey, { mint }, @@ -79,36 +79,4 @@ describe('mergeTokenAccounts', () => { ); expect(totalBalance.toNumber()).to.equal(500); // 5 accounts * 100 tokens each }); - - // TODO: add coverage for this apparent edge case. not required for now though. - it('should handle merging when there is only one account', async () => { - try { - await mergeTokenAccounts(rpc, payer, mint, owner); - console.log('First merge succeeded'); - - const postFirstMergeAccounts = - await rpc.getCompressedTokenAccountsByOwner(owner.publicKey, { - mint, - }); - console.log('Accounts after first merge:', postFirstMergeAccounts); - } catch (error) { - console.error('First merge failed:', error); - throw error; - } - - // Second merge attempt - try { - await mergeTokenAccounts(rpc, payer, mint, owner); - console.log('Second merge succeeded'); - } catch (error) { - console.error('Second merge failed:', error); - } - - const finalAccounts = await rpc.getCompressedTokenAccountsByOwner( - owner.publicKey, - { mint }, - ); - console.log('Final accounts:', finalAccounts); - expect(finalAccounts.items.length).to.equal(1); - }); }); diff --git a/js/compressed-token/tests/e2e/transfer-interface.test.ts b/js/compressed-token/tests/e2e/transfer-interface.test.ts index 90e03a5266..c50edfa5e0 100644 --- a/js/compressed-token/tests/e2e/transfer-interface.test.ts +++ b/js/compressed-token/tests/e2e/transfer-interface.test.ts @@ -84,11 +84,10 @@ describe('transfer-interface', () => { expect(ix.keys[2].pubkey.equals(owner)).toBe(true); }); - it('should add payer as 4th account when different from owner', () => { + it('should have owner as writable (pays for top-ups)', () => { const source = Keypair.generate().publicKey; const destination = Keypair.generate().publicKey; const owner = Keypair.generate().publicKey; - const payerPk = Keypair.generate().publicKey; const amount = BigInt(1000); const ix = createCTokenTransferInstruction( @@ -96,28 +95,12 @@ describe('transfer-interface', () => { destination, owner, amount, - payerPk, - ); - - expect(ix.keys.length).toBe(4); - expect(ix.keys[3].pubkey.equals(payerPk)).toBe(true); - }); - - it('should not add payer when same as owner', () => { - const source = Keypair.generate().publicKey; - const destination = Keypair.generate().publicKey; - const owner = Keypair.generate().publicKey; - const amount = BigInt(1000); - - const ix = createCTokenTransferInstruction( - source, - destination, - owner, - amount, - owner, // payer same as owner ); expect(ix.keys.length).toBe(3); + expect(ix.keys[2].pubkey.equals(owner)).toBe(true); + expect(ix.keys[2].isSigner).toBe(true); + expect(ix.keys[2].isWritable).toBe(true); // owner pays for top-ups }); }); @@ -162,7 +145,7 @@ describe('transfer-interface', () => { const ixs = await createLoadAtaInstructions( rpc, ata, - payer.publicKey, + owner.publicKey, mint, ); @@ -201,7 +184,7 @@ describe('transfer-interface', () => { const ixs = await createLoadAtaInstructions( rpc, ata, - payer.publicKey, + owner.publicKey, mint, ); diff --git a/js/compressed-token/tests/unit/mint-action-layout.test.ts b/js/compressed-token/tests/unit/mint-action-layout.test.ts new file mode 100644 index 0000000000..564012e314 --- /dev/null +++ b/js/compressed-token/tests/unit/mint-action-layout.test.ts @@ -0,0 +1,419 @@ +import { describe, it, expect } from 'vitest'; +import { PublicKey, Keypair } from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { + encodeMintActionInstructionData, + decodeMintActionInstructionData, + MintActionCompressedInstructionData, + MINT_ACTION_DISCRIMINATOR, +} from '../../src/v3/layout/layout-mint-action'; +import { encodeCreateMintInstructionData } from '../../src/v3/instructions/create-mint'; +import { TokenDataVersion } from '../../src/constants'; +import { + deriveAddressV2, + CTOKEN_PROGRAM_ID, + getBatchAddressTreeInfo, +} from '@lightprotocol/stateless.js'; +import { findMintAddress } from '../../src/v3/derivation'; + +describe('MintActionCompressedInstructionData Layout', () => { + describe('encode/decode round-trip', () => { + it('should encode and decode createMint instruction data correctly', () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + + // Create data matching what encodeCreateMintInstructionData produces + const instructionData: MintActionCompressedInstructionData = { + leafIndex: 0, + proveByIndex: false, + rootIndex: 42, + compressedAddress: Array.from(new Uint8Array(32).fill(1)), + tokenPoolBump: 0, + tokenPoolIndex: 0, + maxTopUp: 0, + createMint: { + readOnlyAddressTrees: [0, 0, 0, 0], + readOnlyAddressTreeRootIndices: [0, 0, 0, 0], + }, + actions: [], + proof: { + a: Array.from(new Uint8Array(32).fill(2)), + b: Array.from(new Uint8Array(64).fill(3)), + c: Array.from(new Uint8Array(32).fill(4)), + }, + cpiContext: null, + mint: { + supply: BigInt(0), + decimals: 9, + metadata: { + version: TokenDataVersion.ShaFlat, + cmintDecompressed: false, + mint: mintSigner.publicKey, + }, + mintAuthority: mintAuthority.publicKey, + freezeAuthority: null, + extensions: null, + }, + }; + + const encoded = encodeMintActionInstructionData(instructionData); + expect(encoded[0]).toBe(103); // MINT_ACTION_DISCRIMINATOR + + const decoded = decodeMintActionInstructionData(encoded); + + // Verify all fields match + expect(decoded.leafIndex).toBe(instructionData.leafIndex); + expect(decoded.proveByIndex).toBe(instructionData.proveByIndex); + expect(decoded.rootIndex).toBe(instructionData.rootIndex); + expect(decoded.compressedAddress).toEqual( + instructionData.compressedAddress, + ); + expect(decoded.tokenPoolBump).toBe(instructionData.tokenPoolBump); + expect(decoded.tokenPoolIndex).toBe(instructionData.tokenPoolIndex); + expect(decoded.maxTopUp).toBe(instructionData.maxTopUp); + expect(decoded.createMint).toEqual(instructionData.createMint); + expect(decoded.actions).toEqual([]); + expect(decoded.proof).toEqual(instructionData.proof); + expect(decoded.cpiContext).toBeNull(); + expect(decoded.mint).toBeDefined(); + expect(decoded.mint!.decimals).toBe(9); + }); + + it('should encode createMint without proof (null proof)', () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + + const instructionData: MintActionCompressedInstructionData = { + leafIndex: 0, + proveByIndex: false, + rootIndex: 0, + compressedAddress: Array.from(new Uint8Array(32).fill(0)), + tokenPoolBump: 0, + tokenPoolIndex: 0, + maxTopUp: 0, + createMint: { + readOnlyAddressTrees: [0, 0, 0, 0], + readOnlyAddressTreeRootIndices: [0, 0, 0, 0], + }, + actions: [], + proof: null, + cpiContext: null, + mint: { + supply: BigInt(0), + decimals: 6, + metadata: { + version: TokenDataVersion.ShaFlat, + cmintDecompressed: false, + mint: mintSigner.publicKey, + }, + mintAuthority: mintAuthority.publicKey, + freezeAuthority: null, + extensions: null, + }, + }; + + const encoded = encodeMintActionInstructionData(instructionData); + const decoded = decodeMintActionInstructionData(encoded); + + expect(decoded.proof).toBeNull(); + expect(decoded.mint!.decimals).toBe(6); + }); + + it('should encode createMint with freeze authority', () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const freezeAuthority = Keypair.generate(); + + const instructionData: MintActionCompressedInstructionData = { + leafIndex: 0, + proveByIndex: false, + rootIndex: 100, + compressedAddress: Array.from(new Uint8Array(32).fill(5)), + tokenPoolBump: 0, + tokenPoolIndex: 0, + maxTopUp: 0, + createMint: { + readOnlyAddressTrees: [0, 0, 0, 0], + readOnlyAddressTreeRootIndices: [0, 0, 0, 0], + }, + actions: [], + proof: { + a: Array.from(new Uint8Array(32).fill(10)), + b: Array.from(new Uint8Array(64).fill(11)), + c: Array.from(new Uint8Array(32).fill(12)), + }, + cpiContext: null, + mint: { + supply: BigInt(0), + decimals: 9, + metadata: { + version: TokenDataVersion.ShaFlat, + cmintDecompressed: false, + mint: mintSigner.publicKey, + }, + mintAuthority: mintAuthority.publicKey, + freezeAuthority: freezeAuthority.publicKey, + extensions: null, + }, + }; + + const encoded = encodeMintActionInstructionData(instructionData); + const decoded = decodeMintActionInstructionData(encoded); + + expect(decoded.mint!.freezeAuthority).toBeDefined(); + expect(decoded.mint!.freezeAuthority!.toBase58()).toBe( + freezeAuthority.publicKey.toBase58(), + ); + }); + + it('should encode createMint with token metadata extension', () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + + const instructionData: MintActionCompressedInstructionData = { + leafIndex: 0, + proveByIndex: false, + rootIndex: 50, + compressedAddress: Array.from(new Uint8Array(32).fill(6)), + tokenPoolBump: 0, + tokenPoolIndex: 0, + maxTopUp: 0, + createMint: { + readOnlyAddressTrees: [0, 0, 0, 0], + readOnlyAddressTreeRootIndices: [0, 0, 0, 0], + }, + actions: [], + proof: { + a: Array.from(new Uint8Array(32).fill(20)), + b: Array.from(new Uint8Array(64).fill(21)), + c: Array.from(new Uint8Array(32).fill(22)), + }, + cpiContext: null, + mint: { + supply: BigInt(0), + decimals: 9, + metadata: { + version: TokenDataVersion.ShaFlat, + cmintDecompressed: false, + mint: mintSigner.publicKey, + }, + mintAuthority: mintAuthority.publicKey, + freezeAuthority: null, + extensions: [ + { + tokenMetadata: { + updateAuthority: mintAuthority.publicKey, + name: Buffer.from('Test Token'), + symbol: Buffer.from('TEST'), + uri: Buffer.from( + 'https://test.com/metadata.json', + ), + additionalMetadata: null, + }, + }, + ], + }, + }; + + const encoded = encodeMintActionInstructionData(instructionData); + const decoded = decodeMintActionInstructionData(encoded); + + expect(decoded.mint!.extensions).toBeDefined(); + expect(decoded.mint!.extensions!.length).toBe(1); + }); + }); + + describe('byte layout verification', () => { + it('should have correct discriminator', () => { + expect(MINT_ACTION_DISCRIMINATOR).toEqual(Buffer.from([103])); + }); + + it('should produce consistent byte output', () => { + const mintSigner = PublicKey.default; + const mintAuthority = Keypair.generate().publicKey; + + const instructionData: MintActionCompressedInstructionData = { + leafIndex: 0, + proveByIndex: false, + rootIndex: 0, + compressedAddress: Array(32).fill(0), + tokenPoolBump: 0, + tokenPoolIndex: 0, + maxTopUp: 0, + createMint: { + readOnlyAddressTrees: [0, 0, 0, 0], + readOnlyAddressTreeRootIndices: [0, 0, 0, 0], + }, + actions: [], + proof: null, + cpiContext: null, + mint: { + supply: BigInt(0), + decimals: 9, + metadata: { + version: 0, + cmintDecompressed: false, + mint: mintSigner, + }, + mintAuthority: mintAuthority, + freezeAuthority: null, + extensions: null, + }, + }; + + const encoded1 = encodeMintActionInstructionData(instructionData); + const encoded2 = encodeMintActionInstructionData(instructionData); + + // Should be deterministic + expect(encoded1).toEqual(encoded2); + + // Log hex for debugging + console.log( + 'Encoded bytes (hex):', + encoded1.toString('hex').slice(0, 200) + '...', + ); + console.log('Total encoded length:', encoded1.length); + + // First byte should be discriminator 103 + expect(encoded1[0]).toBe(103); + + // Next 4 bytes should be leafIndex (0 as u32 little-endian) + expect(encoded1.slice(1, 5)).toEqual(Buffer.from([0, 0, 0, 0])); + + // Next byte should be proveByIndex (false = 0) + expect(encoded1[5]).toBe(0); + + // Next 2 bytes should be rootIndex (0 as u16 little-endian) + expect(encoded1.slice(6, 8)).toEqual(Buffer.from([0, 0])); + + // Next 32 bytes should be compressedAddress (all zeros) + expect(encoded1.slice(8, 40)).toEqual(Buffer.alloc(32, 0)); + + // tokenPoolBump at byte 40 + expect(encoded1[40]).toBe(0); + + // tokenPoolIndex at byte 41 + expect(encoded1[41]).toBe(0); + + // maxTopUp at bytes 42-43 (u16 little-endian) + expect(encoded1.slice(42, 44)).toEqual(Buffer.from([0, 0])); + + // createMint Option: byte 44 should be 1 (Some) + expect(encoded1[44]).toBe(1); + + // createMint.readOnlyAddressTrees: bytes 45-48 + expect(encoded1.slice(45, 49)).toEqual(Buffer.from([0, 0, 0, 0])); + + // createMint.readOnlyAddressTreeRootIndices: bytes 49-56 (4 x u16) + expect(encoded1.slice(49, 57)).toEqual( + Buffer.from([0, 0, 0, 0, 0, 0, 0, 0]), + ); + }); + }); + + describe('encodeCreateMintInstructionData (integration)', () => { + it('should correctly encode create mint instruction data from params', () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const addressTreeInfo = getBatchAddressTreeInfo(); + + // Encode using the actual instruction builder + const encoded = encodeCreateMintInstructionData({ + mintSigner: mintSigner.publicKey, + mintAuthority: mintAuthority.publicKey, + freezeAuthority: null, + decimals: 9, + addressTree: addressTreeInfo.tree, + outputQueue: addressTreeInfo.queue, + rootIndex: 42, + proof: { + a: Array.from(new Uint8Array(32).fill(1)), + b: Array.from(new Uint8Array(64).fill(2)), + c: Array.from(new Uint8Array(32).fill(3)), + }, + }); + + // Discriminator check + expect(encoded[0]).toBe(103); + + // Should be decodable + const decoded = decodeMintActionInstructionData(encoded); + expect(decoded.leafIndex).toBe(0); + expect(decoded.proveByIndex).toBe(false); + expect(decoded.rootIndex).toBe(42); + expect(decoded.createMint).not.toBeNull(); + expect(decoded.mint).not.toBeNull(); + expect(decoded.mint!.decimals).toBe(9); + }); + + it('should correctly derive compressed mint address', () => { + const mintSigner = Keypair.generate(); + const addressTreeInfo = getBatchAddressTreeInfo(); + + // Get the mint PDA + const [mintPda] = findMintAddress(mintSigner.publicKey); + + // Derive the compressed address the same way createMintInterface does + const compressedAddress = deriveAddressV2( + mintPda.toBytes(), + addressTreeInfo.tree, + CTOKEN_PROGRAM_ID, + ); + + // Verify it's a valid 32-byte address + expect(compressedAddress.toBytes().length).toBe(32); + }); + + it('should encode create mint with null proof', () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const addressTreeInfo = getBatchAddressTreeInfo(); + + const encoded = encodeCreateMintInstructionData({ + mintSigner: mintSigner.publicKey, + mintAuthority: mintAuthority.publicKey, + freezeAuthority: null, + decimals: 6, + addressTree: addressTreeInfo.tree, + outputQueue: addressTreeInfo.queue, + rootIndex: 0, + proof: null, + }); + + const decoded = decodeMintActionInstructionData(encoded); + expect(decoded.proof).toBeNull(); + }); + + it('should encode create mint with metadata', () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const addressTreeInfo = getBatchAddressTreeInfo(); + + const encoded = encodeCreateMintInstructionData({ + mintSigner: mintSigner.publicKey, + mintAuthority: mintAuthority.publicKey, + freezeAuthority: null, + decimals: 9, + addressTree: addressTreeInfo.tree, + outputQueue: addressTreeInfo.queue, + rootIndex: 100, + proof: { + a: Array.from(new Uint8Array(32).fill(10)), + b: Array.from(new Uint8Array(64).fill(20)), + c: Array.from(new Uint8Array(32).fill(30)), + }, + metadata: { + name: 'Test Token', + symbol: 'TEST', + uri: 'https://test.com/metadata.json', + updateAuthority: mintAuthority.publicKey, + additionalMetadata: null, + }, + }); + + const decoded = decodeMintActionInstructionData(encoded); + expect(decoded.mint!.extensions).not.toBeNull(); + expect(decoded.mint!.extensions!.length).toBe(1); + }); + }); +}); diff --git a/js/compressed-token/tests/unit/unified-guards.test.ts b/js/compressed-token/tests/unit/unified-guards.test.ts index da122aca45..76fb19a81d 100644 --- a/js/compressed-token/tests/unit/unified-guards.test.ts +++ b/js/compressed-token/tests/unit/unified-guards.test.ts @@ -60,7 +60,7 @@ describe('unified guards', () => { await expect( unifiedCreateLoadAtaInstructions(rpc, wrongAta, owner, mint, owner), ).rejects.toThrow( - 'Unified loadAta expects ATA to be derived from c-token program. Derive it with getAssociatedTokenAddressInterface.', + 'For wrap=true, ata must be the c-token ATA. Got spl ATA instead.', ); }); }); diff --git a/js/compressed-token/vitest.config.ts b/js/compressed-token/vitest.config.ts index 3bbbf1ad7c..7f620c20b8 100644 --- a/js/compressed-token/vitest.config.ts +++ b/js/compressed-token/vitest.config.ts @@ -9,6 +9,9 @@ export default defineConfig({ : ['src/**/__tests__/*.test.ts', 'tests/**/*.test.ts'], includeSource: ['src/**/*.{js,ts}'], exclude: ['src/program.ts'], + // e2e tests share a single local validator instance; running files in parallel can + // overflow on-chain queues and lead to nondeterministic ProgramError failures. + fileParallelism: false, testTimeout: 350000, hookTimeout: 100000, reporters: ['verbose'], diff --git a/js/stateless.js/package.json b/js/stateless.js/package.json index 5cfdb89e4f..a227609a5a 100644 --- a/js/stateless.js/package.json +++ b/js/stateless.js/package.json @@ -1,6 +1,6 @@ { "name": "@lightprotocol/stateless.js", - "version": "0.22.1-alpha.1", + "version": "0.22.1-alpha.4", "description": "JavaScript API for Light & ZK Compression", "sideEffects": false, "main": "dist/cjs/node/index.cjs", @@ -103,7 +103,7 @@ "test:e2e:rpc-interop": "pnpm test-validator && vitest run tests/e2e/rpc-interop.test.ts --reporter=verbose --bail=1", "test:e2e:rpc-multi-trees": "pnpm test-validator && vitest run tests/e2e/rpc-multi-trees.test.ts --reporter=verbose --bail=1", "test:e2e:browser": "pnpm playwright test", - "test:e2e:all": "pnpm test-validator && vitest run tests/e2e/test-rpc.test.ts && vitest run tests/e2e/compress.test.ts && vitest run tests/e2e/transfer.test.ts && vitest run tests/e2e/rpc-interop.test.ts && pnpm test-validator-skip-prover && vitest run tests/e2e/rpc-multi-trees.test.ts && vitest run tests/e2e/layout.test.ts && vitest run tests/e2e/safe-conversion.test.ts", + "test:e2e:all": "pnpm test-validator && vitest run tests/e2e/test-rpc.test.ts && vitest run tests/e2e/compress.test.ts && vitest run tests/e2e/transfer.test.ts && vitest run tests/e2e/rpc-interop.test.ts && vitest run tests/e2e/interface-methods.test.ts && pnpm test-validator-skip-prover && vitest run tests/e2e/rpc-multi-trees.test.ts && vitest run tests/e2e/layout.test.ts && vitest run tests/e2e/safe-conversion.test.ts", "test:index": "vitest run tests/e2e/program.test.ts", "test:e2e:layout": "vitest run tests/e2e/layout.test.ts --reporter=verbose", "test:e2e:safe-conversion": "vitest run tests/e2e/safe-conversion.test.ts --reporter=verbose", diff --git a/js/stateless.js/src/constants.ts b/js/stateless.js/src/constants.ts index 1b26e9c243..cd73f8be24 100644 --- a/js/stateless.js/src/constants.ts +++ b/js/stateless.js/src/constants.ts @@ -27,8 +27,8 @@ export const featureFlags = { ) { return process.env.LIGHT_PROTOCOL_VERSION as VERSION; } - // Default to V1 - return VERSION.V1; + // Default to V2 + return VERSION.V2; })(), isV2: () => featureFlags.version.replace(/['"]/g, '').toUpperCase() === 'V2', @@ -145,79 +145,6 @@ export const isLocalTest = (url: string) => { return url.includes('localhost') || url.includes('127.0.0.1'); }; -/** - * @internal - */ -export const localTestActiveStateTreeInfos = (): TreeInfo[] => { - return [ - { - tree: new PublicKey(merkletreePubkey), - queue: new PublicKey(nullifierQueuePubkey), - cpiContext: new PublicKey(cpiContextPubkey), - treeType: TreeType.StateV1, - nextTreeInfo: null, - }, - { - tree: new PublicKey(merkleTree2Pubkey), - queue: new PublicKey(nullifierQueue2Pubkey), - cpiContext: new PublicKey(cpiContext2Pubkey), - treeType: TreeType.StateV1, - nextTreeInfo: null, - }, - { - tree: new PublicKey(batchMerkleTree1), - queue: new PublicKey(batchQueue1), - cpiContext: new PublicKey(batchCpiContext1), - treeType: TreeType.StateV2, - nextTreeInfo: null, - }, - { - tree: new PublicKey(batchMerkleTree2), - queue: new PublicKey(batchQueue2), - cpiContext: new PublicKey(batchCpiContext2), - treeType: TreeType.StateV2, - nextTreeInfo: null, - }, - { - tree: new PublicKey(batchMerkleTree3), - queue: new PublicKey(batchQueue3), - cpiContext: new PublicKey(batchCpiContext3), - treeType: TreeType.StateV2, - nextTreeInfo: null, - }, - { - tree: new PublicKey(batchMerkleTree4), - queue: new PublicKey(batchQueue4), - cpiContext: new PublicKey(batchCpiContext4), - treeType: TreeType.StateV2, - nextTreeInfo: null, - }, - { - tree: new PublicKey(batchMerkleTree5), - queue: new PublicKey(batchQueue5), - cpiContext: new PublicKey(batchCpiContext5), - treeType: TreeType.StateV2, - nextTreeInfo: null, - }, - { - tree: new PublicKey(batchAddressTree), - queue: new PublicKey(batchAddressTree), // v2 address queue is part of the tree account. - cpiContext: PublicKey.default, - treeType: TreeType.AddressV2, - nextTreeInfo: null, - }, - { - tree: new PublicKey(testBatchAddressTree), - queue: new PublicKey(testBatchAddressTree), // v2 address queue is part of the tree account. - cpiContext: PublicKey.default, - treeType: TreeType.AddressV2, - nextTreeInfo: null, - }, - ].filter(info => - featureFlags.isV2() ? true : info.treeType === TreeType.StateV1, - ); -}; - export const getDefaultAddressSpace = () => { return getBatchAddressTreeInfo(); }; @@ -299,7 +226,7 @@ export const nullifierQueue2Pubkey = 'nfq2hgS7NYemXsFaFUCe3EMXSDSfnZnAe27jC6aPP1X'; export const cpiContext2Pubkey = 'cpi2cdhkH5roePvcudTgUL8ppEBfTay1desGh8G8QxK'; -// V2 testing - State Trees (5 triples) +// V2 export const batchMerkleTree1 = 'bmt1LryLZUMmF7ZtqESaw7wifBXLfXHQYoE4GAmrahU'; export const batchQueue1 = 'oq1na8gojfdUhsfCpyjNt6h4JaDWtHf1yQj4koBWfto'; export const batchCpiContext1 = 'cpi15BoVPKgEPw5o8wc2T816GE7b378nMXnhH3Xbq4y'; @@ -321,14 +248,66 @@ export const batchQueue5 = 'oq5oh5ZR3yGomuQgFduNDzjtGvVWfDRGLuDVjv9a96P'; export const batchCpiContext5 = 'cpi5ZTjdgYpZ1Xr7B1cMLLUE81oTtJbNNAyKary2nV6'; // V2 Address Trees -export const batchAddressTree = 'amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx'; // v2 address tree (queue is part of the tree account) -export const testBatchAddressTree = - 'EzKE84aVTkCUhDHLELqyJaq1Y7UVVmqxXqZjVHwHY3rK'; // v2 address tree (queue is part of the tree account) +export const batchAddressTree = 'amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx'; // Deprecated: Use batchMerkleTree1, batchQueue1, batchCpiContext1 instead export const batchMerkleTree = batchMerkleTree1; export const batchQueue = batchQueue1; +/** + * @internal + * Returns local test tree infos. + * V1: 2 state trees (smt/nfq/cpi pairs) + * V2: 5 batched state trees (bmt/oq/cpi triplets) + 1 address tree (amt2) + */ +export const localTestActiveStateTreeInfos = (): TreeInfo[] => { + // V1 State Trees: [tree, queue, cpi] + const V1_STATE_TREES: [string, string, string][] = [ + [merkletreePubkey, nullifierQueuePubkey, cpiContextPubkey], // smt1, nfq1, cpi1 + [merkleTree2Pubkey, nullifierQueue2Pubkey, cpiContext2Pubkey], // smt2, nfq2, cpi2 + ]; + + // V2 State Trees (batched): [bmt, oq, cpi] triplets + const V2_STATE_TREES: [string, string, string][] = [ + [batchMerkleTree1, batchQueue1, batchCpiContext1], // bmt1, oq1, cpi1 + [batchMerkleTree2, batchQueue2, batchCpiContext2], // bmt2, oq2, cpi2 + [batchMerkleTree3, batchQueue3, batchCpiContext3], // bmt3, oq3, cpi3 + [batchMerkleTree4, batchQueue4, batchCpiContext4], // bmt4, oq4, cpi4 + [batchMerkleTree5, batchQueue5, batchCpiContext5], // bmt5, oq5, cpi5 + ]; + + const V2_ADDRESS_TREE = batchAddressTree; // amt2 + + const v1Trees: TreeInfo[] = V1_STATE_TREES.map(([tree, queue, cpi]) => ({ + tree: new PublicKey(tree), + queue: new PublicKey(queue), + cpiContext: new PublicKey(cpi), + treeType: TreeType.StateV1, + nextTreeInfo: null, + })); + + const v2Trees: TreeInfo[] = V2_STATE_TREES.map(([tree, queue, cpi]) => ({ + tree: new PublicKey(tree), + queue: new PublicKey(queue), + cpiContext: new PublicKey(cpi), + treeType: TreeType.StateV2, + nextTreeInfo: null, + })); + + const v2AddressTree: TreeInfo = { + tree: new PublicKey(V2_ADDRESS_TREE), + queue: new PublicKey(V2_ADDRESS_TREE), // queue is part of the tree account + cpiContext: PublicKey.default, + treeType: TreeType.AddressV2, + nextTreeInfo: null, + }; + + if (featureFlags.isV2()) { + return [...v1Trees, ...v2Trees, v2AddressTree]; + } + return v1Trees; +}; + export const confirmConfig: ConfirmOptions = { commitment: 'confirmed', preflightCommitment: 'confirmed', diff --git a/js/stateless.js/src/rpc.ts b/js/stateless.js/src/rpc.ts index 0acbb0b1bd..ee25b9d862 100644 --- a/js/stateless.js/src/rpc.ts +++ b/js/stateless.js/src/rpc.ts @@ -715,6 +715,7 @@ export class Rpc extends Connection implements CompressionApiInterface { /** * Get a list of all state tree infos. If not already cached, fetches from * the cluster. + * if featureFlags.isV2(), returns v2 trees too. */ async getStateTreeInfos(): Promise { if (isLocalTest(this.rpcEndpoint)) { diff --git a/js/stateless.js/src/test-helpers/test-rpc/get-parsed-events.ts b/js/stateless.js/src/test-helpers/test-rpc/get-parsed-events.ts index 34b19af7da..4bdf35d25f 100644 --- a/js/stateless.js/src/test-helpers/test-rpc/get-parsed-events.ts +++ b/js/stateless.js/src/test-helpers/test-rpc/get-parsed-events.ts @@ -49,10 +49,22 @@ export async function getParsedEvents( 'confirmed', ) ).map(s => s.signature); - const txs = await rpc.getParsedTransactions(signatures, { - maxSupportedTransactionVersion: 0, - commitment: 'confirmed', - }); + const txs: (ParsedTransactionWithMeta | null)[] = []; + + // `getParsedTransactions` uses a JSON-RPC batch request under the hood. + // On some RPC servers (including local validators with strict limits), + // batching too many signatures can exceed the max request body size (413). + const maxSupportedTransactionVersion = 0; + const commitment = 'confirmed' as const; + const chunkSize = 100; + for (let i = 0; i < signatures.length; i += chunkSize) { + const chunk = signatures.slice(i, i + chunkSize); + const chunkTxs = await rpc.getParsedTransactions(chunk, { + maxSupportedTransactionVersion, + commitment, + }); + txs.push(...chunkTxs); + } for (const txParsed of txs) { if (!txParsed || !txParsed.transaction || !txParsed.meta) continue; diff --git a/js/stateless.js/src/utils/get-state-tree-infos.ts b/js/stateless.js/src/utils/get-state-tree-infos.ts index 8c13115de2..f163ecf598 100644 --- a/js/stateless.js/src/utils/get-state-tree-infos.ts +++ b/js/stateless.js/src/utils/get-state-tree-infos.ts @@ -194,6 +194,22 @@ export async function getAllStateTreeInfos({ if (!tree || !queue || !cpiContext) { throw new Error('Invalid state tree pubkeys structure'); } + // Detect tree type based on tree address prefix + // - bmt = batch merkle tree (StateV2) + // - amt = address merkle tree (AddressV2 if starts with amt2, AddressV1 if amt1) + // - smt = sparse merkle tree (StateV1) + const treeStr = tree.toBase58(); + let treeType: TreeType; + if (treeStr.startsWith('bmt')) { + treeType = TreeType.StateV2; + } else if (treeStr.startsWith('amt2')) { + treeType = TreeType.AddressV2; + } else if (treeStr.startsWith('amt')) { + treeType = TreeType.AddressV1; + } else { + treeType = TreeType.StateV1; + } + if ( nullifyLookupTablePubkeys .map(addr => addr.toBase58()) @@ -204,7 +220,7 @@ export async function getAllStateTreeInfos({ tree: PublicKey.default, queue: PublicKey.default, cpiContext: PublicKey.default, - treeType: TreeType.StateV1, + treeType, nextTreeInfo: null, }; } @@ -212,7 +228,7 @@ export async function getAllStateTreeInfos({ tree, queue, cpiContext, - treeType: TreeType.StateV1, + treeType, nextTreeInfo, }); } diff --git a/js/stateless.js/tests/unit/utils/tree-info.test.ts b/js/stateless.js/tests/unit/utils/tree-info.test.ts index 672f0d54a1..38f1bf03d3 100644 --- a/js/stateless.js/tests/unit/utils/tree-info.test.ts +++ b/js/stateless.js/tests/unit/utils/tree-info.test.ts @@ -9,6 +9,7 @@ import { merkletreePubkey, nullifierQueue2Pubkey, nullifierQueuePubkey, + localTestActiveStateTreeInfos, } from '../../../src'; describe('selectStateTreeInfo', () => { @@ -126,4 +127,45 @@ describe('selectStateTreeInfo', () => { 'Queue must not be null for state tree', ); }); + + it('should correctly set tree types for BMT (V2) trees', () => { + const allTrees = localTestActiveStateTreeInfos(); + + // Check BMT trees have StateV2 type + const bmtTrees = allTrees.filter((t: TreeInfo) => + t.tree.toBase58().startsWith('bmt'), + ); + expect(bmtTrees.length).toBeGreaterThan(0); + + for (const tree of bmtTrees) { + expect(tree.treeType).toBe(TreeType.StateV2); + expect(tree.treeType).not.toBe(TreeType.StateV1); + // Verify the numeric value is 3 (StateV2) + expect(tree.treeType).toBe(3); + } + + // Check SMT trees have StateV1 type + const smtTrees = allTrees.filter((t: TreeInfo) => + t.tree.toBase58().startsWith('smt'), + ); + expect(smtTrees.length).toBeGreaterThan(0); + + for (const tree of smtTrees) { + expect(tree.treeType).toBe(TreeType.StateV1); + // Verify the numeric value is 1 (StateV1) + expect(tree.treeType).toBe(1); + } + + // Check V2 address tree has AddressV2 type + const amt2Trees = allTrees.filter((t: TreeInfo) => + t.tree.toBase58().startsWith('amt2'), + ); + expect(amt2Trees.length).toBe(1); + + for (const tree of amt2Trees) { + expect(tree.treeType).toBe(TreeType.AddressV2); + // Verify the numeric value is 4 (AddressV2) + expect(tree.treeType).toBe(4); + } + }); }); diff --git a/scripts/devenv/versions.sh b/scripts/devenv/versions.sh index 394405759e..e4deec350d 100755 --- a/scripts/devenv/versions.sh +++ b/scripts/devenv/versions.sh @@ -13,7 +13,8 @@ export SOLANA_VERSION="2.2.15" export ANCHOR_VERSION="0.31.1" export JQ_VERSION="1.8.0" export PHOTON_VERSION="0.51.2" -export PHOTON_COMMIT="3dbfb8e6772779fc89c640b5b0823b95d1958efc" +# current main (ci fails): 3dbfb8e6772779fc89c640b5b0823b95d1958efc +export PHOTON_COMMIT="ac7df6c388db847b7693a7a1cb766a7c9d7809b5" export REDIS_VERSION="8.0.1" export ANCHOR_TAG="anchor-v${ANCHOR_VERSION}"