diff --git a/.gitignore b/.gitignore index f2564480b7..17c82f39d6 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,5 @@ output1.txt expand.rs output.rs + +**/light-token.md \ No newline at end of file diff --git a/cli/package.json b/cli/package.json index 88c9ec237d..824e3ef12e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@lightprotocol/zk-compression-cli", - "version": "0.27.1-alpha.11", + "version": "0.28.0-beta.3", "description": "ZK Compression: Secure Scaling on Solana", "maintainers": [ { diff --git a/cli/src/utils/initTestEnv.ts b/cli/src/utils/initTestEnv.ts index 63d42a0da0..c98c8c29bb 100644 --- a/cli/src/utils/initTestEnv.ts +++ b/cli/src/utils/initTestEnv.ts @@ -13,8 +13,8 @@ import { downloadBinIfNotExists } from "../psp-utils"; import { confirmRpcReadiness, confirmServerStability, - executeCommand, killProcess, + spawnBinary, waitForServers, } from "./process"; import { killProver, startProver } from "./processProverServer"; @@ -456,10 +456,11 @@ export async function startTestValidator({ solanaArgs.push(...validatorArgs.split(" ")); } console.log("Starting test validator..."); - await executeCommand({ - command, - args: [...solanaArgs], - }); + // Use spawnBinary instead of executeCommand to properly detach the process. + // This ensures the validator survives when the CLI exits (executeCommand uses + // piped stdio which causes SIGPIPE when parent exits). + // Pass process.env directly to maintain same env behavior as before. + spawnBinary(command, solanaArgs, process.env); } export async function killTestValidator() { diff --git a/cli/src/utils/process.ts b/cli/src/utils/process.ts index e3d1a241f5..14d555beea 100644 --- a/cli/src/utils/process.ts +++ b/cli/src/utils/process.ts @@ -194,7 +194,11 @@ export async function execute(command: string): Promise { } } -export function spawnBinary(command: string, args: string[] = []) { +export function spawnBinary( + command: string, + args: string[] = [], + env?: NodeJS.ProcessEnv, +) { const logDir = "test-ledger"; const binaryName = path.basename(command); @@ -212,7 +216,7 @@ export function spawnBinary(command: string, args: string[] = []) { stdio: ["ignore", out, err], shell: false, detached: true, - env: { + env: env ?? { ...process.env, RUST_LOG: process.env.RUST_LOG || "debug", }, diff --git a/js/compressed-token/package.json b/js/compressed-token/package.json index bf05c21e48..a4555cecb8 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.9", + "version": "0.23.0-beta.3", "description": "JS client to interact with the compressed-token program", "sideEffects": false, "main": "dist/cjs/node/index.cjs", @@ -92,8 +92,8 @@ "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:ctoken:all", - "test:v2:ctoken": "pnpm build:v2 && LIGHT_PROTOCOL_VERSION=V2 pnpm test:e2e:ctoken:all", + "test:v2": "pnpm build:v2 && LIGHT_PROTOCOL_VERSION=V2 vitest run tests/unit && LIGHT_PROTOCOL_VERSION=V2 LIGHT_PROTOCOL_BETA=true pnpm test:e2e:ctoken:all", + "test:v2:ctoken": "pnpm build:v2 && LIGHT_PROTOCOL_VERSION=V2 LIGHT_PROTOCOL_BETA=true pnpm test:e2e:ctoken:all", "test-all": "vitest run", "test:unit:all": "EXCLUDE_E2E=true vitest run", "test:unit:all:v1": "LIGHT_PROTOCOL_VERSION=V1 vitest run tests/unit --reporter=verbose", @@ -106,7 +106,7 @@ "test:e2e:create-associated-ctoken": "pnpm test-validator && vitest run tests/e2e/create-associated-ctoken.test.ts --reporter=verbose", "test:e2e:mint-to-ctoken": "pnpm test-validator && vitest run tests/e2e/mint-to-ctoken.test.ts --reporter=verbose", "test:e2e:mint-to-compressed": "pnpm test-validator && vitest run tests/e2e/mint-to-compressed.test.ts --reporter=verbose", - "test:e2e:mint-to-interface": "pnpm test-validator && vitest run tests/e2e/mint-to-interface.test.ts --reporter=verbose", + "test:e2e:mint-to-interface": "pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/mint-to-interface.test.ts --reporter=verbose", "test:e2e:mint-workflow": "pnpm test-validator && vitest run tests/e2e/mint-workflow.test.ts --reporter=verbose", "test:e2e:update-mint": "pnpm test-validator && vitest run tests/e2e/update-mint.test.ts --reporter=verbose", "test:e2e:update-metadata": "pnpm test-validator && vitest run tests/e2e/update-metadata.test.ts --reporter=verbose", @@ -124,20 +124,21 @@ "test:e2e:decompress": "pnpm test-validator && vitest run tests/e2e/decompress.test.ts --reporter=verbose", "test:e2e:decompress-delegated": "pnpm test-validator && vitest run tests/e2e/decompress-delegated.test.ts --reporter=verbose", "test:e2e:decompress2": "pnpm test-validator && vitest run tests/e2e/decompress2.test.ts --reporter=verbose", + "test:e2e:v1-v2-migration": "pnpm test-validator && LIGHT_PROTOCOL_VERSION=V2 vitest run tests/e2e/v1-v2-migration.test.ts --reporter=verbose --bail=1", "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 && 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", - "test:e2e:get-account-interface": "pnpm test-validator && vitest run tests/e2e/get-account-interface.test.ts --reporter=verbose", - "test:e2e:load-ata-standard": "pnpm test-validator && vitest run tests/e2e/load-ata-standard.test.ts --reporter=verbose", - "test:e2e:load-ata-unified": "pnpm test-validator && vitest run tests/e2e/load-ata-unified.test.ts --reporter=verbose", - "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 && 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:wrap": "pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/wrap.test.ts --reporter=verbose", + "test:e2e:get-mint-interface": "pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/get-mint-interface.test.ts --reporter=verbose", + "test:e2e:get-or-create-ata-interface": "pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/get-or-create-ata-interface.test.ts --reporter=verbose", + "test:e2e:get-account-interface": "pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/get-account-interface.test.ts --reporter=verbose", + "test:e2e:load-ata-standard": "pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/load-ata-standard.test.ts --reporter=verbose", + "test:e2e:load-ata-unified": "pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/load-ata-unified.test.ts --reporter=verbose", + "test:e2e:load-ata-combined": "pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/load-ata-combined.test.ts --reporter=verbose", + "test:e2e:load-ata-spl-t22": "pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/load-ata-spl-t22.test.ts --reporter=verbose", + "test:e2e:load-ata:all": "pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/load-ata-standard.test.ts --bail=1 && pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/load-ata-unified.test.ts --bail=1 && pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/load-ata-combined.test.ts --bail=1 && pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/load-ata-spl-t22.test.ts --bail=1", + "test:e2e:ctoken:all": "pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/create-compressed-mint.test.ts --bail=1 && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/create-associated-ctoken.test.ts --bail=1 && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/mint-to-ctoken.test.ts --bail=1 && pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/mint-to-compressed.test.ts --bail=1 && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/mint-to-interface.test.ts --bail=1 && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/mint-workflow.test.ts --bail=1 && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/update-mint.test.ts --bail=1 && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/update-metadata.test.ts --bail=1 && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/compressible-load.test.ts --bail=1 && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/wrap.test.ts --bail=1 && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/get-mint-interface.test.ts --bail=1 && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/get-account-interface.test.ts --bail=1 && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/create-mint-interface.test.ts --bail=1 && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/create-ata-interface.test.ts --bail=1 && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/get-or-create-ata-interface.test.ts --bail=1 && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/transfer-interface.test.ts --bail=1 && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/unwrap.test.ts --bail=1 && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/decompress2.test.ts --bail=1 && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/payment-flows.test.ts --bail=1 && vitest run tests/e2e/v1-v2-migration.test.ts --bail=1 && pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/load-ata-standard.test.ts --bail=1 && pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/load-ata-unified.test.ts --bail=1 && pnpm test-validator && LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/load-ata-combined.test.ts --bail=1 && pnpm test-validator && LIGHT_PROTOCOL_BETA=true 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", diff --git a/js/compressed-token/src/actions/approve.ts b/js/compressed-token/src/actions/approve.ts index e11776f483..19021e0a83 100644 --- a/js/compressed-token/src/actions/approve.ts +++ b/js/compressed-token/src/actions/approve.ts @@ -15,8 +15,8 @@ import { import BN from 'bn.js'; import { CompressedTokenProgram } from '../program'; import { - selectMinCompressedTokenAccountsForTransfer, selectTokenAccountsForApprove, + selectAccountsByPreferredTreeType, } from '../utils'; /** @@ -49,11 +49,17 @@ export async function approve( }, ); - const [inputAccounts] = selectTokenAccountsForApprove( + // Select accounts from preferred tree type (V2 in V2 mode) with fallback + const { accounts: accountsToUse } = selectAccountsByPreferredTreeType( compressedTokenAccounts.items, amount, ); + const [inputAccounts] = selectTokenAccountsForApprove( + accountsToUse, + amount, + ); + const proof = await rpc.getValidityProofV0( inputAccounts.map(account => ({ hash: account.compressedAccount.hash, diff --git a/js/compressed-token/src/actions/decompress-delegated.ts b/js/compressed-token/src/actions/decompress-delegated.ts index 35ea8ad93d..82d59e433e 100644 --- a/js/compressed-token/src/actions/decompress-delegated.ts +++ b/js/compressed-token/src/actions/decompress-delegated.ts @@ -16,7 +16,10 @@ import { import BN from 'bn.js'; import { CompressedTokenProgram } from '../program'; -import { selectMinCompressedTokenAccountsForTransfer } from '../utils'; +import { + selectMinCompressedTokenAccountsForTransfer, + selectAccountsByPreferredTreeType, +} from '../utils'; import { selectSplInterfaceInfosForDecompression, SplInterfaceInfo, @@ -56,11 +59,17 @@ export async function decompressDelegated( mint, }); - const [inputAccounts] = selectMinCompressedTokenAccountsForTransfer( + // Select accounts from preferred tree type (V2 in V2 mode) with fallback + const { accounts: accountsToUse } = selectAccountsByPreferredTreeType( compressedTokenAccounts.items, amount, ); + const [inputAccounts] = selectMinCompressedTokenAccountsForTransfer( + accountsToUse, + amount, + ); + const proof = await rpc.getValidityProofV0( inputAccounts.map(account => ({ hash: account.compressedAccount.hash, diff --git a/js/compressed-token/src/actions/decompress.ts b/js/compressed-token/src/actions/decompress.ts index ad4a4c4b79..40e7822b55 100644 --- a/js/compressed-token/src/actions/decompress.ts +++ b/js/compressed-token/src/actions/decompress.ts @@ -14,7 +14,10 @@ import { } from '@lightprotocol/stateless.js'; import BN from 'bn.js'; import { CompressedTokenProgram } from '../program'; -import { selectMinCompressedTokenAccountsForTransfer } from '../utils'; +import { + selectMinCompressedTokenAccountsForTransfer, + selectAccountsByPreferredTreeType, +} from '../utils'; import { selectSplInterfaceInfosForDecompression, SplInterfaceInfo, @@ -50,16 +53,20 @@ export async function decompress( const compressedTokenAccounts = await rpc.getCompressedTokenAccountsByOwner( owner.publicKey, - { - mint, - }, + { mint }, ); - const [inputAccounts] = selectMinCompressedTokenAccountsForTransfer( + // Select accounts from preferred tree type (V2 in V2 mode) with fallback + const { accounts: accountsToUse } = selectAccountsByPreferredTreeType( compressedTokenAccounts.items, amount, ); + const [inputAccounts] = selectMinCompressedTokenAccountsForTransfer( + accountsToUse, + amount, + ); + const proof = await rpc.getValidityProofV0( inputAccounts.map(account => ({ hash: account.compressedAccount.hash, diff --git a/js/compressed-token/src/actions/merge-token-accounts.ts b/js/compressed-token/src/actions/merge-token-accounts.ts index a38766a626..c22de0f9b1 100644 --- a/js/compressed-token/src/actions/merge-token-accounts.ts +++ b/js/compressed-token/src/actions/merge-token-accounts.ts @@ -11,8 +11,10 @@ import { buildAndSignTx, sendAndConfirmTx, bn, + TreeType, } from '@lightprotocol/stateless.js'; import { CompressedTokenProgram } from '../program'; +import { selectAccountsByPreferredTreeType } from '../utils'; /** * Max input accounts per merge. @@ -24,9 +26,13 @@ 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. + * accounts. Each call merges up to 4 accounts at a time. + * + * Supports automatic V1 -> V2 migration: when running in V2 mode, + * merging V1 token accounts will produce a V2 output. + * + * IMPORTANT: Only accounts from the same tree type can be merged in one + * transaction. If you have mixed V1+V2 accounts, merge them separately. * * @param rpc RPC connection to use * @param payer Fee payer @@ -58,13 +64,52 @@ export async function mergeTokenAccounts( throw new Error('Only one token account exists, nothing to merge'); } + // Select accounts from preferred tree type (V2 in V2 mode) - for merge need at least 2 + const { accounts: preferredAccounts, treeType: preferredTreeType } = + selectAccountsByPreferredTreeType(compressedTokenAccounts.items); + + let selectedAccounts = preferredAccounts; + let selectedTreeType = preferredTreeType; + + // For merge, need at least 2 accounts of the same type + // If preferred type has < 2, try fallback type + if (selectedAccounts.length < 2) { + const fallbackType = + preferredTreeType === TreeType.StateV2 + ? TreeType.StateV1 + : TreeType.StateV2; + const fallbackAccounts = compressedTokenAccounts.items.filter( + acc => acc.compressedAccount.treeInfo.treeType === fallbackType, + ); + + if (fallbackAccounts.length >= 2) { + selectedAccounts = fallbackAccounts; + selectedTreeType = fallbackType; + } else if ( + selectedAccounts.length === 1 && + fallbackAccounts.length === 1 + ) { + // Have 1 V1 and 1 V2 - can't merge mixed types + throw new Error( + 'Cannot merge accounts from different tree types (V1/V2). ' + + 'You have 1 V1 and 1 V2 account - nothing to merge within same type.', + ); + } else { + throw new Error( + `Not enough accounts of the same tree type to merge. ` + + `Found: ${selectedAccounts.length} ${selectedTreeType === TreeType.StateV1 ? 'V1' : 'V2'} accounts.`, + ); + } + } + // Take up to MAX_MERGE_ACCOUNTS to merge in this transaction - const batch = compressedTokenAccounts.items.slice(0, MAX_MERGE_ACCOUNTS); + const batch = selectedAccounts.slice(0, MAX_MERGE_ACCOUNTS); const proof = await rpc.getValidityProof( batch.map(account => bn(account.compressedAccount.hash)), ); + // V1→V2 migration handled inside CompressedTokenProgram.mergeTokenAccounts const mergeInstructions = await CompressedTokenProgram.mergeTokenAccounts({ payer: payer.publicKey, owner: owner.publicKey, diff --git a/js/compressed-token/src/actions/revoke.ts b/js/compressed-token/src/actions/revoke.ts index 47ac83ef15..fbacc8bf23 100644 --- a/js/compressed-token/src/actions/revoke.ts +++ b/js/compressed-token/src/actions/revoke.ts @@ -12,6 +12,7 @@ import { ParsedTokenAccount, } from '@lightprotocol/stateless.js'; import { CompressedTokenProgram } from '../program'; +import { groupAccountsByTreeType } from '../utils'; /** * Revoke one or more delegated token accounts @@ -19,6 +20,7 @@ import { CompressedTokenProgram } from '../program'; * @param rpc Rpc connection to use * @param payer Fee payer * @param accounts Delegated compressed token accounts to revoke + * (must all be from the same tree type) * @param owner Owner of the compressed tokens * @param confirmOptions Options for confirming the transaction * @@ -31,6 +33,15 @@ export async function revoke( owner: Signer, confirmOptions?: ConfirmOptions, ): Promise { + // Validate all accounts are from the same tree type + const accountsByTreeType = groupAccountsByTreeType(accounts); + if (accountsByTreeType.size > 1) { + throw new Error( + 'Cannot revoke accounts from different tree types (V1/V2) in the same transaction. ' + + 'Revoke V1 and V2 accounts separately.', + ); + } + const proof = await rpc.getValidityProofV0( accounts.map(account => ({ hash: account.compressedAccount.hash, diff --git a/js/compressed-token/src/actions/transfer-delegated.ts b/js/compressed-token/src/actions/transfer-delegated.ts index a788050163..e5c77850a6 100644 --- a/js/compressed-token/src/actions/transfer-delegated.ts +++ b/js/compressed-token/src/actions/transfer-delegated.ts @@ -14,7 +14,10 @@ import { } from '@lightprotocol/stateless.js'; import BN from 'bn.js'; import { CompressedTokenProgram } from '../program'; -import { selectMinCompressedTokenAccountsForTransfer } from '../utils'; +import { + selectMinCompressedTokenAccountsForTransfer, + selectAccountsByPreferredTreeType, +} from '../utils'; /** * Transfer delegated compressed tokens to another owner @@ -44,11 +47,17 @@ export async function transferDelegated( mint, }); - const [inputAccounts] = selectMinCompressedTokenAccountsForTransfer( + // Select accounts from preferred tree type (V2 in V2 mode) with fallback + const { accounts: accountsToUse } = selectAccountsByPreferredTreeType( compressedTokenAccounts.items, amount, ); + const [inputAccounts] = selectMinCompressedTokenAccountsForTransfer( + accountsToUse, + amount, + ); + const proof = await rpc.getValidityProofV0( inputAccounts.map(account => ({ hash: account.compressedAccount.hash, diff --git a/js/compressed-token/src/actions/transfer.ts b/js/compressed-token/src/actions/transfer.ts index e46261a988..fa5138057d 100644 --- a/js/compressed-token/src/actions/transfer.ts +++ b/js/compressed-token/src/actions/transfer.ts @@ -11,15 +11,19 @@ import { buildAndSignTx, Rpc, dedupeSigner, - StateTreeInfo, - selectStateTreeInfo, } from '@lightprotocol/stateless.js'; import BN from 'bn.js'; import { CompressedTokenProgram } from '../program'; -import { selectMinCompressedTokenAccountsForTransfer } from '../utils'; +import { + selectMinCompressedTokenAccountsForTransfer, + selectAccountsByPreferredTreeType, +} from '../utils'; /** - * Transfer compressed tokens from one owner to another + * Transfer compressed tokens from one owner to another. + * + * Supports automatic V1 -> V2 migration: when running in V2 mode, + * V1 token inputs will produce V2 token outputs. * * @param rpc Rpc connection to use * @param payer Fee payer @@ -41,18 +45,23 @@ export async function transfer( confirmOptions?: ConfirmOptions, ): Promise { amount = bn(amount); + const compressedTokenAccounts = await rpc.getCompressedTokenAccountsByOwner( owner.publicKey, - { - mint, - }, + { mint }, ); - const [inputAccounts] = selectMinCompressedTokenAccountsForTransfer( + // Select accounts from preferred tree type (V2 in V2 mode) with fallback + const { accounts: accountsToUse } = selectAccountsByPreferredTreeType( compressedTokenAccounts.items, amount, ); + const [inputAccounts] = selectMinCompressedTokenAccountsForTransfer( + accountsToUse, + amount, + ); + const proof = await rpc.getValidityProofV0( inputAccounts.map(account => ({ hash: account.compressedAccount.hash, @@ -61,6 +70,7 @@ export async function transfer( })), ); + // V1→V2 migration handled inside CompressedTokenProgram.transfer const ix = await CompressedTokenProgram.transfer({ payer: payer.publicKey, inputCompressedTokenAccounts: inputAccounts, diff --git a/js/compressed-token/src/program.ts b/js/compressed-token/src/program.ts index c788912f07..468b49345b 100644 --- a/js/compressed-token/src/program.ts +++ b/js/compressed-token/src/program.ts @@ -24,7 +24,21 @@ import { CompressedProof, featureFlags, TreeType, + batchMerkleTree1, + batchQueue1, + batchCpiContext1, } from '@lightprotocol/stateless.js'; + +/** + * Hardcoded V2 tree for V1→V2 migration in transfer/merge. + */ +const DEFAULT_V2_MIGRATION_TREE: TreeInfo = { + tree: new PublicKey(batchMerkleTree1), + queue: new PublicKey(batchQueue1), + cpiContext: new PublicKey(batchCpiContext1), + treeType: TreeType.StateV2, + nextTreeInfo: null, +}; import { MINT_SIZE, TOKEN_2022_PROGRAM_ID, @@ -1037,7 +1051,9 @@ export class CompressedTokenProgram { } /** - * Construct transfer instruction for compressed tokens + * Construct transfer instruction for compressed tokens. + * + * V1 inputs automatically migrate to V2 outputs when in V2 mode. * * @param payer Fee payer. * @param inputCompressedTokenAccounts Source compressed token accounts. @@ -1063,6 +1079,15 @@ export class CompressedTokenProgram { amount, ); + // Determine output tree: V1 inputs in V2 mode -> hardcoded V2 tree + const inputTreeType = + inputCompressedTokenAccounts[0]?.compressedAccount.treeInfo + .treeType; + const outputStateTreeInfo = + inputTreeType === TreeType.StateV1 && featureFlags.isV2() + ? DEFAULT_V2_MIGRATION_TREE + : undefined; + const { inputTokenDataWithContext, packedOutputTokenData, @@ -1071,6 +1096,7 @@ export class CompressedTokenProgram { inputCompressedTokenAccounts, rootIndices: recentInputStateRootIndices, tokenTransferOutputs, + outputStateTreeInfo, }); const { mint } = parseTokenData(inputCompressedTokenAccounts); @@ -1443,7 +1469,7 @@ export class CompressedTokenProgram { } /** - * Create `mergeTokenAccounts` instruction + * Create `mergeTokenAccounts` instruction. * * @param payer Fee payer. * @param owner Owner of the compressed token @@ -1452,7 +1478,6 @@ export class CompressedTokenProgram { * @param mint SPL Token mint address. * @param recentValidityProof Recent validity proof. * @param recentInputStateRootIndices Recent state root indices. - * * @returns instruction */ static async mergeTokenAccounts({ diff --git a/js/compressed-token/src/utils/pack-compressed-token-accounts.ts b/js/compressed-token/src/utils/pack-compressed-token-accounts.ts index 99719fec1b..35286c66c3 100644 --- a/js/compressed-token/src/utils/pack-compressed-token-accounts.ts +++ b/js/compressed-token/src/utils/pack-compressed-token-accounts.ts @@ -18,9 +18,9 @@ export type PackCompressedTokenAccountsParams = { /** Input state to be consumed */ inputCompressedTokenAccounts: ParsedTokenAccount[]; /** - * State trees that the output should be inserted into. Defaults to the 0th - * state tree of the input state. Gets padded to the length of - * outputCompressedAccounts. + * Output state tree. Required for mint/compress (no inputs). + * For transfer/merge with V1 inputs: pass a V2 tree for migration. + * If not provided with inputs, uses input tree. */ outputStateTreeInfo?: TreeInfo; /** Optional remaining accounts to append to */ @@ -96,26 +96,25 @@ export function packCompressedTokenAccounts( }, ); - if (inputCompressedTokenAccounts.length > 0 && outputStateTreeInfo) { - throw new Error( - 'Cannot specify both input accounts and outputStateTreeInfo', - ); - } + // Determine output tree: + // 1. If outputStateTreeInfo provided, use it (enables V1→V2 migration) + // 2. Otherwise use input tree (requires inputs) + let outputTreeInfo: TreeInfo; - let treeInfo: TreeInfo; - if (inputCompressedTokenAccounts.length > 0) { - treeInfo = inputCompressedTokenAccounts[0].compressedAccount.treeInfo; - } else if (outputStateTreeInfo) { - treeInfo = outputStateTreeInfo; + if (outputStateTreeInfo) { + outputTreeInfo = outputStateTreeInfo; + } else if (inputCompressedTokenAccounts.length > 0) { + outputTreeInfo = + inputCompressedTokenAccounts[0].compressedAccount.treeInfo; } else { throw new Error( - 'Neither input accounts nor outputStateTreeInfo are available', + 'Either input accounts or outputStateTreeInfo must be provided', ); } // Use next tree if available, otherwise fall back to current tree. // `nextTreeInfo` always takes precedence. - const activeTreeInfo = treeInfo.nextTreeInfo || treeInfo; + const activeTreeInfo = outputTreeInfo.nextTreeInfo || outputTreeInfo; let activeTreeOrQueue = activeTreeInfo.tree; if (activeTreeInfo.treeType === TreeType.StateV2) { diff --git a/js/compressed-token/src/utils/select-input-accounts.ts b/js/compressed-token/src/utils/select-input-accounts.ts index 56dd68c156..3d871c930a 100644 --- a/js/compressed-token/src/utils/select-input-accounts.ts +++ b/js/compressed-token/src/utils/select-input-accounts.ts @@ -1,16 +1,164 @@ -import { bn, ParsedTokenAccount } from '@lightprotocol/stateless.js'; +import { + bn, + ParsedTokenAccount, + TreeType, + featureFlags, +} from '@lightprotocol/stateless.js'; import BN from 'bn.js'; export const ERROR_NO_ACCOUNTS_FOUND = 'Could not find accounts to select for transfer.'; +export const ERROR_MIXED_TREE_TYPES = + 'Cannot select accounts from different tree types (V1/V2) in the same batch. Filter accounts by tree type first.'; + +/** + * Options for input account selection + */ +export interface SelectInputAccountsOptions { + /** + * Filter accounts by tree type. If provided, only accounts in trees of + * this type will be selected. This prevents mixed V1/V2 batches which + * fail at proof generation. + */ + treeType?: TreeType; +} + +/** + * Filters accounts by tree type if specified + */ +function filterByTreeType( + accounts: ParsedTokenAccount[], + treeType?: TreeType, +): ParsedTokenAccount[] { + if (!treeType) return accounts; + return accounts.filter( + acc => acc.compressedAccount.treeInfo.treeType === treeType, + ); +} + +/** + * Validates that all selected accounts are from the same tree type. + * Throws if mixed tree types are detected. + * Silently skips validation if accounts don't have treeInfo (e.g. mock accounts). + */ +function validateSameTreeType(accounts: ParsedTokenAccount[]): void { + if (accounts.length <= 1) return; + + // Skip validation if treeInfo is not available (mock accounts) + const accountsWithTreeInfo = accounts.filter( + acc => acc.compressedAccount?.treeInfo?.treeType !== undefined, + ); + if (accountsWithTreeInfo.length <= 1) return; + + const firstTreeType = + accountsWithTreeInfo[0].compressedAccount.treeInfo.treeType; + const hasMixedTypes = accountsWithTreeInfo.some( + acc => acc.compressedAccount.treeInfo.treeType !== firstTreeType, + ); + + if (hasMixedTypes) { + throw new Error(ERROR_MIXED_TREE_TYPES); + } +} + +/** + * Groups accounts by tree type for separate processing + */ +export function groupAccountsByTreeType( + accounts: ParsedTokenAccount[], +): Map { + const groups = new Map(); + + for (const account of accounts) { + const treeType = account.compressedAccount.treeInfo.treeType; + const existing = groups.get(treeType) || []; + existing.push(account); + groups.set(treeType, existing); + } + + return groups; +} + +/** + * Result of selectAccountsByPreferredTreeType + */ +export interface SelectedAccountsResult { + /** The selected accounts (all from the same tree type) */ + accounts: ParsedTokenAccount[]; + /** The tree type of the selected accounts */ + treeType: TreeType; + /** Total balance of selected accounts */ + totalBalance: BN; +} + +/** + * Selects accounts by preferred tree type with automatic fallback. + * + * In V2 mode, prefers StateV2 accounts. Falls back to StateV1 if V2 + * has insufficient balance. + * + * This ensures all returned accounts are from the same tree type, + * preventing mixed V1/V2 batch proof failures. + * + * @param accounts All available accounts (can be mixed V1/V2) + * @param requiredAmount Minimum amount needed (optional - if not provided, returns all from preferred type) + * @returns Selected accounts from a single tree type + */ +export function selectAccountsByPreferredTreeType( + accounts: ParsedTokenAccount[], + requiredAmount?: BN, +): SelectedAccountsResult { + const preferredTreeType = featureFlags.isV2() + ? TreeType.StateV2 + : TreeType.StateV1; + + const accountsByTreeType = groupAccountsByTreeType(accounts); + + // Try preferred tree type first + let selectedTreeType = preferredTreeType; + let selectedAccounts = accountsByTreeType.get(preferredTreeType) || []; + let totalBalance = selectedAccounts.reduce( + (sum, acc) => sum.add(acc.parsed.amount), + bn(0), + ); + + // If insufficient balance in preferred type and requiredAmount specified, try fallback + if (requiredAmount && totalBalance.lt(requiredAmount)) { + const fallbackType = + preferredTreeType === TreeType.StateV2 + ? TreeType.StateV1 + : TreeType.StateV2; + const fallbackAccounts = accountsByTreeType.get(fallbackType) || []; + const fallbackBalance = fallbackAccounts.reduce( + (sum, acc) => sum.add(acc.parsed.amount), + bn(0), + ); + + if (fallbackBalance.gte(requiredAmount)) { + selectedTreeType = fallbackType; + selectedAccounts = fallbackAccounts; + totalBalance = fallbackBalance; + } + // If neither type has enough, proceed with preferred type + // and let downstream selection throw the insufficient balance error + } + + return { + accounts: selectedAccounts, + treeType: selectedTreeType, + totalBalance, + }; +} + /** * Selects token accounts for approval, first trying to find an exact match, then falling back to minimum selection. * * @param {ParsedTokenAccount[]} accounts - Token accounts to choose from. * @param {BN} approveAmount - Amount to approve. * @param {number} [maxInputs=4] - Max accounts to select when falling back to minimum selection. + * @param {SelectInputAccountsOptions} [options] - Optional selection options. * @returns {[ * selectedAccounts: ParsedTokenAccount[], * total: BN, @@ -26,14 +174,17 @@ export function selectTokenAccountsForApprove( accounts: ParsedTokenAccount[], approveAmount: BN, maxInputs: number = 4, + options?: SelectInputAccountsOptions, ): [ selectedAccounts: ParsedTokenAccount[], total: BN, totalLamports: BN | null, maxPossibleAmount: BN, ] { + const filteredAccounts = filterByTreeType(accounts, options?.treeType); + // First try to find an exact match - const exactMatch = accounts.find(account => + const exactMatch = filteredAccounts.find(account => account.parsed.amount.eq(approveAmount), ); if (exactMatch) { @@ -47,9 +198,10 @@ export function selectTokenAccountsForApprove( // If no exact match, fall back to minimum selection return selectMinCompressedTokenAccountsForTransfer( - accounts, + filteredAccounts, approveAmount, maxInputs, + options, ); } @@ -61,6 +213,7 @@ export function selectTokenAccountsForApprove( * @param {BN} amount Amount to decompress. * @param {number} [maxInputs=4] Max accounts to select. Default * is 4. + * @param {SelectInputAccountsOptions} [options] - Optional selection options. * * @returns Returns selected accounts and their totals. */ @@ -68,6 +221,7 @@ export function selectMinCompressedTokenAccountsForDecompression( accounts: ParsedTokenAccount[], amount: BN, maxInputs: number = 4, + options?: SelectInputAccountsOptions, ): { selectedAccounts: ParsedTokenAccount[]; total: BN; @@ -79,6 +233,7 @@ export function selectMinCompressedTokenAccountsForDecompression( accounts, amount, maxInputs, + options, ); return { selectedAccounts, total, totalLamports, maxPossibleAmount }; } @@ -91,6 +246,8 @@ export function selectMinCompressedTokenAccountsForDecompression( * @param {BN} transferAmount Amount to transfer or decompress. * @param {number} [maxInputs=4] Max accounts to select. Default * is 4. + * @param {SelectInputAccountsOptions} [options] - Optional selection options. + * Use treeType to filter by V1/V2. * * @returns Returns selected accounts and their totals. [ * selectedAccounts: ParsedTokenAccount[], @@ -103,31 +260,37 @@ export function selectMinCompressedTokenAccountsForTransfer( accounts: ParsedTokenAccount[], transferAmount: BN, maxInputs: number = 4, + options?: SelectInputAccountsOptions, ): [ selectedAccounts: ParsedTokenAccount[], total: BN, totalLamports: BN | null, maxPossibleAmount: BN, ] { + const filteredAccounts = filterByTreeType(accounts, options?.treeType); + const [ selectedAccounts, accumulatedAmount, accumulatedLamports, maxPossibleAmount, ] = selectMinCompressedTokenAccountsForTransferOrPartial( - accounts, + filteredAccounts, transferAmount, maxInputs, ); + // Validate selected accounts are all same tree type + validateSameTreeType(selectedAccounts); + if (accumulatedAmount.lt(bn(transferAmount))) { - const totalBalance = accounts.reduce( + const totalBalance = filteredAccounts.reduce( (acc, account) => acc.add(account.parsed.amount), bn(0), ); if (selectedAccounts.length >= maxInputs) { throw new Error( - `Account limit exceeded: max ${maxPossibleAmount.toString()} (${maxInputs} accounts) per transaction. Total balance: ${totalBalance.toString()} (${accounts.length} accounts). Consider multiple transfers to spend full balance.`, + `Account limit exceeded: max ${maxPossibleAmount.toString()} (${maxInputs} accounts) per transaction. Total balance: ${totalBalance.toString()} (${filteredAccounts.length} accounts). Consider multiple transfers to spend full balance.`, ); } else { throw new Error( @@ -225,6 +388,8 @@ export function selectMinCompressedTokenAccountsForTransferOrPartial( * @param {ParsedTokenAccount[]} accounts - The list of token accounts to select from. * @param {BN} transferAmount - The token amount to be transferred. * @param {number} [maxInputs=4] - The maximum number of accounts to select. Default: 4. + * @param {SelectInputAccountsOptions} [options] - Optional selection options. + * Use treeType to filter by V1/V2. * @returns {[ * selectedAccounts: ParsedTokenAccount[], * total: BN, @@ -257,31 +422,37 @@ export function selectSmartCompressedTokenAccountsForTransfer( accounts: ParsedTokenAccount[], transferAmount: BN, maxInputs: number = 4, + options?: SelectInputAccountsOptions, ): [ selectedAccounts: ParsedTokenAccount[], total: BN, totalLamports: BN | null, maxPossibleAmount: BN, ] { + const filteredAccounts = filterByTreeType(accounts, options?.treeType); + const [ selectedAccounts, accumulatedAmount, accumulatedLamports, maxPossibleAmount, ] = selectSmartCompressedTokenAccountsForTransferOrPartial( - accounts, + filteredAccounts, transferAmount, maxInputs, ); + // Validate selected accounts are all same tree type + validateSameTreeType(selectedAccounts); + if (accumulatedAmount.lt(bn(transferAmount))) { - const totalBalance = accounts.reduce( + const totalBalance = filteredAccounts.reduce( (acc, account) => acc.add(account.parsed.amount), bn(0), ); if (selectedAccounts.length >= maxInputs) { throw new Error( - `Account limit exceeded: max ${maxPossibleAmount.toString()} (${maxInputs} accounts) per transaction. Total balance: ${totalBalance.toString()} (${accounts.length} accounts). Consider multiple transfers to spend full balance.`, + `Account limit exceeded: max ${maxPossibleAmount.toString()} (${maxInputs} accounts) per transaction. Total balance: ${totalBalance.toString()} (${filteredAccounts.length} accounts). Consider multiple transfers to spend full balance.`, ); } else { throw new Error( diff --git a/js/compressed-token/src/v3/actions/create-associated-ctoken.ts b/js/compressed-token/src/v3/actions/create-associated-ctoken.ts index acf830e6ca..7d7dc7ecd0 100644 --- a/js/compressed-token/src/v3/actions/create-associated-ctoken.ts +++ b/js/compressed-token/src/v3/actions/create-associated-ctoken.ts @@ -8,6 +8,7 @@ import { Rpc, buildAndSignTx, sendAndConfirmTx, + assertBetaEnabled, } from '@lightprotocol/stateless.js'; import { createAssociatedCTokenAccountInstruction, @@ -39,6 +40,8 @@ export async function createAssociatedCTokenAccount( rentPayerPda?: PublicKey, confirmOptions?: ConfirmOptions, ): Promise { + assertBetaEnabled(); + const ix = createAssociatedCTokenAccountInstruction( payer.publicKey, owner, @@ -84,6 +87,8 @@ export async function createAssociatedCTokenAccountIdempotent( rentPayerPda?: PublicKey, confirmOptions?: ConfirmOptions, ): Promise { + assertBetaEnabled(); + const ix = createAssociatedCTokenAccountIdempotentInstruction( payer.publicKey, owner, diff --git a/js/compressed-token/src/v3/actions/create-ata-interface.ts b/js/compressed-token/src/v3/actions/create-ata-interface.ts index 0324832e9a..50618b7280 100644 --- a/js/compressed-token/src/v3/actions/create-ata-interface.ts +++ b/js/compressed-token/src/v3/actions/create-ata-interface.ts @@ -11,6 +11,7 @@ import { CTOKEN_PROGRAM_ID, buildAndSignTx, sendAndConfirmTx, + assertBetaEnabled, } from '@lightprotocol/stateless.js'; import { createAssociatedTokenAccountInterfaceInstruction, @@ -51,6 +52,8 @@ export async function createAtaInterface( associatedTokenProgramId?: PublicKey, ctokenConfig?: CTokenConfig, ): Promise { + assertBetaEnabled(); + const effectiveAtaProgramId = associatedTokenProgramId ?? getAtaProgramId(programId); @@ -125,6 +128,8 @@ export async function createAtaInterfaceIdempotent( associatedTokenProgramId?: PublicKey, ctokenConfig?: CTokenConfig, ): Promise { + assertBetaEnabled(); + const effectiveAtaProgramId = associatedTokenProgramId ?? getAtaProgramId(programId); 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 3d87210068..4b9d95b889 100644 --- a/js/compressed-token/src/v3/actions/create-mint-interface.ts +++ b/js/compressed-token/src/v3/actions/create-mint-interface.ts @@ -18,6 +18,7 @@ import { DerivationMode, CTOKEN_PROGRAM_ID, getDefaultAddressTreeInfo, + assertBetaEnabled, } from '@lightprotocol/stateless.js'; import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token'; import { @@ -59,6 +60,8 @@ export async function createMintInterface( outputStateTreeInfo?: TreeInfo, addressTreeInfo?: AddressTreeInfo, ): Promise<{ mint: PublicKey; transactionSignature: TransactionSignature }> { + assertBetaEnabled(); + // Dispatch to SPL/Token-2022 mint creation if ( programId.equals(TOKEN_PROGRAM_ID) || diff --git a/js/compressed-token/src/v3/actions/decompress-interface.ts b/js/compressed-token/src/v3/actions/decompress-interface.ts index ef8aa7a968..d28ee624c3 100644 --- a/js/compressed-token/src/v3/actions/decompress-interface.ts +++ b/js/compressed-token/src/v3/actions/decompress-interface.ts @@ -11,7 +11,9 @@ import { sendAndConfirmTx, dedupeSigner, ParsedTokenAccount, + assertBetaEnabled, } from '@lightprotocol/stateless.js'; +import { assertV2Only } from '../assert-v2-only'; import { createAssociatedTokenAccountIdempotentInstruction, getAssociatedTokenAddress, @@ -51,6 +53,8 @@ export async function decompressInterface( splInterfaceInfo?: SplInterfaceInfo, confirmOptions?: ConfirmOptions, ): Promise { + assertBetaEnabled(); + // Determine if this is SPL or c-token destination const isSplDestination = splInterfaceInfo !== undefined; @@ -65,6 +69,9 @@ export async function decompressInterface( return null; // Nothing to decompress } + // v3 interface only supports V2 trees + assertV2Only(compressedAccounts); + // Calculate total and determine amount const totalBalance = compressedAccounts.reduce( (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), @@ -79,7 +86,7 @@ export async function decompressInterface( ); } - // Select accounts to use (for now, use all - could optimize later) + // Select minimum accounts needed for the amount const accountsToUse: ParsedTokenAccount[] = []; let accumulatedAmount = BigInt(0); for (const acc of compressedAccounts) { 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 efcba8c649..8c3f25479a 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 @@ -3,6 +3,7 @@ import { CTOKEN_PROGRAM_ID, buildAndSignTx, sendAndConfirmTx, + assertBetaEnabled, } from '@lightprotocol/stateless.js'; import { getAssociatedTokenAddressSync, @@ -70,6 +71,8 @@ export async function getOrCreateAtaInterface( programId = CTOKEN_PROGRAM_ID, associatedTokenProgramId = getAtaProgramId(programId), ): Promise { + assertBetaEnabled(); + return _getOrCreateAtaInterface( rpc, payer, diff --git a/js/compressed-token/src/v3/actions/load-ata.ts b/js/compressed-token/src/v3/actions/load-ata.ts index 72d2a7ec52..cb3d631c59 100644 --- a/js/compressed-token/src/v3/actions/load-ata.ts +++ b/js/compressed-token/src/v3/actions/load-ata.ts @@ -4,9 +4,11 @@ import { buildAndSignTx, sendAndConfirmTx, dedupeSigner, - bn, ParsedTokenAccount, + bn, + assertBetaEnabled, } from '@lightprotocol/stateless.js'; +import { assertV2Only } from '../assert-v2-only'; import { PublicKey, TransactionInstruction, @@ -141,6 +143,8 @@ export async function createLoadAtaInstructions( options?: InterfaceOptions, wrap = false, ): Promise { + assertBetaEnabled(); + payer ??= owner; // Validation happens inside getAtaInterface via checkAtaAddress helper: @@ -211,6 +215,13 @@ export async function createLoadAtaInstructionsFromInterface( const mint = ata._mint; const sources = ata._sources ?? []; + // v3 interface only supports V2 trees - check cold sources early + const compressedAccountsToCheck = + getCompressedTokenAccountsFromAtaSources(sources); + if (compressedAccountsToCheck.length > 0) { + assertV2Only(compressedAccountsToCheck); + } + // Derive addresses const ctokenAtaAddress = getAssociatedTokenAddressInterface(mint, owner); const splAta = getAssociatedTokenAddressSync( @@ -344,11 +355,14 @@ export async function createLoadAtaInstructionsFromInterface( } // 4. Decompress compressed tokens to c-token ATA + // Note: v3 interface only supports V2 trees if (coldBalance > BigInt(0) && ctokenColdSource) { const compressedAccounts = getCompressedTokenAccountsFromAtaSources(sources); if (compressedAccounts.length > 0) { + assertV2Only(compressedAccounts); + const proof = await rpc.getValidityProofV0( compressedAccounts.map(acc => ({ hash: acc.compressedAccount.hash, @@ -378,6 +392,8 @@ export async function createLoadAtaInstructionsFromInterface( getCompressedTokenAccountsFromAtaSources(sources); if (compressedAccounts.length > 0) { + assertV2Only(compressedAccounts); + const proof = await rpc.getValidityProofV0( compressedAccounts.map(acc => ({ hash: acc.compressedAccount.hash, @@ -498,6 +514,8 @@ export async function loadAta( interfaceOptions?: InterfaceOptions, wrap = false, ): Promise { + assertBetaEnabled(); + payer ??= owner; const ixs = await createLoadAtaInstructions( 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 76d2180ce4..e31a44464f 100644 --- a/js/compressed-token/src/v3/actions/mint-to-compressed.ts +++ b/js/compressed-token/src/v3/actions/mint-to-compressed.ts @@ -14,6 +14,7 @@ import { CTOKEN_PROGRAM_ID, selectStateTreeInfo, TreeInfo, + assertBetaEnabled, } from '@lightprotocol/stateless.js'; import { createMintToCompressedInstruction } from '../instructions/mint-to-compressed'; import { getMintInterface } from '../get-mint-interface'; @@ -40,6 +41,8 @@ export async function mintToCompressed( tokenAccountVersion: number = 3, confirmOptions?: ConfirmOptions, ): Promise { + assertBetaEnabled(); + const mintInfo = await getMintInterface( rpc, mint, diff --git a/js/compressed-token/src/v3/actions/mint-to-interface.ts b/js/compressed-token/src/v3/actions/mint-to-interface.ts index d9fbbd6962..b16531ee7b 100644 --- a/js/compressed-token/src/v3/actions/mint-to-interface.ts +++ b/js/compressed-token/src/v3/actions/mint-to-interface.ts @@ -11,6 +11,7 @@ import { sendAndConfirmTx, DerivationMode, bn, + assertBetaEnabled, } from '@lightprotocol/stateless.js'; import { createMintToInterfaceInstruction } from '../instructions/mint-to-interface'; import { getMintInterface } from '../get-mint-interface'; @@ -45,6 +46,8 @@ export async function mintToInterface( confirmOptions?: ConfirmOptions, programId?: PublicKey, ): Promise { + assertBetaEnabled(); + // Fetch mint interface (auto-detects program type if not provided) const mintInterface = await getMintInterface( rpc, diff --git a/js/compressed-token/src/v3/actions/mint-to.ts b/js/compressed-token/src/v3/actions/mint-to.ts index 93375bb2e4..a9f208d150 100644 --- a/js/compressed-token/src/v3/actions/mint-to.ts +++ b/js/compressed-token/src/v3/actions/mint-to.ts @@ -14,6 +14,7 @@ import { CTOKEN_PROGRAM_ID, selectStateTreeInfo, TreeInfo, + assertBetaEnabled, } from '@lightprotocol/stateless.js'; import { createMintToInstruction } from '../instructions/mint-to'; import { getMintInterface } from '../get-mint-interface'; @@ -28,6 +29,8 @@ export async function mintTo( outputQueue?: PublicKey, confirmOptions?: ConfirmOptions, ): Promise { + assertBetaEnabled(); + 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 91d37a2dc3..0e9b66612f 100644 --- a/js/compressed-token/src/v3/actions/transfer-interface.ts +++ b/js/compressed-token/src/v3/actions/transfer-interface.ts @@ -13,7 +13,9 @@ import { CTOKEN_PROGRAM_ID, dedupeSigner, ParsedTokenAccount, + assertBetaEnabled, } from '@lightprotocol/stateless.js'; +import { assertV2Only } from '../assert-v2-only'; import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, @@ -105,6 +107,8 @@ export async function transferInterface( options?: InterfaceOptions, wrap = false, ): Promise { + assertBetaEnabled(); + const amountBigInt = BigInt(amount.toString()); const { splInterfaceInfos: providedSplInterfaceInfos } = options ?? {}; @@ -317,7 +321,10 @@ export async function transferInterface( } // Decompress compressed tokens if they exist + // Note: v3 interface only supports V2 trees if (compressedBalance > BigInt(0) && compressedAccounts.length > 0) { + assertV2Only(compressedAccounts); + const proof = await rpc.getValidityProofV0( compressedAccounts.map(acc => ({ hash: acc.compressedAccount.hash, diff --git a/js/compressed-token/src/v3/actions/unwrap.ts b/js/compressed-token/src/v3/actions/unwrap.ts index 0d8f61ee67..f0bf16ed87 100644 --- a/js/compressed-token/src/v3/actions/unwrap.ts +++ b/js/compressed-token/src/v3/actions/unwrap.ts @@ -10,6 +10,7 @@ import { buildAndSignTx, sendAndConfirmTx, dedupeSigner, + assertBetaEnabled, } from '@lightprotocol/stateless.js'; import { getMint } from '@solana/spl-token'; import BN from 'bn.js'; @@ -45,6 +46,8 @@ export async function unwrap( splInterfaceInfo?: SplInterfaceInfo, confirmOptions?: ConfirmOptions, ): Promise { + assertBetaEnabled(); + // 1. Get SPL interface info if not provided let resolvedSplInterfaceInfo = splInterfaceInfo; if (!resolvedSplInterfaceInfo) { diff --git a/js/compressed-token/src/v3/actions/update-metadata.ts b/js/compressed-token/src/v3/actions/update-metadata.ts index 95aa7fefeb..c96746a197 100644 --- a/js/compressed-token/src/v3/actions/update-metadata.ts +++ b/js/compressed-token/src/v3/actions/update-metadata.ts @@ -12,6 +12,7 @@ import { DerivationMode, bn, CTOKEN_PROGRAM_ID, + assertBetaEnabled, } from '@lightprotocol/stateless.js'; import { createUpdateMetadataFieldInstruction, @@ -44,6 +45,8 @@ export async function updateMetadataField( extensionIndex: number = 0, confirmOptions?: ConfirmOptions, ): Promise { + assertBetaEnabled(); + const mintInterface = await getMintInterface( rpc, mint, @@ -114,6 +117,8 @@ export async function updateMetadataAuthority( extensionIndex: number = 0, confirmOptions?: ConfirmOptions, ): Promise { + assertBetaEnabled(); + const mintInterface = await getMintInterface( rpc, mint, @@ -184,6 +189,8 @@ export async function removeMetadataKey( extensionIndex: number = 0, confirmOptions?: ConfirmOptions, ): Promise { + assertBetaEnabled(); + const mintInterface = await getMintInterface( rpc, mint, diff --git a/js/compressed-token/src/v3/actions/update-mint.ts b/js/compressed-token/src/v3/actions/update-mint.ts index 838328e573..bd96b28f7f 100644 --- a/js/compressed-token/src/v3/actions/update-mint.ts +++ b/js/compressed-token/src/v3/actions/update-mint.ts @@ -12,6 +12,7 @@ import { DerivationMode, bn, CTOKEN_PROGRAM_ID, + assertBetaEnabled, } from '@lightprotocol/stateless.js'; import { createUpdateMintAuthorityInstruction, @@ -37,6 +38,8 @@ export async function updateMintAuthority( newMintAuthority: PublicKey | null, confirmOptions?: ConfirmOptions, ): Promise { + assertBetaEnabled(); + const mintInterface = await getMintInterface( rpc, mint, @@ -104,6 +107,8 @@ export async function updateFreezeAuthority( newFreezeAuthority: PublicKey | null, confirmOptions?: ConfirmOptions, ): Promise { + assertBetaEnabled(); + const mintInterface = await getMintInterface( rpc, mint, diff --git a/js/compressed-token/src/v3/actions/wrap.ts b/js/compressed-token/src/v3/actions/wrap.ts index 98fe068179..5efeb6a467 100644 --- a/js/compressed-token/src/v3/actions/wrap.ts +++ b/js/compressed-token/src/v3/actions/wrap.ts @@ -10,6 +10,7 @@ import { buildAndSignTx, sendAndConfirmTx, dedupeSigner, + assertBetaEnabled, } from '@lightprotocol/stateless.js'; import { getMint } from '@solana/spl-token'; import { createWrapInstruction } from '../instructions/wrap'; @@ -61,6 +62,8 @@ export async function wrap( splInterfaceInfo?: SplInterfaceInfo, confirmOptions?: ConfirmOptions, ): Promise { + assertBetaEnabled(); + // Get SPL interface info if not provided let resolvedSplInterfaceInfo = splInterfaceInfo; if (!resolvedSplInterfaceInfo) { diff --git a/js/compressed-token/src/v3/assert-v2-only.ts b/js/compressed-token/src/v3/assert-v2-only.ts new file mode 100644 index 0000000000..d250221ff4 --- /dev/null +++ b/js/compressed-token/src/v3/assert-v2-only.ts @@ -0,0 +1,26 @@ +import { + ParsedTokenAccount, + TreeType, + assertBetaEnabled, +} from '@lightprotocol/stateless.js'; + +// Re-export for convenience +export { assertBetaEnabled }; + +/** + * Throws if any V1 compressed accounts are present. + * v3 interface only supports V2 trees. + */ +export function assertV2Only(accounts: ParsedTokenAccount[]): void { + const v1Count = accounts.filter( + acc => acc.compressedAccount.treeInfo.treeType === TreeType.StateV1, + ).length; + + if (v1Count > 0) { + throw new Error( + 'v3 interface does not support V1 compressed accounts. ' + + `Found ${v1Count} V1 account(s). ` + + 'Use the main SDK actions (transfer, decompress, merge) to migrate V1 accounts to V2.', + ); + } +} diff --git a/js/compressed-token/src/v3/get-account-interface.ts b/js/compressed-token/src/v3/get-account-interface.ts index a2f7ddf757..9cb5b4d5d1 100644 --- a/js/compressed-token/src/v3/get-account-interface.ts +++ b/js/compressed-token/src/v3/get-account-interface.ts @@ -16,6 +16,7 @@ import { deriveAddressV2, bn, getDefaultAddressTreeInfo, + assertBetaEnabled, } from '@lightprotocol/stateless.js'; import { Buffer } from 'buffer'; import BN from 'bn.js'; @@ -214,6 +215,8 @@ export async function getAccountInterface( commitment?: Commitment, programId?: PublicKey, ): Promise { + assertBetaEnabled(); + return _getAccountInterface(rpc, address, commitment, programId, undefined); } @@ -240,6 +243,8 @@ export async function getAtaInterface( wrap = false, allowOwnerOffCurve = false, ): Promise { + assertBetaEnabled(); + // 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. @@ -509,24 +514,11 @@ async function getUnifiedAccountInterface( const fetchTypes: TokenAccountSource['type'][] = []; const fetchAddresses: PublicKey[] = []; - // c-token hot + cold + // c-token hot fetchPromises.push(_tryFetchCTokenHot(rpc, cTokenAta, commitment)); fetchTypes.push(TokenAccountSourceType.CTokenHot); fetchAddresses.push(cTokenAta); - fetchPromises.push( - fetchByOwner - ? _tryFetchCTokenColdByOwner( - rpc, - fetchByOwner.owner, - fetchByOwner.mint, - cTokenAta, - ) - : _tryFetchCTokenColdByAddress(rpc, address!), - ); - fetchTypes.push(TokenAccountSourceType.CTokenCold); - fetchAddresses.push(cTokenAta); - // SPL / Token-2022 (only when wrap is enabled) if (wrap) { // Always derive SPL/T22 addresses from owner+mint, not from the passed @@ -560,13 +552,23 @@ async function getUnifiedAccountInterface( fetchAddresses.push(token2022Ata); } - const results = await Promise.allSettled(fetchPromises); + // Fetch ALL cold c-token accounts (not just one) - important for V1/V2 detection + const coldAccountsPromise = fetchByOwner + ? rpc.getCompressedTokenAccountsByOwner(fetchByOwner.owner, { + mint: fetchByOwner.mint, + }) + : rpc.getCompressedTokenAccountsByOwner(address!); - // collect all successful results + const [hotResults, coldResult] = await Promise.all([ + Promise.allSettled(fetchPromises), + coldAccountsPromise.catch(() => ({ items: [] })), + ]); + + // collect all successful hot results const sources: TokenAccountSource[] = []; - for (let i = 0; i < results.length; i++) { - const result = results[i]; + for (let i = 0; i < hotResults.length; i++) { + const result = hotResults[i]; if (result.status === 'fulfilled') { const value = result.value; sources.push({ @@ -580,6 +582,27 @@ async function getUnifiedAccountInterface( } } + // Add ALL cold c-token accounts (handles both V1 and V2) + for (const item of coldResult.items) { + const compressedAccount = item.compressedAccount; + if ( + compressedAccount && + compressedAccount.data && + compressedAccount.data.data.length > 0 && + compressedAccount.owner.equals(CTOKEN_PROGRAM_ID) + ) { + const parsed = parseCTokenCold(cTokenAta, compressedAccount); + sources.push({ + type: TokenAccountSourceType.CTokenCold, + address: cTokenAta, + amount: parsed.parsed.amount, + accountInfo: parsed.accountInfo, + loadContext: parsed.loadContext, + parsed: parsed.parsed, + }); + } + } + // account not found if (sources.length === 0) { throw new TokenAccountNotFoundError(); diff --git a/js/compressed-token/src/v3/get-mint-interface.ts b/js/compressed-token/src/v3/get-mint-interface.ts index ac0ad9eb23..402ba5ab6f 100644 --- a/js/compressed-token/src/v3/get-mint-interface.ts +++ b/js/compressed-token/src/v3/get-mint-interface.ts @@ -7,6 +7,7 @@ import { CTOKEN_PROGRAM_ID, getDefaultAddressTreeInfo, MerkleContext, + assertBetaEnabled, } from '@lightprotocol/stateless.js'; import { Mint, @@ -53,6 +54,8 @@ export async function getMintInterface( commitment?: Commitment, programId?: PublicKey, ): Promise { + assertBetaEnabled(); + // try all three programs in parallel if (!programId) { const [tokenResult, token2022Result, compressedResult] = diff --git a/js/compressed-token/tests/e2e/v1-v2-migration.test.ts b/js/compressed-token/tests/e2e/v1-v2-migration.test.ts new file mode 100644 index 0000000000..652e650b82 --- /dev/null +++ b/js/compressed-token/tests/e2e/v1-v2-migration.test.ts @@ -0,0 +1,1405 @@ +/** + * V1 -> V2 Migration Test Suite + * + * This test suite verifies V1 -> V2 migration when running V2 SDK with + * existing V1 tokens. + * + * WHAT WORKS: + * - V1 accounts are discoverable via getCompressedTokenAccountsByOwner + * - V1 accounts can be transferred, producing V2 outputs (auto-migration) + * - V1 accounts can be merged, producing V2 outputs (auto-migration) + * - V1 accounts can be decompressed to SPL + * - Validity proofs work for V1 account hashes + * - Mixed V1+V2 accounts returned together from RPC queries + * + * AUTO-MIGRATION BEHAVIOR (always enabled in V2 mode): + * - V1 inputs ALWAYS produce V2 outputs + * - V2 inputs produce V2 outputs + * + * LIMITATIONS: + * - Mixed V1+V2 batch proofs are NOT supported in the same transaction + * - Cannot transfer/merge accounts that span both V1 and V2 trees in single tx + * + * MIGRATION PATH FOR USERS: + * - Transfer/merge operations automatically migrate V1 to V2 + * - Users with mixed V1+V2 need to process them separately + * - Decompression works the same for both V1 and V2 + */ +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { Keypair, PublicKey, Signer } from '@solana/web3.js'; +import { + Rpc, + bn, + newAccountWithLamports, + getTestRpc, + selectStateTreeInfo, + TreeInfo, + TreeType, + featureFlags, + VERSION, + ParsedTokenAccount, +} from '@lightprotocol/stateless.js'; +import { WasmFactory } from '@lightprotocol/hasher.rs'; +import { + createMint, + mintTo, + transfer, + compress, + decompress, + mergeTokenAccounts, + approve, +} from '../../src/actions'; +import { + getTokenPoolInfos, + selectTokenPoolInfo, + selectTokenPoolInfosForDecompression, + TokenPoolInfo, +} from '../../src/utils/get-token-pool-infos'; +import { + createAssociatedTokenAccount, + mintTo as splMintTo, +} from '@solana/spl-token'; +import { TokenDataVersion } from '../../src/constants'; + +// Force V2 mode for all tests +featureFlags.version = VERSION.V2; + +const TEST_TOKEN_DECIMALS = 9; + +/** + * Get token data version from compressed account discriminator. + */ +function getVersionFromDiscriminator( + discriminator: number[] | undefined, +): TokenDataVersion { + if (!discriminator || discriminator.length < 8) { + return TokenDataVersion.ShaFlat; + } + if (discriminator[0] === 2) { + return TokenDataVersion.V1; + } + const versionByte = discriminator[7]; + if (versionByte === 3) { + return TokenDataVersion.V2; + } + if (versionByte === 4) { + return TokenDataVersion.ShaFlat; + } + return TokenDataVersion.ShaFlat; +} + +/** + * Helper to get a V1 state tree info for minting (simulates pre-migration state) + */ +function selectV1StateTreeInfo(treeInfos: TreeInfo[]): TreeInfo { + return selectStateTreeInfo(treeInfos, TreeType.StateV1); +} + +/** + * Helper to get a V2 state tree info + */ +function selectV2StateTreeInfo(treeInfos: TreeInfo[]): TreeInfo { + return selectStateTreeInfo(treeInfos, TreeType.StateV2); +} + +/** + * Assert that an account is stored in a V1 tree + */ +function assertAccountInV1Tree(account: ParsedTokenAccount) { + expect(account.compressedAccount.treeInfo.treeType).toBe(TreeType.StateV1); +} + +/** + * Assert that an account is stored in a V2 tree + */ +function assertAccountInV2Tree(account: ParsedTokenAccount) { + expect(account.compressedAccount.treeInfo.treeType).toBe(TreeType.StateV2); +} + +/** + * Assert that a token account has V1 discriminator + */ +function assertV1Discriminator(account: ParsedTokenAccount) { + const version = getVersionFromDiscriminator( + account.compressedAccount.data?.discriminator, + ); + expect(version).toBe(TokenDataVersion.V1); +} + +describe('v1-v2-migration', () => { + let rpc: Rpc; + let payer: Signer; + let mintAuthority: Keypair; + let mint: PublicKey; + let treeInfos: TreeInfo[]; + let v1TreeInfo: TreeInfo; + let v2TreeInfo: TreeInfo; + let tokenPoolInfos: TokenPoolInfo[]; + + beforeAll(async () => { + const lightWasm = await WasmFactory.getInstance(); + rpc = await getTestRpc(lightWasm); + payer = await newAccountWithLamports(rpc, 10e9); + mintAuthority = Keypair.generate(); + + const mintKeypair = Keypair.generate(); + mint = ( + await createMint( + rpc, + payer, + mintAuthority.publicKey, + TEST_TOKEN_DECIMALS, + mintKeypair, + ) + ).mint; + + treeInfos = await rpc.getStateTreeInfos(); + + // Verify we have both V1 and V2 trees available + const v1Trees = treeInfos.filter(t => t.treeType === TreeType.StateV1); + const v2Trees = treeInfos.filter(t => t.treeType === TreeType.StateV2); + + expect(v1Trees.length).toBeGreaterThan(0); + expect(v2Trees.length).toBeGreaterThan(0); + + v1TreeInfo = selectV1StateTreeInfo(treeInfos); + v2TreeInfo = selectV2StateTreeInfo(treeInfos); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 120_000); + + describe('RPC Layer - Account Discovery', () => { + let owner: Signer; + + beforeEach(async () => { + owner = await newAccountWithLamports(rpc, 1e9); + }); + + it('getCompressedTokenAccountsByOwner returns V1 accounts when SDK is V2', async () => { + // Mint to V1 tree + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const accounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + + expect(accounts.items.length).toBe(1); + assertAccountInV1Tree(accounts.items[0]); + assertV1Discriminator(accounts.items[0]); + expect(accounts.items[0].parsed.amount.eq(bn(1000))).toBe(true); + }); + + it('getCompressedTokenAccountsByOwner returns mixed V1+V2 accounts', async () => { + // Mint to V1 tree + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Mint to V2 tree + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(300), + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const accounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + + expect(accounts.items.length).toBe(2); + + const v1Account = accounts.items.find( + a => a.compressedAccount.treeInfo.treeType === TreeType.StateV1, + ); + const v2Account = accounts.items.find( + a => a.compressedAccount.treeInfo.treeType === TreeType.StateV2, + ); + + expect(v1Account).toBeDefined(); + expect(v2Account).toBeDefined(); + expect(v1Account!.parsed.amount.eq(bn(500))).toBe(true); + expect(v2Account!.parsed.amount.eq(bn(300))).toBe(true); + }); + + it('getCompressedTokenAccountsByOwner aggregates V1+V2 correctly for balance check', async () => { + // Mint multiple to V1 and V2 trees + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(100), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(200), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(300), + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const accounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + + // Verify we got all 3 accounts (2 V1 + 1 V2) + expect(accounts.items.length).toBe(3); + + const totalBalance = accounts.items.reduce( + (sum, item) => sum.add(item.parsed.amount), + bn(0), + ); + + expect(totalBalance.eq(bn(600))).toBe(true); + + // Verify the tree types + const v1Accounts = accounts.items.filter( + a => a.compressedAccount.treeInfo.treeType === TreeType.StateV1, + ); + const v2Accounts = accounts.items.filter( + a => a.compressedAccount.treeInfo.treeType === TreeType.StateV2, + ); + expect(v1Accounts.length).toBe(2); + expect(v2Accounts.length).toBe(1); + }); + }); + + describe('Transfer - V1 Inputs with Auto-Migration', () => { + let sender: Signer; + let recipient: PublicKey; + + beforeEach(async () => { + sender = await newAccountWithLamports(rpc, 1e9); + recipient = Keypair.generate().publicKey; + }); + + it('transfer single V1 token auto-migrates to V2 output', async () => { + // Setup: mint to V1 tree + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(1000), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Verify input is V1 + const preSenderAccounts = + await rpc.getCompressedTokenAccountsByOwner(sender.publicKey, { + mint, + }); + expect(preSenderAccounts.items.length).toBe(1); + assertAccountInV1Tree(preSenderAccounts.items[0]); + + // Transfer - auto-migration to V2 is default in V2 mode + await transfer(rpc, payer, mint, bn(700), sender, recipient); + + // Verify recipient account is now in V2 tree (auto-migrated) + const recipientAccounts = + await rpc.getCompressedTokenAccountsByOwner(recipient, { + mint, + }); + expect(recipientAccounts.items.length).toBe(1); + // V1 inputs -> V2 outputs with auto-migration + assertAccountInV2Tree(recipientAccounts.items[0]); + expect(recipientAccounts.items[0].parsed.amount.eq(bn(700))).toBe( + true, + ); + }); + + it('transfer with change - both outputs go to V2 tree (auto-migration)', async () => { + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(1000), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + await transfer(rpc, payer, mint, bn(600), sender, recipient); + + // Sender change is now in V2 tree (auto-migrated) + const senderAccounts = await rpc.getCompressedTokenAccountsByOwner( + sender.publicKey, + { + mint, + }, + ); + expect(senderAccounts.items.length).toBe(1); + assertAccountInV2Tree(senderAccounts.items[0]); + expect(senderAccounts.items[0].parsed.amount.eq(bn(400))).toBe( + true, + ); + + // Recipient in V2 tree (auto-migrated) + const recipientAccounts = + await rpc.getCompressedTokenAccountsByOwner(recipient, { + mint, + }); + assertAccountInV2Tree(recipientAccounts.items[0]); + }); + + it('transfer using multiple V1 inputs auto-migrates to V2', async () => { + // Mint two separate V1 accounts + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(300), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(400), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Verify both inputs are V1 + const preSenderAccounts = + await rpc.getCompressedTokenAccountsByOwner(sender.publicKey, { + mint, + }); + expect(preSenderAccounts.items.length).toBe(2); + preSenderAccounts.items.forEach(a => assertAccountInV1Tree(a)); + + // Transfer requires both inputs - auto-migrates to V2 + await transfer(rpc, payer, mint, bn(650), sender, recipient); + + const recipientAccounts = + await rpc.getCompressedTokenAccountsByOwner(recipient, { + mint, + }); + expect(recipientAccounts.items.length).toBe(1); + expect(recipientAccounts.items[0].parsed.amount.eq(bn(650))).toBe( + true, + ); + // Verify output is V2 + assertAccountInV2Tree(recipientAccounts.items[0]); + + // Sender change is also V2 + const senderAccounts = await rpc.getCompressedTokenAccountsByOwner( + sender.publicKey, + { + mint, + }, + ); + expect(senderAccounts.items.length).toBe(1); + expect(senderAccounts.items[0].parsed.amount.eq(bn(50))).toBe(true); + assertAccountInV2Tree(senderAccounts.items[0]); + }); + + it('transfer using 4 V1 inputs (max batch) auto-migrates to V2', async () => { + // Mint 4 V1 accounts + for (let i = 0; i < 4; i++) { + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(100), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + } + + const preSenderAccounts = + await rpc.getCompressedTokenAccountsByOwner(sender.publicKey, { + mint, + }); + expect(preSenderAccounts.items.length).toBe(4); + preSenderAccounts.items.forEach(a => assertAccountInV1Tree(a)); + + // Transfer using all 4 inputs + await transfer(rpc, payer, mint, bn(400), sender, recipient); + + const recipientAccounts = + await rpc.getCompressedTokenAccountsByOwner(recipient, { + mint, + }); + expect(recipientAccounts.items.length).toBe(1); + expect(recipientAccounts.items[0].parsed.amount.eq(bn(400))).toBe( + true, + ); + }); + }); + + describe('Transfer - V2 Inputs', () => { + let sender: Signer; + let recipient: PublicKey; + + beforeEach(async () => { + sender = await newAccountWithLamports(rpc, 1e9); + recipient = Keypair.generate().publicKey; + }); + + it('transfer V2 token stays in V2 tree', async () => { + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(1000), + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const preSenderAccounts = + await rpc.getCompressedTokenAccountsByOwner(sender.publicKey, { + mint, + }); + assertAccountInV2Tree(preSenderAccounts.items[0]); + + await transfer(rpc, payer, mint, bn(700), sender, recipient); + + const recipientAccounts = + await rpc.getCompressedTokenAccountsByOwner(recipient, { + mint, + }); + expect(recipientAccounts.items.length).toBe(1); + assertAccountInV2Tree(recipientAccounts.items[0]); + }); + }); + + describe('Mixed V1+V2 - Current Limitations', () => { + let owner: Signer; + + beforeEach(async () => { + owner = await newAccountWithLamports(rpc, 1e9); + }); + + it('mixed V1+V2 batch proof request fails (expected limitation)', async () => { + // Mint to V1 tree + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(300), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Mint to V2 tree + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(200), + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const accounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + expect(accounts.items.length).toBe(2); + + // Mixed V1+V2 proof should fail + await expect( + rpc.getValidityProof( + accounts.items.map(a => bn(a.compressedAccount.hash)), + ), + ).rejects.toThrow( + 'Requested hashes belong to different tree types', + ); + }); + + it('transfer with mixed V1+V2 - selects from V2 first (preferred)', async () => { + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(300), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(200), + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const recipient = Keypair.generate().publicKey; + + // Transfer amount that can be covered by V2 only + await transfer(rpc, payer, mint, bn(150), owner, recipient); + + // Recipient should get V2 tokens (preferred type) + const recipientAccounts = + await rpc.getCompressedTokenAccountsByOwner(recipient, { + mint, + }); + expect(recipientAccounts.items.length).toBe(1); + assertAccountInV2Tree(recipientAccounts.items[0]); + }); + + it('transfer with mixed V1+V2 - falls back to V1 if V2 insufficient', async () => { + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(100), + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const recipient = Keypair.generate().publicKey; + + // Transfer amount that can only be covered by V1 + await transfer(rpc, payer, mint, bn(400), owner, recipient); + + // Recipient should get V2 tokens (auto-migrated from V1 input) + const recipientAccounts = + await rpc.getCompressedTokenAccountsByOwner(recipient, { + mint, + }); + expect(recipientAccounts.items.length).toBe(1); + assertAccountInV2Tree(recipientAccounts.items[0]); + }); + }); + + describe('Merge - V1 Consolidation with Auto-Migration', () => { + let owner: Signer; + + beforeEach(async () => { + owner = await newAccountWithLamports(rpc, 1e9); + }); + + it('merge multiple V1 accounts auto-migrates to V2 output', async () => { + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(100), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(200), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const preAccounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + expect(preAccounts.items.length).toBe(2); + preAccounts.items.forEach(a => assertAccountInV1Tree(a)); + + await mergeTokenAccounts(rpc, payer, mint, owner); + + const postAccounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + expect(postAccounts.items.length).toBe(1); + // Auto-migration: V1 merge produces V2 output + assertAccountInV2Tree(postAccounts.items[0]); + expect(postAccounts.items[0].parsed.amount.eq(bn(300))).toBe(true); + }); + + it('merge 4 V1 accounts (max batch) auto-migrates to V2', async () => { + for (let i = 0; i < 4; i++) { + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(50), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + } + + const preAccounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + expect(preAccounts.items.length).toBe(4); + preAccounts.items.forEach(a => assertAccountInV1Tree(a)); + + await mergeTokenAccounts(rpc, payer, mint, owner); + + const postAccounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + expect(postAccounts.items.length).toBe(1); + expect(postAccounts.items[0].parsed.amount.eq(bn(200))).toBe(true); + // Verify auto-migration to V2 + assertAccountInV2Tree(postAccounts.items[0]); + }); + + it('merge with 1 V1 and 1 V2 account fails (cannot mix tree types)', async () => { + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(100), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(200), + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const preAccounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + expect(preAccounts.items.length).toBe(2); + + // Should fail because we have 1 V1 and 1 V2 - can't merge mixed types + await expect( + mergeTokenAccounts(rpc, payer, mint, owner), + ).rejects.toThrow( + 'Cannot merge accounts from different tree types', + ); + }); + + it('merge with 2+ V1 and 2+ V2 accounts prefers V2', async () => { + // Create 2 V1 accounts + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(100), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(100), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Create 2 V2 accounts + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(50), + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(50), + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const preAccounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + expect(preAccounts.items.length).toBe(4); + + // Merge should pick V2 accounts (preferred in V2 mode) + await mergeTokenAccounts(rpc, payer, mint, owner); + + const postAccounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + // Should have: 2 V1 (unchanged) + 1 V2 (merged) + expect(postAccounts.items.length).toBe(3); + + const v1Accounts = postAccounts.items.filter( + a => a.compressedAccount.treeInfo.treeType === TreeType.StateV1, + ); + const v2Accounts = postAccounts.items.filter( + a => a.compressedAccount.treeInfo.treeType === TreeType.StateV2, + ); + + // V1 accounts unchanged + expect(v1Accounts.length).toBe(2); + // V2 merged into 1 + expect(v2Accounts.length).toBe(1); + expect(v2Accounts[0].parsed.amount.eq(bn(100))).toBe(true); + }); + + it('merge with 2+ V1 and only 1 V2 falls back to V1', async () => { + // Create 3 V1 accounts + for (let i = 0; i < 3; i++) { + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(100), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + } + + // Create only 1 V2 account (not enough to merge) + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(50), + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const preAccounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + expect(preAccounts.items.length).toBe(4); + + // Merge should fall back to V1 (V2 has only 1 account) + await mergeTokenAccounts(rpc, payer, mint, owner); + + const postAccounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + // Should have: 1 V1 (merged from 3) + 1 V2 (unchanged) + expect(postAccounts.items.length).toBe(2); + + const v1Accounts = postAccounts.items.filter( + a => a.compressedAccount.treeInfo.treeType === TreeType.StateV1, + ); + const v2Accounts = postAccounts.items.filter( + a => a.compressedAccount.treeInfo.treeType === TreeType.StateV2, + ); + + // V1 merged into 1 (auto-migrated to V2) + expect(v1Accounts.length).toBe(0); + // Note: merged V1 becomes V2, so we have 2 V2 now + expect(v2Accounts.length).toBe(2); + // One is the original 50, other is merged 300 + const amounts = v2Accounts + .map(a => a.parsed.amount.toNumber()) + .sort((a, b) => a - b); + expect(amounts).toEqual([50, 300]); + }); + }); + + describe('Decompress - V1 to SPL', () => { + let owner: Signer; + let ownerAta: PublicKey; + + beforeEach(async () => { + owner = await newAccountWithLamports(rpc, 1e9); + ownerAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + // First mint SPL tokens and compress them to create pool balance + await splMintTo( + rpc, + payer, + mint, + ownerAta, + mintAuthority, + 1_000_000_000n, // 1B tokens + ); + + // Compress some to create pool balance + await compress( + rpc, + payer, + mint, + bn(500_000_000), + owner, + ownerAta, + owner.publicKey, + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + }); + + it('decompress V1 token to SPL ATA works', async () => { + // Mint V1 compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const preAccounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + // We have the compressed from beforeEach + the new V1 mint + const v1Account = preAccounts.items.find( + a => a.compressedAccount.treeInfo.treeType === TreeType.StateV1, + ); + expect(v1Account).toBeDefined(); + + // Get fresh token pool infos (with updated balances) + const freshPoolInfos = await getTokenPoolInfos(rpc, mint); + + const preAtaBalance = await rpc.getTokenAccountBalance(ownerAta); + + await decompress( + rpc, + payer, + mint, + bn(500), + owner, + ownerAta, + selectTokenPoolInfosForDecompression(freshPoolInfos, bn(500)), + ); + + // Verify SPL balance increased + const postAtaBalance = await rpc.getTokenAccountBalance(ownerAta); + expect( + BigInt(postAtaBalance.value.amount) - + BigInt(preAtaBalance.value.amount), + ).toBe(500n); + }); + + it('partial decompress V1 token - change account created', async () => { + // Mint V1 compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const freshPoolInfos = await getTokenPoolInfos(rpc, mint); + + await decompress( + rpc, + payer, + mint, + bn(400), + owner, + ownerAta, + selectTokenPoolInfosForDecompression(freshPoolInfos, bn(400)), + ); + + // Verify SPL balance + const ataBalance = await rpc.getTokenAccountBalance(ownerAta); + // 500M from compress + 400 from decompress = should have received 400 + expect(ataBalance.value.amount).toContain('400'); + + // Verify compressed accounts - should have change + const postAccounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + // Should have V2 from compress + change from V1 decompress + expect(postAccounts.items.length).toBeGreaterThanOrEqual(1); + }); + + it('decompress with mixed V1+V2 prefers V2 input', async () => { + // Mint V1 compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Mint V2 compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(300), + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const preAccounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + // beforeEach compresses 500M to V2, plus our V1 500 and V2 300 + const v1Pre = preAccounts.items.filter( + a => a.compressedAccount.treeInfo.treeType === TreeType.StateV1, + ); + const v2Pre = preAccounts.items.filter( + a => a.compressedAccount.treeInfo.treeType === TreeType.StateV2, + ); + expect(v1Pre.length).toBeGreaterThanOrEqual(1); + expect(v2Pre.length).toBeGreaterThanOrEqual(1); + + const freshPoolInfos = await getTokenPoolInfos(rpc, mint); + + // Decompress amount that can be covered by V2 + await decompress( + rpc, + payer, + mint, + bn(200), + owner, + ownerAta, + selectTokenPoolInfosForDecompression(freshPoolInfos, bn(200)), + ); + + const postAccounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + const v1Post = postAccounts.items.filter( + a => a.compressedAccount.treeInfo.treeType === TreeType.StateV1, + ); + + // V1 account should be unchanged (V2 was preferred) + expect(v1Post.length).toBe(v1Pre.length); + expect(v1Post[0].parsed.amount.eq(bn(500))).toBe(true); + }); + + it('decompress with insufficient V2 falls back to V1', async () => { + // Mint V1 compressed tokens (large amount) + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Mint V2 compressed tokens (small amount) + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(50), + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const freshPoolInfos = await getTokenPoolInfos(rpc, mint); + + // Decompress amount that exceeds V2 balance + await decompress( + rpc, + payer, + mint, + bn(800), + owner, + ownerAta, + selectTokenPoolInfosForDecompression(freshPoolInfos, bn(800)), + ); + + const postAccounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + const v1Post = postAccounts.items.filter( + a => a.compressedAccount.treeInfo.treeType === TreeType.StateV1, + ); + const v2Post = postAccounts.items.filter( + a => a.compressedAccount.treeInfo.treeType === TreeType.StateV2, + ); + + // V1 was used (fell back), should have change or be consumed + // V2 should be unchanged (50 tokens) + const v2Amount = v2Post.reduce( + (sum, a) => sum.add(a.parsed.amount), + bn(0), + ); + // V2 (50) should still exist unchanged - we only created 50 in this test + // but beforeEach also compresses 500M to V2 + expect(v2Amount.gte(bn(50))).toBe(true); + }); + }); + + describe('Proof Generation - V1 Accounts', () => { + let owner: Signer; + + beforeEach(async () => { + owner = await newAccountWithLamports(rpc, 1e9); + }); + + it('getValidityProof works for V1 account hashes', async () => { + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const accounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + assertAccountInV1Tree(accounts.items[0]); + + const proof = await rpc.getValidityProof( + accounts.items.map(a => bn(a.compressedAccount.hash)), + ); + + expect(proof).toBeDefined(); + expect(proof.compressedProof).toBeDefined(); + expect(proof.rootIndices.length).toBe(1); + }); + + it('getValidityProofV0 works for V1 accounts', async () => { + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const accounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + + const proof = await rpc.getValidityProofV0( + accounts.items.map(a => ({ + hash: a.compressedAccount.hash, + tree: a.compressedAccount.treeInfo.tree, + queue: a.compressedAccount.treeInfo.queue, + })), + ); + + expect(proof).toBeDefined(); + expect(proof.compressedProof).toBeDefined(); + }); + + it('getValidityProof works for multiple V1 accounts', async () => { + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(100), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(200), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const accounts = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + expect(accounts.items.length).toBe(2); + accounts.items.forEach(a => assertAccountInV1Tree(a)); + + const proof = await rpc.getValidityProof( + accounts.items.map(a => bn(a.compressedAccount.hash)), + ); + + expect(proof).toBeDefined(); + expect(proof.rootIndices.length).toBe(2); + }); + }); + + describe('Real World Scenario - V1 to V2 Migration', () => { + it('user with V1 tokens auto-migrates to V2 through transfers', async () => { + const walletOwner = await newAccountWithLamports(rpc, 2e9); + + // Simulate user with multiple V1 token accounts (like Phantom user) + const amounts = [150, 75, 200, 50]; + for (const amount of amounts) { + await mintTo( + rpc, + payer, + mint, + walletOwner.publicKey, + mintAuthority, + bn(amount), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + } + + // Verify initial state - all V1 + let accounts = await rpc.getCompressedTokenAccountsByOwner( + walletOwner.publicKey, + { mint }, + ); + expect(accounts.items.length).toBe(4); + accounts.items.forEach(a => assertAccountInV1Tree(a)); + const totalInitial = amounts.reduce((a, b) => a + b, 0); // 475 + + // Transfer 1: send some tokens - auto-migrates to V2 + const friend1 = Keypair.generate().publicKey; + await transfer(rpc, payer, mint, bn(100), walletOwner, friend1); + + const friend1Accounts = await rpc.getCompressedTokenAccountsByOwner( + friend1, + { mint }, + ); + expect(friend1Accounts.items.length).toBe(1); + expect(friend1Accounts.items[0].parsed.amount.eq(bn(100))).toBe( + true, + ); + // Friend receives V2 tokens + assertAccountInV2Tree(friend1Accounts.items[0]); + + // Transfer 2: send more tokens - auto-migrates to V2 + const friend2 = Keypair.generate().publicKey; + await transfer(rpc, payer, mint, bn(200), walletOwner, friend2); + + const friend2Accounts = await rpc.getCompressedTokenAccountsByOwner( + friend2, + { mint }, + ); + expect(friend2Accounts.items.length).toBe(1); + expect(friend2Accounts.items[0].parsed.amount.eq(bn(200))).toBe( + true, + ); + // Friend receives V2 tokens + assertAccountInV2Tree(friend2Accounts.items[0]); + + // Verify remaining balance + accounts = await rpc.getCompressedTokenAccountsByOwner( + walletOwner.publicKey, + { mint }, + ); + const remainingBalance = accounts.items.reduce( + (sum, a) => sum.add(a.parsed.amount), + bn(0), + ); + // 475 - 100 - 200 = 175 + expect(remainingBalance.eq(bn(175))).toBe(true); + }); + + it('user can use approve with V1 accounts', async () => { + const walletOwner = await newAccountWithLamports(rpc, 2e9); + const delegate = Keypair.generate(); + + // Create V1 tokens + await mintTo( + rpc, + payer, + mint, + walletOwner.publicKey, + mintAuthority, + bn(1000), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Approve should work with V1 accounts (selects V1 since no V2) + const sig = await approve( + rpc, + payer, + mint, + bn(500), + walletOwner, + delegate.publicKey, + ); + + expect(sig).toBeDefined(); + }); + + it('user can merge V1 accounts to V2 then transfer', async () => { + const walletOwner = await newAccountWithLamports(rpc, 2e9); + + // Create multiple small V1 accounts + for (let i = 0; i < 3; i++) { + await mintTo( + rpc, + payer, + mint, + walletOwner.publicKey, + mintAuthority, + bn(100), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + } + + let accounts = await rpc.getCompressedTokenAccountsByOwner( + walletOwner.publicKey, + { mint }, + ); + expect(accounts.items.length).toBe(3); + accounts.items.forEach(a => assertAccountInV1Tree(a)); + + // Merge V1 accounts - auto-migrates to V2 + await mergeTokenAccounts(rpc, payer, mint, walletOwner); + + accounts = await rpc.getCompressedTokenAccountsByOwner( + walletOwner.publicKey, + { mint }, + ); + expect(accounts.items.length).toBe(1); + expect(accounts.items[0].parsed.amount.eq(bn(300))).toBe(true); + // Merged account is now V2 + assertAccountInV2Tree(accounts.items[0]); + + // Now transfer from merged V2 account + const recipient = Keypair.generate().publicKey; + await transfer(rpc, payer, mint, bn(250), walletOwner, recipient); + + const recipientAccounts = + await rpc.getCompressedTokenAccountsByOwner(recipient, { + mint, + }); + expect(recipientAccounts.items.length).toBe(1); + expect(recipientAccounts.items[0].parsed.amount.eq(bn(250))).toBe( + true, + ); + assertAccountInV2Tree(recipientAccounts.items[0]); + + // Verify change is also V2 + accounts = await rpc.getCompressedTokenAccountsByOwner( + walletOwner.publicKey, + { mint }, + ); + expect(accounts.items.length).toBe(1); + expect(accounts.items[0].parsed.amount.eq(bn(50))).toBe(true); + assertAccountInV2Tree(accounts.items[0]); + }); + }); +}); diff --git a/js/compressed-token/tests/e2e/v3-interface-migration.test.ts b/js/compressed-token/tests/e2e/v3-interface-migration.test.ts new file mode 100644 index 0000000000..aaec2b48de --- /dev/null +++ b/js/compressed-token/tests/e2e/v3-interface-migration.test.ts @@ -0,0 +1,360 @@ +/** + * V3 Interface V1/V2 Test Suite + * + * Tests that v3 interface rejects V1 accounts with meaningful errors. + * V1 users must use main SDK actions (transfer, decompress, merge) to migrate. + */ +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { Keypair, PublicKey, Signer } from '@solana/web3.js'; +import { + Rpc, + bn, + newAccountWithLamports, + getTestRpc, + selectStateTreeInfo, + TreeInfo, + TreeType, + featureFlags, + VERSION, +} from '@lightprotocol/stateless.js'; +import { WasmFactory } from '@lightprotocol/hasher.rs'; +import { createMint, mintTo } from '../../src/actions'; +import { + getTokenPoolInfos, + selectTokenPoolInfo, + TokenPoolInfo, +} from '../../src/utils/get-token-pool-infos'; +import { + decompressInterface, + getAtaInterface, + getAssociatedTokenAddressInterface, + transferInterface, + createAtaInterfaceIdempotent, +} from '../../src/v3'; +import { createLoadAtaInstructions, loadAta } from '../../src/index'; + +featureFlags.version = VERSION.V2; + +const TEST_TOKEN_DECIMALS = 9; + +describe('v3-interface-v1-rejection', () => { + let rpc: Rpc; + let payer: Signer; + let mintAuthority: Keypair; + let mint: PublicKey; + let treeInfos: TreeInfo[]; + let v1TreeInfo: TreeInfo; + let v2TreeInfo: TreeInfo; + let tokenPoolInfos: TokenPoolInfo[]; + + beforeAll(async () => { + const lightWasm = await WasmFactory.getInstance(); + rpc = await getTestRpc(lightWasm); + payer = await newAccountWithLamports(rpc, 10e9); + mintAuthority = Keypair.generate(); + + const mintKeypair = Keypair.generate(); + mint = ( + await createMint( + rpc, + payer, + mintAuthority.publicKey, + TEST_TOKEN_DECIMALS, + mintKeypair, + ) + ).mint; + + treeInfos = await rpc.getStateTreeInfos(); + v1TreeInfo = selectStateTreeInfo(treeInfos, TreeType.StateV1); + v2TreeInfo = selectStateTreeInfo(treeInfos, TreeType.StateV2); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 120_000); + + describe('decompressInterface', () => { + let owner: Signer; + + beforeEach(async () => { + owner = await newAccountWithLamports(rpc, 1e9); + }); + + it('rejects V1 accounts with meaningful error', async () => { + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + await expect( + decompressInterface(rpc, payer, owner, mint, bn(500)), + ).rejects.toThrow( + 'v3 interface does not support V1 compressed accounts', + ); + }); + + it('rejects mixed V1+V2 accounts', async () => { + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(300), + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + await expect( + decompressInterface(rpc, payer, owner, mint, bn(200)), + ).rejects.toThrow( + 'v3 interface does not support V1 compressed accounts', + ); + }); + + it('succeeds with only V2 accounts', async () => { + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const sig = await decompressInterface( + rpc, + payer, + owner, + mint, + bn(500), + ); + expect(sig).toBeDefined(); + expect(sig).not.toBeNull(); + }); + }); + + describe('loadAta / createLoadAtaInstructions', () => { + let owner: Signer; + let ctokenAta: PublicKey; + + beforeEach(async () => { + owner = await newAccountWithLamports(rpc, 1e9); + ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + }); + + it('rejects V1 accounts with meaningful error', async () => { + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + await expect( + createLoadAtaInstructions( + rpc, + ctokenAta, + owner.publicKey, + mint, + payer.publicKey, + ), + ).rejects.toThrow( + 'v3 interface does not support V1 compressed accounts', + ); + }); + + it('rejects mixed V1+V2 accounts', async () => { + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(300), + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + await expect( + createLoadAtaInstructions( + rpc, + ctokenAta, + owner.publicKey, + mint, + payer.publicKey, + ), + ).rejects.toThrow( + 'v3 interface does not support V1 compressed accounts', + ); + }); + + it('succeeds with only V2 accounts', async () => { + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const sig = await loadAta(rpc, ctokenAta, owner, mint, payer); + expect(sig === null || typeof sig === 'string').toBe(true); + }); + }); + + describe('getAtaInterface', () => { + let owner: Signer; + + beforeEach(async () => { + owner = await newAccountWithLamports(rpc, 1e9); + }); + + it('discovers V2 accounts correctly', async () => { + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + v2TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const ataInfo = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + mint, + ); + + expect(ataInfo.parsed.amount).toBeGreaterThanOrEqual(BigInt(1000)); + }); + + it('discovers V1 accounts (read-only, no error)', async () => { + // getAtaInterface is read-only, so it can discover V1 accounts + // The error happens when trying to USE them in operations + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const ataInfo = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + mint, + ); + + // Should discover the V1 balance + expect(ataInfo.isCold).toBe(true); + }); + }); + + describe('transferInterface', () => { + let owner: Signer; + let recipient: Signer; + + beforeEach(async () => { + owner = await newAccountWithLamports(rpc, 1e9); + recipient = await newAccountWithLamports(rpc, 1e9); + }); + + it('rejects V1 accounts with meaningful error', async () => { + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + v1TreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const sourceAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const destAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); + + await createAtaInterfaceIdempotent( + rpc, + payer, + recipient.publicKey, + mint, + ); + + // transferInterface(rpc, payer, source, mint, destination, owner, amount) + await expect( + transferInterface( + rpc, + payer, + sourceAta, + mint, + destAta, + owner, + BigInt(500), + ), + ).rejects.toThrow( + 'v3 interface does not support V1 compressed accounts', + ); + }); + + // Note: V2 success case is covered by existing v3 interface tests. + // The V1 rejection test validates our assertV2Only check works. + }); +}); diff --git a/js/compressed-token/tests/unit/unified-guards.test.ts b/js/compressed-token/tests/unit/unified-guards.test.ts index 76fb19a81d..88b5909113 100644 --- a/js/compressed-token/tests/unit/unified-guards.test.ts +++ b/js/compressed-token/tests/unit/unified-guards.test.ts @@ -4,7 +4,11 @@ import { TOKEN_PROGRAM_ID, getAssociatedTokenAddressSync, } from '@solana/spl-token'; -import { Rpc, CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { + Rpc, + CTOKEN_PROGRAM_ID, + featureFlags, +} from '@lightprotocol/stateless.js'; import { getAtaProgramId } from '../../src/v3/ata-utils'; import { @@ -43,24 +47,34 @@ describe('unified guards', () => { ).not.toThrow(); }); - it('throws when unified createLoadAtaInstructions receives non c-token ATA', async () => { - const rpc = {} as Rpc; - const owner = Keypair.generate().publicKey; - const mint = Keypair.generate().publicKey; + // Skip unless V2+beta - createLoadAtaInstructions is a V2-only interface method requiring beta + it.skipIf(!featureFlags.isV2() || !featureFlags.isBeta())( + 'throws when unified createLoadAtaInstructions receives non c-token ATA', + async () => { + const rpc = {} as Rpc; + const owner = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; - // Derive SPL ATA using base function (not unified) - const wrongAta = getAssociatedTokenAddressSync( - mint, - owner, - false, - TOKEN_PROGRAM_ID, - getAtaProgramId(TOKEN_PROGRAM_ID), - ); + // Derive SPL ATA using base function (not unified) + const wrongAta = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + getAtaProgramId(TOKEN_PROGRAM_ID), + ); - await expect( - unifiedCreateLoadAtaInstructions(rpc, wrongAta, owner, mint, owner), - ).rejects.toThrow( - 'For wrap=true, ata must be the c-token ATA. Got spl ATA instead.', - ); - }); + await expect( + unifiedCreateLoadAtaInstructions( + rpc, + wrongAta, + owner, + mint, + owner, + ), + ).rejects.toThrow( + 'For wrap=true, ata must be the c-token ATA. Got spl ATA instead.', + ); + }, + ); }); diff --git a/js/stateless.js/package.json b/js/stateless.js/package.json index 7c321c2437..5af23fb56a 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.8", + "version": "0.23.0-beta.3", "description": "JavaScript API for Light & ZK Compression", "sideEffects": false, "main": "dist/cjs/node/index.cjs", @@ -88,7 +88,7 @@ "test": "pnpm test:unit:all && pnpm test:e2e:all", "test-ci": "vitest run tests/unit && pnpm test:e2e:all", "test:v1": "pnpm build:v1 && LIGHT_PROTOCOL_VERSION=V1 vitest run tests/unit && LIGHT_PROTOCOL_VERSION=V1 pnpm test:e2e:all", - "test:v2": "pnpm build:v2 && LIGHT_PROTOCOL_VERSION=V2 vitest run tests/unit && LIGHT_PROTOCOL_VERSION=V2 pnpm test:e2e:all", + "test:v2": "pnpm build:v2 && LIGHT_PROTOCOL_VERSION=V2 vitest run tests/unit && LIGHT_PROTOCOL_VERSION=V2 LIGHT_PROTOCOL_BETA=true pnpm test:e2e:all", "test-all": "vitest run", "test:unit:all": "vitest run tests/unit --reporter=verbose", "test:unit:all:v1": "LIGHT_PROTOCOL_VERSION=V1 vitest run tests/unit --reporter=verbose", @@ -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 && 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: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 && if [ \"$LIGHT_PROTOCOL_VERSION\" != \"V1\" ]; then LIGHT_PROTOCOL_VERSION=V2 LIGHT_PROTOCOL_BETA=true vitest run tests/e2e/interface-methods.test.ts; fi && 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/actions/create-account.ts b/js/stateless.js/src/actions/create-account.ts index 7b128a8e48..9590ee50b1 100644 --- a/js/stateless.js/src/actions/create-account.ts +++ b/js/stateless.js/src/actions/create-account.ts @@ -13,10 +13,8 @@ import { Rpc } from '../rpc'; import { NewAddressParams, buildAndSignTx, - deriveAddress, - deriveAddressSeed, - deriveAddressSeedV2, - deriveAddressV2, + deriveAddressLegacy, + deriveAddressSeedLegacy, selectStateTreeInfo, sendAndConfirmTx, } from '../utils'; @@ -62,8 +60,8 @@ export async function createAccount( ); } - const seed = deriveAddressSeed(seeds, programId); - const address = deriveAddress(seed, tree); + const seed = deriveAddressSeedLegacy(seeds, programId); + const address = deriveAddressLegacy(seed, tree); if (!outputStateTreeInfo) { const stateTreeInfo = await rpc.getStateTreeInfos(); @@ -159,8 +157,8 @@ export async function createAccountWithLamports( ); } - const seed = deriveAddressSeed(seeds, programId); - const address = deriveAddress(seed, tree); + const seed = deriveAddressSeedLegacy(seeds, programId); + const address = deriveAddressLegacy(seed, tree); const proof = await rpc.getValidityProof( inputAccounts.map(account => account.hash), diff --git a/js/stateless.js/src/constants.ts b/js/stateless.js/src/constants.ts index cd73f8be24..d447d0dbb4 100644 --- a/js/stateless.js/src/constants.ts +++ b/js/stateless.js/src/constants.ts @@ -8,11 +8,12 @@ export enum VERSION { V2 = 'V2', } -/** /** * @internal * Feature flags. Only use if you know what you are doing. */ +let _betaEnabled = true; // Default to enabled for beta releases + export const featureFlags = { version: ((): VERSION => { // Check if we're in a build environment (replaced by rollup) @@ -32,8 +33,69 @@ export const featureFlags = { })(), isV2: () => featureFlags.version.replace(/['"]/g, '').toUpperCase() === 'V2', + /** + * Beta flag for interface methods (not yet deployed on mainnet). + * Checks programmatic override first, then env var LIGHT_PROTOCOL_BETA. + */ + isBeta: (): boolean => { + if (_betaEnabled) { + return true; + } + if ( + typeof process !== 'undefined' && + process.env?.LIGHT_PROTOCOL_BETA + ) { + const val = process.env.LIGHT_PROTOCOL_BETA.toLowerCase(); + return val === 'true' || val === '1'; + } + return false; + }, + /** + * Enable beta features programmatically. + * Call this once at app initialization to unlock interface methods. + * + * @example + * ```typescript + * import { featureFlags } from '@lightprotocol/stateless.js'; + * featureFlags.enableBeta(); + * ``` + */ + enableBeta: (): void => { + _betaEnabled = true; + }, + /** + * Disable beta features programmatically. + */ + disableBeta: (): void => { + _betaEnabled = false; + }, }; +/** + * Error message for beta-gated interface methods. + */ +export const BETA_REQUIRED_ERROR = + 'Interface methods require beta feature flag. ' + + 'These features are not yet deployed on mainnet (only localnet/devnet). ' + + 'Set LIGHT_PROTOCOL_BETA=true to enable.'; + +/** + * Assert that beta features are enabled. + * Throws if V2 is not enabled OR if beta is not enabled. + * + * Use this at the entry point of all interface methods. + */ +export function assertBetaEnabled(): void { + if (!featureFlags.isV2()) { + throw new Error( + 'Interface methods require V2. Set LIGHT_PROTOCOL_VERSION=V2.', + ); + } + if (!featureFlags.isBeta()) { + throw new Error(BETA_REQUIRED_ERROR); + } +} + /** * Returns the correct endpoint name for the current API version. E.g. * versionedEndpoint('getCompressedAccount') -> 'getCompressedAccount' (V1) diff --git a/js/stateless.js/src/rpc.ts b/js/stateless.js/src/rpc.ts index 23f48719cc..bce24ea030 100644 --- a/js/stateless.js/src/rpc.ts +++ b/js/stateless.js/src/rpc.ts @@ -91,6 +91,7 @@ import { batchAddressTree, CTOKEN_PROGRAM_ID, getDefaultAddressSpace, + assertBetaEnabled, } from './constants'; import { setDevnetCompat } from './devnet-compat'; import BN from 'bn.js'; @@ -1985,9 +1986,13 @@ export class Rpc extends Connection implements CompressionApiInterface { ): Promise> { validateNumbersForProof(hashes.length, newAddresses.length); + // V2 endpoint is backward compatible - it generates correct proofs + // for both V1 and V2 trees based on tree height, not endpoint choice. + const endpoint = versionedEndpoint('getValidityProof'); + const unsafeRes = await rpcRequest( this.compressionApiEndpoint, - versionedEndpoint('getValidityProof'), + endpoint, { hashes: hashes.map(({ hash }) => encodeBN254toBase58(hash)), newAddressesWithTrees: newAddresses.map( @@ -1999,8 +2004,10 @@ export class Rpc extends Connection implements CompressionApiInterface { }, ); + const useV2Parsing = featureFlags.isV2(); + let res; - if (featureFlags.isV2()) { + if (useV2Parsing) { res = create( unsafeRes, jsonRpcResultAndContext(ValidityProofResultV2), @@ -2026,7 +2033,7 @@ export class Rpc extends Connection implements CompressionApiInterface { const value = res.result.value as any; - if (featureFlags.isV2()) { + if (useV2Parsing) { return { value: { compressedProof: value.compressedProof, @@ -2112,9 +2119,7 @@ export class Rpc extends Connection implements CompressionApiInterface { isCold: boolean; loadContext?: MerkleContext; } | null> { - if (!featureFlags.isV2()) { - throw new Error('getAccountInfoInterface requires feature flag V2'); - } + assertBetaEnabled(); addressSpace = addressSpace ?? getDefaultAddressSpace(); @@ -2202,6 +2207,8 @@ export class Rpc extends Connection implements CompressionApiInterface { options?: SignaturesForAddressOptions, compressedOptions?: PaginatedOptions, ): Promise { + assertBetaEnabled(); + const [solanaResult, compressedResult] = await Promise.allSettled([ this.getSignaturesForAddress(address, options), this.getCompressionSignaturesForAddress(address, compressedOptions), @@ -2239,6 +2246,8 @@ export class Rpc extends Connection implements CompressionApiInterface { options?: SignaturesForAddressOptions, compressedOptions?: PaginatedOptions, ): Promise { + assertBetaEnabled(); + const [solanaResult, compressedResult] = await Promise.allSettled([ this.getSignaturesForAddress(owner, options), this.getCompressionSignaturesForOwner(owner, compressedOptions), @@ -2279,6 +2288,8 @@ export class Rpc extends Connection implements CompressionApiInterface { mint: PublicKey, commitment?: Commitment, ): Promise { + assertBetaEnabled(); + const [onChainResult, compressedResult] = await Promise.allSettled([ this.getTokenAccountBalance(address, commitment), this.getCompressedTokenBalancesByOwner(owner, { mint }), @@ -2328,6 +2339,8 @@ export class Rpc extends Connection implements CompressionApiInterface { address: PublicKey, commitment?: Commitment, ): Promise { + assertBetaEnabled(); + const [onChainResult, compressedResult] = await Promise.allSettled([ this.getBalance(address, commitment), this.getCompressedBalanceByOwner(address), diff --git a/js/stateless.js/src/utils/address.ts b/js/stateless.js/src/utils/address.ts index fd5811b58a..2b2e03d023 100644 --- a/js/stateless.js/src/utils/address.ts +++ b/js/stateless.js/src/utils/address.ts @@ -4,11 +4,15 @@ import { hashvToBn254FieldSizeBe, hashvToBn254FieldSizeBeU8Array, } from './conversion'; -import { defaultTestStateTreeAccounts } from '../constants'; +import { defaultTestStateTreeAccounts, featureFlags } from '../constants'; import { getIndexOrAdd } from '../programs/system/pack'; import { keccak_256 } from '@noble/hashes/sha3'; -export function deriveAddressSeed( +/** + * @deprecated Use deriveAddressSeed (no programId param) for V2. + * V1 seed derivation includes programId in hash. + */ +export function deriveAddressSeedLegacy( seeds: Uint8Array[], programId: PublicKey, ): Uint8Array { @@ -17,16 +21,11 @@ export function deriveAddressSeed( return hash; } -/* - * Derive an address for a compressed account from a seed and an address Merkle - * tree public key. - * - * @param seed Seed to derive the address from - * @param addressMerkleTreePubkey Merkle tree public key. Defaults to - * defaultTestStateTreeAccounts().addressTree - * @returns Derived address +/** + * @deprecated Use deriveAddress with programId param for V2. + * V1 address derivation doesn't include programId. */ -export function deriveAddress( +export function deriveAddressLegacy( seed: Uint8Array, addressMerkleTreePubkey: PublicKey = defaultTestStateTreeAccounts() .addressTree, @@ -45,21 +44,57 @@ export function deriveAddress( return new PublicKey(buf); } +/** + * Explicit V2 address seed derivation - always uses V2 behavior regardless of feature flag. + * Use this when you explicitly want V2 derivation (e.g., in tests). + */ export function deriveAddressSeedV2(seeds: Uint8Array[]): Uint8Array { const combinedSeeds: Uint8Array[] = seeds.map(seed => Uint8Array.from(seed), ); - const hash = hashvToBn254FieldSizeBeU8Array(combinedSeeds); - return hash; + return hashvToBn254FieldSizeBeU8Array(combinedSeeds); } /** - * Derives an address from a seed using the v2 method (matching Rust's derive_address_from_seed) + * Derive an address seed from multiple seed components. * - * @param addressSeed The address seed (32 bytes) - * @param addressMerkleTreePubkey Merkle tree public key - * @param programId Program ID - * @returns Derived address + * V2 mode: Does NOT include programId in seed hash. Do not pass programId. + * V1 mode: Includes programId in seed hash. Must pass programId. + * + * @param seeds Array of seed bytes to combine + * @param programId (V1 only) Program ID - required in V1 mode, must be omitted in V2 mode + * @returns 32-byte address seed + */ +export function deriveAddressSeed( + seeds: Uint8Array[], + programId?: PublicKey, +): Uint8Array { + const isV2 = featureFlags.isV2(); + + if (programId !== undefined) { + if (isV2) { + throw new Error( + 'deriveAddressSeed: programId must not be passed in V2 mode. ' + + 'For V2, omit programId here and pass it to deriveAddress instead. ' + + 'If you need V1 behavior, set LIGHT_PROTOCOL_VERSION=V1.', + ); + } + return deriveAddressSeedLegacy(seeds, programId); + } + + if (!isV2) { + throw new Error( + 'deriveAddressSeed: programId is required in V1 mode. ' + + 'Pass programId as the second argument. ' + + 'If you need V2 behavior, set LIGHT_PROTOCOL_VERSION=V2.', + ); + } + return deriveAddressSeedV2(seeds); +} + +/** + * Explicit V2 address derivation - always uses V2 behavior regardless of feature flag. + * Use this when you explicitly want V2 derivation (e.g., in tests). */ export function deriveAddressV2( addressSeed: Uint8Array, @@ -71,7 +106,6 @@ export function deriveAddressV2( } const merkleTreeBytes = addressMerkleTreePubkey.toBytes(); const programIdBytes = programId.toBytes(); - // Match Rust implementation: hash [seed, merkle_tree_pubkey, program_id] const combined = [ Uint8Array.from(addressSeed), Uint8Array.from(merkleTreeBytes), @@ -81,6 +115,45 @@ export function deriveAddressV2( return new PublicKey(hash); } +/** + * Derive an address for a compressed account. + * + * V2 mode: Requires programId, includes it in final hash. + * V1 mode: Must not pass programId, uses legacy derivation. + * + * @param addressSeed The address seed (32 bytes) from deriveAddressSeed + * @param addressMerkleTreePubkey Address tree public key + * @param programId (V2 only) Program ID - required in V2 mode, must be omitted in V1 mode + * @returns Derived address + */ +export function deriveAddress( + addressSeed: Uint8Array, + addressMerkleTreePubkey: PublicKey, + programId?: PublicKey, +): PublicKey { + const isV2 = featureFlags.isV2(); + + if (programId === undefined) { + if (isV2) { + throw new Error( + 'deriveAddress: programId is required in V2 mode. ' + + 'Pass programId as the third argument. ' + + 'If you need V1 behavior, set LIGHT_PROTOCOL_VERSION=V1.', + ); + } + return deriveAddressLegacy(addressSeed, addressMerkleTreePubkey); + } + + if (!isV2) { + throw new Error( + 'deriveAddress: programId must not be passed in V1 mode. ' + + 'Omit programId from deriveAddress. ' + + 'If you need V2 behavior, set LIGHT_PROTOCOL_VERSION=V2.', + ); + } + return deriveAddressV2(addressSeed, addressMerkleTreePubkey, programId); +} + export interface NewAddressParams { /** * Seed for the compressed account. Must be seed used to derive @@ -173,13 +246,13 @@ if (import.meta.vitest) { '7yucc7fL3JGbyMwg4neUaenNSdySS39hbAk89Ao3t1Hz', ); - describe('derive address seed', () => { - it('should derive a valid address seed', () => { + describe('derive address seed (legacy/V1)', () => { + it('should derive a valid address seed with programId', () => { const seeds: Uint8Array[] = [ new TextEncoder().encode('foo'), new TextEncoder().encode('bar'), ]; - expect(deriveAddressSeed(seeds, programId)).toStrictEqual( + expect(deriveAddressSeedLegacy(seeds, programId)).toStrictEqual( new Uint8Array([ 0, 246, 150, 3, 192, 95, 53, 123, 56, 139, 206, 179, 253, 133, 115, 103, 120, 155, 251, 72, 250, 47, 117, 217, 118, @@ -188,12 +261,12 @@ if (import.meta.vitest) { ); }); - it('should derive a valid address seed', () => { + it('should derive a valid address seed with programId', () => { const seeds: Uint8Array[] = [ new TextEncoder().encode('ayy'), new TextEncoder().encode('lmao'), ]; - expect(deriveAddressSeed(seeds, programId)).toStrictEqual( + expect(deriveAddressSeedLegacy(seeds, programId)).toStrictEqual( new Uint8Array([ 0, 202, 44, 25, 221, 74, 144, 92, 69, 168, 38, 19, 206, 208, 29, 162, 53, 27, 120, 214, 152, 116, 15, 107, 212, 168, 33, @@ -203,17 +276,17 @@ if (import.meta.vitest) { }); }); - describe('deriveAddress function', () => { + describe('deriveAddress function (legacy/V1)', () => { it('should derive a valid address from a seed and a merkle tree public key', async () => { const seeds: Uint8Array[] = [ new TextEncoder().encode('foo'), new TextEncoder().encode('bar'), ]; - const seed = deriveAddressSeed(seeds, programId); + const seed = deriveAddressSeedLegacy(seeds, programId); const merkleTreePubkey = new PublicKey( '11111111111111111111111111111111', ); - const derivedAddress = deriveAddress(seed, merkleTreePubkey); + const derivedAddress = deriveAddressLegacy(seed, merkleTreePubkey); expect(derivedAddress).toBeInstanceOf(PublicKey); expect(derivedAddress).toStrictEqual( new PublicKey('139uhyyBtEh4e1CBDJ68ooK5nCeWoncZf9HPyAfRrukA'), @@ -225,11 +298,11 @@ if (import.meta.vitest) { new TextEncoder().encode('ayy'), new TextEncoder().encode('lmao'), ]; - const seed = deriveAddressSeed(seeds, programId); + const seed = deriveAddressSeedLegacy(seeds, programId); const merkleTreePubkey = new PublicKey( '11111111111111111111111111111111', ); - const derivedAddress = deriveAddress(seed, merkleTreePubkey); + const derivedAddress = deriveAddressLegacy(seed, merkleTreePubkey); expect(derivedAddress).toBeInstanceOf(PublicKey); expect(derivedAddress).toStrictEqual( new PublicKey('12bhHm6PQjbNmEn3Yu1Gq9k7XwVn2rZpzYokmLwbFazN'), diff --git a/js/stateless.js/src/utils/instruction.ts b/js/stateless.js/src/utils/instruction.ts index 0e2b10e373..cb824ef4ec 100644 --- a/js/stateless.js/src/utils/instruction.ts +++ b/js/stateless.js/src/utils/instruction.ts @@ -1,5 +1,5 @@ import { AccountMeta, PublicKey, SystemProgram } from '@solana/web3.js'; -import { defaultStaticAccountsStruct } from '../constants'; +import { defaultStaticAccountsStruct, featureFlags } from '../constants'; import { LightSystemProgram } from '../programs'; export class PackedAccounts { @@ -8,6 +8,10 @@ export class PackedAccounts { private nextIndex: number = 0; private map: Map = new Map(); + /** + * Create PackedAccounts with system accounts pre-added. + * Auto-selects V1 or V2 account layout based on featureFlags. + */ static newWithSystemAccounts( config: SystemAccountMetaConfig, ): PackedAccounts { @@ -16,6 +20,9 @@ export class PackedAccounts { return instance; } + /** + * @deprecated Use newWithSystemAccounts - it auto-selects V2 when appropriate. + */ static newWithSystemAccountsV2( config: SystemAccountMetaConfig, ): PackedAccounts { @@ -36,10 +43,22 @@ export class PackedAccounts { this.preAccounts.push(accountMeta); } + /** + * Add Light system accounts. Auto-selects V1 or V2 layout based on featureFlags. + */ addSystemAccounts(config: SystemAccountMetaConfig): void { - this.systemAccounts.push(...getLightSystemAccountMetas(config)); + if (featureFlags.isV2()) { + this.systemAccounts.push(...getLightSystemAccountMetasV2(config)); + } else { + this.systemAccounts.push( + ...getLightSystemAccountMetasLegacy(config), + ); + } } + /** + * @deprecated Use addSystemAccounts - it auto-selects V2 when appropriate. + */ addSystemAccountsV2(config: SystemAccountMetaConfig): void { this.systemAccounts.push(...getLightSystemAccountMetasV2(config)); } @@ -128,7 +147,10 @@ export class SystemAccountMetaConfig { } } -export function getLightSystemAccountMetas( +/** + * @deprecated V1 system account layout. Use getLightSystemAccountMetas which auto-selects. + */ +export function getLightSystemAccountMetasLegacy( config: SystemAccountMetaConfig, ): AccountMeta[] { let signerSeed = new TextEncoder().encode('cpi_authority'); @@ -191,6 +213,18 @@ export function getLightSystemAccountMetas( return metas; } +/** + * Get Light system account metas. Auto-selects V1 or V2 layout based on featureFlags. + */ +export function getLightSystemAccountMetas( + config: SystemAccountMetaConfig, +): AccountMeta[] { + if (featureFlags.isV2()) { + return getLightSystemAccountMetasV2(config); + } + return getLightSystemAccountMetasLegacy(config); +} + export function getLightSystemAccountMetasV2( config: SystemAccountMetaConfig, ): AccountMeta[] { diff --git a/js/stateless.js/tests/e2e/rpc-interop.test.ts b/js/stateless.js/tests/e2e/rpc-interop.test.ts index f41f60a91c..2fd459c594 100644 --- a/js/stateless.js/tests/e2e/rpc-interop.test.ts +++ b/js/stateless.js/tests/e2e/rpc-interop.test.ts @@ -10,7 +10,9 @@ import { createAccount, createAccountWithLamports, deriveAddress, + deriveAddressLegacy, deriveAddressSeed, + deriveAddressSeedLegacy, featureFlags, getDefaultAddressTreeInfo, selectStateTreeInfo, @@ -147,12 +149,14 @@ describe('rpc-interop', () => { 'getValidityProof [noforester] (new-addresses) should match', async () => { const newAddressSeeds = [new Uint8Array(randomBytes(32))]; - const newAddressSeed = deriveAddressSeed( + const newAddressSeed = deriveAddressSeedLegacy( newAddressSeeds, LightSystemProgram.programId, ); - const newAddress = bn(deriveAddress(newAddressSeed).toBuffer()); + const newAddress = bn( + deriveAddressLegacy(newAddressSeed).toBuffer(), + ); /// consistent proof metadata for same address const validityProof = await rpc.getValidityProof([], [newAddress]); @@ -230,11 +234,13 @@ describe('rpc-interop', () => { assert.isTrue(hash.eq(hashTest)); const newAddressSeeds = [new Uint8Array(randomBytes(32))]; - const newAddressSeed = deriveAddressSeed( + const newAddressSeed = deriveAddressSeedLegacy( newAddressSeeds, LightSystemProgram.programId, ); - const newAddress = bn(deriveAddress(newAddressSeed).toBytes()); + const newAddress = bn( + deriveAddressLegacy(newAddressSeed).toBytes(), + ); const validityProof = await rpc.getValidityProof( [hash], @@ -343,14 +349,22 @@ describe('rpc-interop', () => { /// This assumes support for getMultipleNewAddressProofs in Photon. it('getMultipleNewAddressProofs [noforester] should match', async () => { - const newAddress = bn( - deriveAddress( - deriveAddressSeed( - [new Uint8Array(randomBytes(32))], - LightSystemProgram.programId, - ), - ).toBytes(), - ); + const addressTreeInfo = getDefaultAddressTreeInfo(); + const seed = new Uint8Array(randomBytes(32)); + const newAddress = featureFlags.isV2() + ? bn( + deriveAddress( + deriveAddressSeed([seed]), + addressTreeInfo.tree, + LightSystemProgram.programId, + ).toBytes(), + ) + : bn( + deriveAddress( + deriveAddressSeed([seed], LightSystemProgram.programId), + addressTreeInfo.tree, + ).toBytes(), + ); const newAddressProof = ( await rpc.getMultipleNewAddressProofs([newAddress]) )[0]; @@ -494,6 +508,9 @@ describe('rpc-interop', () => { ); it('getCompressedAccountsByOwner should match', async () => { + // Wait for Photon indexer to catch up with all prior transactions + await sleep(3000); + const senderAccounts = await rpc.getCompressedAccountsByOwner( payer.publicKey, ); @@ -702,9 +719,12 @@ describe('rpc-interop', () => { '[test-rpc missing] getCompressionSignaturesForAddress should work', async () => { const seeds = [new Uint8Array(randomBytes(32))]; - const seed = deriveAddressSeed(seeds, LightSystemProgram.programId); + const seed = deriveAddressSeedLegacy( + seeds, + LightSystemProgram.programId, + ); const addressTreeInfo = getDefaultAddressTreeInfo(); - const address = deriveAddress(seed, addressTreeInfo.tree); + const address = deriveAddressLegacy(seed, addressTreeInfo.tree); await createAccount( rpc, @@ -747,10 +767,13 @@ describe('rpc-interop', () => { '[test-rpc missing] getCompressedAccount with address param should work ', async () => { const seeds = [new Uint8Array(randomBytes(32))]; - const seed = deriveAddressSeed(seeds, LightSystemProgram.programId); + const seed = deriveAddressSeedLegacy( + seeds, + LightSystemProgram.programId, + ); const addressTreeInfo = getDefaultAddressTreeInfo(); - const address = deriveAddress(seed, addressTreeInfo.tree); + const address = deriveAddressLegacy(seed, addressTreeInfo.tree); await createAccount( rpc, diff --git a/js/stateless.js/tests/e2e/rpc-multi-trees.test.ts b/js/stateless.js/tests/e2e/rpc-multi-trees.test.ts index 107b15fecb..581793ca1b 100644 --- a/js/stateless.js/tests/e2e/rpc-multi-trees.test.ts +++ b/js/stateless.js/tests/e2e/rpc-multi-trees.test.ts @@ -9,11 +9,11 @@ import { compress, createAccount, createAccountWithLamports, - deriveAddress, - deriveAddressSeed, + defaultTestStateTreeAccounts, featureFlags, selectStateTreeInfo, } from '../../src'; +import { deriveAddress, deriveAddressSeed } from '../../src/utils/address'; import { getTestRpc, TestRpc } from '../../src/test-helpers/test-rpc'; import { transfer } from '../../src/actions/transfer'; import { WasmFactory } from '@lightprotocol/hasher.rs'; @@ -91,7 +91,10 @@ describe('rpc-multi-trees', () => { [seed], LightSystemProgram.programId, ); - address = deriveAddress(addressSeed); + address = deriveAddress( + addressSeed, + defaultTestStateTreeAccounts().addressTree, + ); await createAccount( rpc, @@ -159,7 +162,12 @@ describe('rpc-multi-trees', () => { newAddressSeeds, LightSystemProgram.programId, ); - const newAddress = bn(deriveAddress(newAddressSeed).toBytes()); + const newAddress = bn( + deriveAddress( + newAddressSeed, + defaultTestStateTreeAccounts().addressTree, + ).toBytes(), + ); const validityProof = await rpc.getValidityProof( [hash], diff --git a/js/stateless.js/tests/unit/version.test.ts b/js/stateless.js/tests/unit/version.test.ts index 76285d96fd..97db06c8be 100644 --- a/js/stateless.js/tests/unit/version.test.ts +++ b/js/stateless.js/tests/unit/version.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { featureFlags, VERSION } from '../../src/constants'; +import { + featureFlags, + VERSION, + assertBetaEnabled, + BETA_REQUIRED_ERROR, +} from '../../src/constants'; describe('Version System', () => { it('should have version set', () => { @@ -25,3 +30,33 @@ describe('Version System', () => { expect(featureFlags.isV2()).toBe(expectedIsV2); }); }); + +describe('assertBetaEnabled', () => { + it('should throw correct error based on version and beta flag', () => { + const isV2 = featureFlags.isV2(); + const isBeta = featureFlags.isBeta(); + + if (!isV2) { + // V1 mode: should throw V2 required error + expect(() => assertBetaEnabled()).toThrowError( + 'Interface methods require V2. Set LIGHT_PROTOCOL_VERSION=V2.', + ); + } else if (!isBeta) { + // V2 mode without beta: should throw beta required error + expect(() => assertBetaEnabled()).toThrowError(BETA_REQUIRED_ERROR); + } else { + // V2 mode with beta: should not throw + expect(() => assertBetaEnabled()).not.toThrow(); + } + }); + + it('V1 mode must reject interface methods with specific error message', () => { + // This test ensures the V1 guard is in place and produces the expected error. + // If running in V1 mode, this validates the error. If V2, it's a no-op check. + if (!featureFlags.isV2()) { + expect(() => assertBetaEnabled()).toThrowError( + /Interface methods require V2/, + ); + } + }); +}); diff --git a/scripts/lint.sh b/scripts/lint.sh index a12aaf558e..d60195131b 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -2,9 +2,9 @@ set -e -# JS linting -cd js/stateless.js && pnpm prettier --check . && pnpm lint && cd ../.. -cd js/compressed-token && pnpm prettier --check . && pnpm lint && cd ../.. +# JS linting (use subshells to avoid directory issues) +(cd js/stateless.js && pnpm prettier --write . && pnpm lint) +(cd js/compressed-token && pnpm prettier --write . && pnpm lint) # Rust linting cargo +nightly fmt --all -- --check diff --git a/scripts/release/bump-versions-and-publish-npm.sh b/scripts/release/bump-versions-and-publish-npm.sh index 5995c1be4c..e1587b7b28 100755 --- a/scripts/release/bump-versions-and-publish-npm.sh +++ b/scripts/release/bump-versions-and-publish-npm.sh @@ -3,6 +3,7 @@ # ./scripts/bump-versions-and-publish-npm.sh minor # ./scripts/bump-versions-and-publish-npm.sh patch @lightprotocol/stateless.js @lightprotocol/compressed-token # ./scripts/bump-versions-and-publish-npm.sh alpha @lightprotocol/stateless.js +# ./scripts/bump-versions-and-publish-npm.sh beta @lightprotocol/stateless.js @lightprotocol/compressed-token cd "$(git rev-parse --show-toplevel)" @@ -41,6 +42,11 @@ publish_package() { echo "Error occurred while publishing ${package_name}." return 1 fi + elif [ "$version_type" == "beta" ]; then + if ! (cd "${package_dir}" && pnpm version prerelease --preid beta && pnpm publish --tag beta --access public --no-git-checks --verbose); then + echo "Error occurred while publishing ${package_name}." + return 1 + fi else if ! (cd "${package_dir}" && pnpm version "${version_type}" && pnpm publish --access public --no-git-checks --verbose); then echo "Error occurred while publishing ${package_name}." @@ -62,6 +68,11 @@ if [ "$#" -eq 0 ]; then echo "Error occurred during bulk version bump and publish." error_occurred=1 fi + elif [ "$version_type" == "beta" ]; then + if ! pnpm -r exec -- pnpm version prerelease --preid beta || ! pnpm -r exec -- pnpm publish --tag beta --access public --verbose; then + echo "Error occurred during bulk version bump and publish." + error_occurred=1 + fi else if ! pnpm -r exec -- pnpm version "${version_type}" || ! pnpm -r exec -- pnpm publish --access public --verbose; then echo "Error occurred during bulk version bump and publish." diff --git a/sdk-tests/sdk-anchor-test/tests/test_v1.ts b/sdk-tests/sdk-anchor-test/tests/test_v1.ts index b757a2f911..5beb6b8c77 100644 --- a/sdk-tests/sdk-anchor-test/tests/test_v1.ts +++ b/sdk-tests/sdk-anchor-test/tests/test_v1.ts @@ -8,6 +8,7 @@ import { defaultTestStateTreeAccounts, deriveAddressSeed, deriveAddress, + featureFlags, PackedAccounts, Rpc, sleep, @@ -21,7 +22,10 @@ const anchorWalletPath = path.join(os.homedir(), ".config/solana/id.json"); process.env.ANCHOR_WALLET = anchorWalletPath; process.env.ANCHOR_PROVIDER_URL = "http://localhost:8899"; -describe("sdk-anchor-test-v1", () => { +// Skip V1 tests when running in V2 mode +const describeV1 = featureFlags.isV2() ? describe.skip : describe; + +describeV1("sdk-anchor-test-v1", () => { const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); const programId = new web3.PublicKey( diff --git a/sdk-tests/sdk-anchor-test/tests/test_v2.ts b/sdk-tests/sdk-anchor-test/tests/test_v2.ts index afae29805a..a24420ed25 100644 --- a/sdk-tests/sdk-anchor-test/tests/test_v2.ts +++ b/sdk-tests/sdk-anchor-test/tests/test_v2.ts @@ -7,6 +7,7 @@ import { createRpc, deriveAddressSeedV2, deriveAddressV2, + featureFlags, PackedAccounts, Rpc, sleep, @@ -20,7 +21,10 @@ const anchorWalletPath = path.join(os.homedir(), ".config/solana/id.json"); process.env.ANCHOR_WALLET = anchorWalletPath; process.env.ANCHOR_PROVIDER_URL = "http://localhost:8899"; -describe("sdk-anchor-test-v2", () => { +// Skip V2 tests when running in V1 mode +const describeV2 = featureFlags.isV2() ? describe : describe.skip; + +describeV2("sdk-anchor-test-v2", () => { const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); const programId = new web3.PublicKey(